diff options
Diffstat (limited to 'research/experiments/021-memory')
5 files changed, 261 insertions, 0 deletions
diff --git a/research/experiments/021-memory/2026-09-30-budget-linux-aarch64.jsonl b/research/experiments/021-memory/2026-09-30-budget-linux-aarch64.jsonl new file mode 100644 index 0000000..9b6fc4d --- /dev/null +++ b/research/experiments/021-memory/2026-09-30-budget-linux-aarch64.jsonl @@ -0,0 +1,2 @@ +{"case": "budget", "seconds": 30, "posted": 29411, "refused": 177258, "rss_began_kib": 3072, "rss_end_kib": 9808, "rss_per_post_bytes": 234.5, "store_bytes": 9512871, "samples": [[0.0, 3072, 0], [2.0, 6400, 13775], [4.01, 9248, 27484], [6.01, 9872, 29411], [8.01, 9856, 29411], [10.02, 9936, 29411], [12.02, 9808, 29411], [14.02, 9936, 29411], [16.02, 9872, 29411], [18.03, 9888, 29411], [20.03, 9840, 29411], [22.03, 9856, 29411], [24.04, 10016, 29411], [26.04, 9872, 29411], [28.04, 9808, 29411], [30.05, 9872, 29411], [30.05, 9872, 29411]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 4, "requests": 206669, "bursts": 150678, "burst_events": 29411, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 17913, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 0, "answers": 0, "commit_groups": 150679, "grouped_commits": 150679, "checkpoints": 0, "link_packets": 0, "link_records": 0, "link_resent": 0, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0, "state_bytes": 3999896, "memory_refused": 177258}} +{"case": "outbox", "seconds": 30, "posted": 203928, "refused": 0, "rss_began_kib": 3088, "rss_end_kib": 47040, "rss_per_post_bytes": 220.7, "store_bytes": 34678695, "samples": [[0.0, 3088, 0], [2.0, 6320, 13620], [4.01, 9312, 27246], [6.01, 12160, 41022], [8.01, 15024, 54518], [10.02, 18016, 68266], [12.02, 20928, 82068], [14.02, 23856, 95792], [16.03, 26720, 109011], [18.03, 29680, 122729], [20.03, 32592, 136438], [22.04, 35648, 150137], [24.04, 38432, 163833], [26.05, 41136, 176611], [28.05, 44064, 190213], [30.05, 47040, 203924], [30.05, 47040, 203924]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 4, "requests": 203928, "bursts": 122896, "burst_events": 203928, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 122897, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 0, "answers": 0, "commit_groups": 122897, "grouped_commits": 122897, "checkpoints": 0, "link_packets": 0, "link_records": 0, "link_resent": 0, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0, "state_bytes": 27734208, "memory_refused": 0}} diff --git a/research/experiments/021-memory/2026-09-30-held-before-linux-aarch64.jsonl b/research/experiments/021-memory/2026-09-30-held-before-linux-aarch64.jsonl new file mode 100644 index 0000000..6036e49 --- /dev/null +++ b/research/experiments/021-memory/2026-09-30-held-before-linux-aarch64.jsonl @@ -0,0 +1 @@ +{"label": "de2860a", "case": "held", "seconds": 30, "posted": 203791, "refused": 0, "rss_began_kib": 6576, "rss_end_kib": 694096, "rss_per_post_bytes": 3454.6, "store_bytes": 19287708, "samples": [[0.0, 6576, 0], [2.0, 50000, 12778], [4.01, 96624, 26556], [6.01, 143488, 40444], [8.01, 189488, 54067], [10.02, 236320, 67956], [12.02, 283360, 81869], [14.02, 328080, 95138], [16.03, 375056, 109037], [18.03, 419184, 122186], [20.04, 466144, 136107], [22.04, 508992, 148894], [24.04, 555392, 162669], [26.05, 601664, 176432], [28.05, 647600, 190046], [30.05, 694080, 203787], [30.05, 694080, 203787]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 8, "requests": 223791, "bursts": 134467, "burst_events": 223791, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 134468, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 0, "answers": 0, "commit_groups": 0, "grouped_commits": 0, "checkpoints": 10, "link_packets": 0, "link_records": 0, "link_resent": 0, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0}} diff --git a/research/experiments/021-memory/2026-09-30-memory-linux-aarch64.jsonl b/research/experiments/021-memory/2026-09-30-memory-linux-aarch64.jsonl new file mode 100644 index 0000000..407c273 --- /dev/null +++ b/research/experiments/021-memory/2026-09-30-memory-linux-aarch64.jsonl @@ -0,0 +1,5 @@ +{"case": "outbox", "seconds": 30, "posted": 202763, "rss_began_kib": 3088, "rss_end_kib": 46768, "rss_per_post_bytes": 220.6, "store_bytes": 34678695, "samples": [[0.0, 3088, 0], [2.0, 6320, 13658], [4.01, 9248, 27323], [6.01, 12192, 40969], [8.01, 15072, 54665], [10.01, 18064, 68230], [12.02, 20848, 81832], [14.02, 23808, 95269], [16.03, 26752, 108838], [18.03, 29584, 122559], [20.03, 32560, 136189], [22.03, 35520, 149978], [24.04, 38416, 163368], [26.04, 41184, 176553], [28.04, 44000, 189399], [30.04, 46736, 202759], [30.04, 46736, 202759]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 4, "requests": 202763, "bursts": 120723, "burst_events": 202763, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 120724, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 0, "answers": 0, "commit_groups": 120724, "grouped_commits": 120724, "checkpoints": 0, "link_packets": 0, "link_records": 0, "link_resent": 0, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0}} +{"case": "link-down", "seconds": 30, "posted": 198874, "rss_began_kib": 8688, "rss_end_kib": 86144, "rss_per_post_bytes": 398.8, "store_bytes": 34678695, "samples": [[0.0, 8688, 0], [2.0, 14368, 13447], [4.01, 19536, 26781], [6.01, 24608, 39799], [8.01, 29744, 53146], [10.02, 34928, 66594], [12.02, 40144, 80020], [14.02, 45136, 92957], [16.02, 50240, 106415], [18.03, 55520, 119903], [20.03, 60784, 133388], [22.03, 65600, 145927], [24.04, 70736, 159166], [26.04, 75952, 172647], [28.04, 81152, 186071], [30.04, 86304, 198870], [30.04, 86304, 198870]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 4, "requests": 198874, "bursts": 119928, "burst_events": 198874, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 119929, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 0, "answers": 0, "commit_groups": 119929, "grouped_commits": 119929, "checkpoints": 0, "link_packets": 67, "link_records": 349, "link_resent": 32, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0}} +{"case": "wire-down", "seconds": 30, "posted": 198269, "rss_began_kib": 3120, "rss_end_kib": 106512, "rss_per_post_bytes": 534.0, "store_bytes": 34678695, "samples": [[0.0, 3120, 0], [2.0, 10512, 13272], [4.01, 17376, 26528], [6.01, 24336, 39975], [8.01, 31440, 53404], [10.02, 38272, 66774], [12.02, 45216, 80113], [14.02, 52000, 92988], [16.03, 58704, 106250], [18.03, 65824, 119668], [20.03, 72640, 133038], [22.03, 79552, 146378], [24.04, 86288, 159271], [26.04, 92880, 171996], [28.04, 99776, 185352], [30.05, 106512, 198265], [30.05, 106512, 198265]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 4, "requests": 198269, "bursts": 119629, "burst_events": 198269, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 119630, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 9, "answers": 0, "commit_groups": 119630, "grouped_commits": 119630, "checkpoints": 0, "link_packets": 0, "link_records": 0, "link_resent": 0, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0}} +{"case": "unheld", "seconds": 30, "posted": 206179, "rss_began_kib": 6608, "rss_end_kib": 6992, "rss_per_post_bytes": 1.9, "store_bytes": 19287708, "samples": [[0.0, 6608, 0], [2.0, 6976, 13925], [4.01, 6976, 27728], [6.01, 6976, 41750], [8.01, 6976, 55762], [10.02, 6992, 69046], [12.02, 6992, 82578], [14.02, 6992, 95917], [16.03, 6992, 109910], [18.03, 6992, 123963], [20.03, 6992, 137643], [22.04, 6992, 151687], [24.04, 6992, 165710], [26.05, 6992, 178567], [28.05, 6992, 192132], [30.05, 6992, 206175], [30.05, 6992, 206175]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 8, "requests": 226179, "bursts": 134438, "burst_events": 226179, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 134439, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 0, "answers": 0, "commit_groups": 0, "grouped_commits": 0, "checkpoints": 10, "link_packets": 0, "link_records": 0, "link_resent": 0, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0}} +{"case": "held", "seconds": 30, "posted": 205354, "rss_began_kib": 6640, "rss_end_kib": 10640, "rss_per_post_bytes": 19.9, "store_bytes": 19287708, "samples": [[0.0, 6640, 0], [2.0, 9168, 13836], [4.01, 9872, 27868], [6.01, 10240, 41809], [8.01, 10416, 54381], [10.02, 10512, 68479], [12.02, 10560, 82526], [14.02, 10592, 96125], [16.03, 10592, 109730], [18.03, 10592, 123623], [20.03, 10624, 137733], [22.04, 10640, 151837], [24.04, 10640, 164413], [26.04, 10640, 178153], [28.05, 10640, 192298], [30.05, 10640, 205350], [30.05, 10640, 205350]], "stats": {"active": 0, "retained_payload_bytes": 0, "accepted": 8, "requests": 225354, "bursts": 132173, "burst_events": 225354, "ticks": 0, "expired": 0, "threads": 2, "owner": true, "snapshots": 132174, "views_from_snapshots": 0, "delivered": 0, "delivery_attempts": 0, "answers": 0, "commit_groups": 0, "grouped_commits": 0, "checkpoints": 10, "link_packets": 0, "link_records": 0, "link_resent": 0, "link_lost": 0, "link_acknowledged": 0, "link_stored": 0, "link_refused": 0, "listener_packets": 0, "listener_received": 0, "listener_deliveries": 0, "listener_woken": 0, "listener_remembered": 0, "listener_swept": 0, "listener_active": 0, "listener_keyed": 0, "listener_secrets": 0, "listener_epochs": 0, "listener_refused": 0}} diff --git a/research/experiments/021-memory/hold-surface.c b/research/experiments/021-memory/hold-surface.c new file mode 100644 index 0000000..c1c2d4b --- /dev/null +++ b/research/experiments/021-memory/hold-surface.c @@ -0,0 +1,32 @@ +/* A surface that holds a snapshot until the server stops: a reader that + * never lets go, for run.py's held case (research/experiments/021-memory). + * It holds the first snapshot whose number view VIEW is at least AT (the + * first snapshot at all without them), and lets every other one go. + * + * pure-serve ... --surface hold-surface.so [-- VIEW AT] */ +#include "surface.h" +#include <poll.h> +#include <stdint.h> +#include <stdlib.h> +#include <unistd.h> + +int pure_surface_run(const PureSurface *s, int argc, char **argv) { + static PureApp reader; + int view = argc > 2 ? pure_module_view(s->module, argv[1]) : -1; + double at = argc > 2 ? atof(argv[2]) : 0, value = 0; + PureSnapshot *held = NULL; + while (!s->stopping(s->host)) { + struct pollfd wait = {s->changes, POLLIN, 0}; + if (poll(&wait, 1, 20) > 0 && (wait.revents & POLLIN)) { + uint64_t saves; + if (read(s->changes, &saves, sizeof(saves)) < 0) { /* cleared already */ } + } + if (held) continue; + PureSnapshot *read_now = s->read(s->host, &reader); + if (!read_now) continue; + if (view < 0 || (pure_app_view(&reader, (uint32_t)view, &value) && value >= at)) held = read_now; + else s->release(s->host, read_now); + } + if (held) s->release(s->host, held); + return 0; +} diff --git a/research/experiments/021-memory/run.py b/research/experiments/021-memory/run.py new file mode 100644 index 0000000..49bb5a9 --- /dev/null +++ b/research/experiments/021-memory/run.py @@ -0,0 +1,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() |
