aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/research/112-sending-between-apps.md56
-rw-r--r--research/experiments/027-apps-sending/2026-10-01-load.txt2
-rw-r--r--research/experiments/027-apps-sending/2026-10-01-sends-linux-aarch64.jsonl10
-rw-r--r--research/experiments/027-apps-sending/dst.aup9
-rwxr-xr-xresearch/experiments/027-apps-sending/runs.sh12
-rw-r--r--research/experiments/027-apps-sending/sends.py117
-rw-r--r--research/experiments/027-apps-sending/src.aup16
7 files changed, 222 insertions, 0 deletions
diff --git a/docs/research/112-sending-between-apps.md b/docs/research/112-sending-between-apps.md
new file mode 100644
index 0000000..223418b
--- /dev/null
+++ b/docs/research/112-sending-between-apps.md
@@ -0,0 +1,56 @@
+# 112 — 한 호스트의 앱 사이의 건넴: 안쪽 출구와 링크
+
+2026-10-01. [017](../design/017-apps-on-one-host.md)의 2단계를 잰다. 기준은 구현하기 전에 017에 적어 커밋했다(`f394f1e`).
+
+- 바꿀 결정: 같은 호스트의 앱 사이를 링크(소켓, 봉인, 패킷)가 아니라 안쪽 출구로 건넬지.
+- 통과 조건: 같은 두 앱 사이에서, 받는 앱에 저장된 보냄의 초당 수가 루프백 `pure://` 링크보다 낮지 않고, 저장된 보냄 하나에
+ 드는 호스트의 CPU가 링크의 절반 이하다.
+
+## 쉬운 말로
+
+- **통과했다.** 안쪽 출구는 링크의 두 배(초당 114만 건 대 58만 건)를 나르고, 보냄 하나에 호스트의 CPU를 40%만 쓴다(1.17 µs 대
+ 2.93 µs).
+- 안쪽 출구에는 봉인, 패킷, 소켓, 받는 스레드가 없다. 보내는 앱의 차례가 저장한 보냄을 묶음으로 받는 앱의 대기열에 넣고, 받는
+ 앱이 링크의 기록처럼 들은 번호 다음부터 적용한다. 순서와 한 번만은 링크와 같은 "들은 번호"가 지킨다. 그래서 받는 쪽의 코드는
+ 링크와 같고, 보내는 쪽은 출구 하나(약 200줄)다.
+- 여기서 잰 CPU에는 보내는 사건(목록을 걸으며 1,000건을 보내고 저장)과 받는 앱의 적용·저장도 들어 있다. 두 길에 같다. 출구만의
+ 차이는 이보다 크다.
+
+## 결과
+
+원시 결과와 명령은 [027-apps-sending](../../research/experiments/027-apps-sending/)에 있다(`runs.sh`, `sends.py`, 두 앱
+`src.aup`와 `dst.aup`).
+
+**방법.** 한 호스트(`--apps`, 연결 스레드 하나, 실행 스레드 둘, RAM, 코어 0–2)에 앱 둘. `src`는 항목 1,000개를 들고, `burst`
+사건 하나가 그것을 걸으며 하나에 보냄 하나(`send out { k }`)를 낸다. `dst`는 받은 것을 세고 더한다. 한 판은 `burst` 2,000번(보냄
+200만 건)을 차례로 보내고, `dst`가 모두 저장할 때까지의 시간과 호스트의 CPU(utime + stime)를 잰다. `dst`의 합이 모든 보냄이 한
+번씩 왔는지 본다. 링크는 호스트가 자기 `--link-port`로, 자기 키로 봉인해 보낸다(`send=out=pure://127.0.0.1:포트/dst/take`).
+다섯 라운드, 두 길을 번갈아. 부하 평균은 1.3–1.6이었다.
+
+| 라운드 | 안쪽 출구(건/초, µs/건) | 링크(건/초, µs/건, 패킷) |
+|---|---:|---:|
+| 1 | 1,124,661, 1.17 | 575,155, 2.92, 63,888 |
+| 2 | 1,149,587, 1.17 | 591,140, 2.87, 63,094 |
+| 3 | 1,138,945, 1.18 | 578,879, 2.94, 66,506 |
+| 4 | 1,141,642, 1.17 | 550,106, 3.12, 72,477 |
+| 5 | 1,161,814, 1.16 | 601,578, 2.93, 69,839 |
+
+중앙값은 안쪽 출구 1,141,642건/초에 1.17 µs, 링크 578,879건/초에 2.93 µs다(1.97배, 0.40배). 안쪽 출구는 판마다 묶음 2,000개를
+넘겼다. 사건 하나의 보냄 1,000건이 한 묶음이었다.
+
+**검사**(`tests/check.py`의 `server_apps_links`). 한 호스트의 앱이 400건마다 넷에 보낸다. 다른 호스트의 앱(링크, 봉인, 그
+호스트의 링크 포트가 `far/take`로 받는다), 같은 호스트의 앱 셋(같은 모양, 이름으로 받는 모양, 메모리 예산이 차는 앱)이다.
+150번째 요청 앞에서 보내는 호스트를 `kill -9`하고 다시 띄운다. 넷 모두 400건을 한 번씩 순서대로 받는다. 가득 찬 앱은 147건을
+쥐고, 나머지는 보내는 앱의 outbox가 붙든다. 그 앱이 쥔 것을 놓을 때마다 이어서 받아 400건을 다 받는다. 세 번 돌려 세 번
+같았다.
+
+## 판정
+
+- **017의 2단계는 들어간다.** 같은 호스트의 앱 사이는 안쪽 출구로 건넨다. 다른 호스트로는 지금처럼 링크로 보낸다(`pure://…/앱/입력`,
+ 호스트의 링크 포트 하나가 모든 앱의 입력을 받는다).
+
+## 다음
+
+- **2b: 보냄마다의 스레드.** 링크와 전달선은 아직 하나에 스레드 하나다. 안쪽 출구에는 스레드가 없다. 앱마다 스레드를 두지 않는다는
+ 원칙에 어긋나는 마지막 자리다.
+- 3단계(앱의 수명): 달리는 호스트에 앱을 더하고, 멈추고, 바꾼다.
diff --git a/research/experiments/027-apps-sending/2026-10-01-load.txt b/research/experiments/027-apps-sending/2026-10-01-load.txt
new file mode 100644
index 0000000..877d5fc
--- /dev/null
+++ b/research/experiments/027-apps-sending/2026-10-01-load.txt
@@ -0,0 +1,2 @@
+ 07:04:41 up 4:17, 3 users, load average: 1.33, 4.07, 4.66
+ 07:05:09 up 4:17, 3 users, load average: 1.58, 3.87, 4.57
diff --git a/research/experiments/027-apps-sending/2026-10-01-sends-linux-aarch64.jsonl b/research/experiments/027-apps-sending/2026-10-01-sends-linux-aarch64.jsonl
new file mode 100644
index 0000000..194d5c3
--- /dev/null
+++ b/research/experiments/027-apps-sending/2026-10-01-sends-linux-aarch64.jsonl
@@ -0,0 +1,10 @@
+{"round": 1, "way": "local", "sends": 2000000, "seconds": 1.778, "sends_per_second": 1124661, "cpu_us_per_send": 1.17, "handed": 1999, "link_packets": 0}
+{"round": 1, "way": "link", "sends": 2000000, "seconds": 3.477, "sends_per_second": 575155, "cpu_us_per_send": 2.92, "handed": 0, "link_packets": 63888}
+{"round": 2, "way": "link", "sends": 2000000, "seconds": 3.383, "sends_per_second": 591140, "cpu_us_per_send": 2.87, "handed": 0, "link_packets": 63094}
+{"round": 2, "way": "local", "sends": 2000000, "seconds": 1.74, "sends_per_second": 1149587, "cpu_us_per_send": 1.17, "handed": 2000, "link_packets": 0}
+{"round": 3, "way": "local", "sends": 2000000, "seconds": 1.756, "sends_per_second": 1138945, "cpu_us_per_send": 1.18, "handed": 2000, "link_packets": 0}
+{"round": 3, "way": "link", "sends": 2000000, "seconds": 3.455, "sends_per_second": 578879, "cpu_us_per_send": 2.94, "handed": 0, "link_packets": 66506}
+{"round": 4, "way": "link", "sends": 2000000, "seconds": 3.636, "sends_per_second": 550106, "cpu_us_per_send": 3.12, "handed": 0, "link_packets": 72477}
+{"round": 4, "way": "local", "sends": 2000000, "seconds": 1.752, "sends_per_second": 1141642, "cpu_us_per_send": 1.17, "handed": 2000, "link_packets": 0}
+{"round": 5, "way": "local", "sends": 2000000, "seconds": 1.721, "sends_per_second": 1161814, "cpu_us_per_send": 1.16, "handed": 2000, "link_packets": 0}
+{"round": 5, "way": "link", "sends": 2000000, "seconds": 3.325, "sends_per_second": 601578, "cpu_us_per_send": 2.93, "handed": 0, "link_packets": 69839}
diff --git a/research/experiments/027-apps-sending/dst.aup b/research/experiments/027-apps-sending/dst.aup
new file mode 100644
index 0000000..c1c8acb
--- /dev/null
+++ b/research/experiments/027-apps-sending/dst.aup
@@ -0,0 +1,9 @@
+state n = 0
+state total = 0
+input take = message { k: u32 }
+on take {
+ n = n + 1
+ total = total + take.k
+}
+view count = n
+view sum = total
diff --git a/research/experiments/027-apps-sending/runs.sh b/research/experiments/027-apps-sending/runs.sh
new file mode 100755
index 0000000..fe4dfda
--- /dev/null
+++ b/research/experiments/027-apps-sending/runs.sh
@@ -0,0 +1,12 @@
+#!/bin/sh
+# The runs of docs/design/017's second stage, from the repository's root:
+# two apps of one host, sending through the local outlet or over a
+# loopback link, five rounds of 2,000 bursts (2,000,000 sends), the ways in
+# turns, once the last minute's load is below 1.5.
+set -e
+out=research/experiments/027-apps-sending
+until awk '{ exit !($1 < 1.5) }' /proc/loadavg; do sleep 10; done
+echo "$(uptime)" > $out/2026-10-01-load.txt
+make -s build/pure-serve
+python3 $out/sends.py build/pure-serve 5 2000 > $out/2026-10-01-sends-linux-aarch64.jsonl
+echo "$(uptime)" >> $out/2026-10-01-load.txt
diff --git a/research/experiments/027-apps-sending/sends.py b/research/experiments/027-apps-sending/sends.py
new file mode 100644
index 0000000..454dc2b
--- /dev/null
+++ b/research/experiments/027-apps-sending/sends.py
@@ -0,0 +1,117 @@
+#!/usr/bin/env python3
+"""Two apps of one host (docs/design/017, stage 2), src sending to dst
+either through the local outlet (send=out=dst/take) or over a loopback
+pure:// link to the host's own --link-port (sealed with its key). src
+holds 1,000 items; each burst event walks them and sends one message
+each, so sending (not HTTP) is what the host does. A run: BURSTS bursts
+posted one after another, then the time until dst has stored every send,
+and the host's CPU over it (utime + stime). dst's sum says each send came
+once. In RAM, the host on cores 0-2.
+
+sends.py SERVE ROUNDS [BURSTS] prints a JSON line per run
+"""
+import http.client
+import json
+import os
+import select
+import shutil
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent
+ROOT = HERE.parents[2]
+AUP = ROOT.parent / 'aup' / 'build' / 'aup'
+ITEMS = 1000
+
+
+def started(args):
+ process = subprocess.Popen(['taskset', '-c', '0-2', *map(str, args), '--port', '0'], stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+ ready, _, _ = select.select([process.stdout], [], [], 10)
+ assert ready, process.stderr.read()
+ return process, json.loads(process.stdout.readline())
+
+
+def stopped(process):
+ process.terminate()
+ _, error = process.communicate(timeout=60)
+ assert process.returncode == 0, error[-500:]
+ return json.loads(error.decode().strip().splitlines()[-1])
+
+
+def cpu_seconds(pid):
+ fields = Path(f'/proc/{pid}/stat').read_text().rsplit(')', 1)[1].split()
+ return (int(fields[11]) + int(fields[12])) / os.sysconf('SC_CLK_TCK')
+
+
+def run(way, serve, work, modules, key, public, bursts):
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
+ probe.bind(('127.0.0.1', 0))
+ link_port = probe.getsockname()[1]
+ to = 'dst/take' if way == 'local' else f'pure://127.0.0.1:{link_port}/dst/take seal=out={public}'
+ (work / 'apps.conf').write_text(f"src {modules['src']} {work / 'src'} send=out={to}\n"
+ f"dst {modules['dst']} {work / 'dst'}\n")
+ args = [serve, '--apps', work / 'apps.conf', '--threads', 1, '--workers', 2]
+ if way == 'link':
+ args += ['--key', key, '--link-port', link_port, '--sender', f'src.out={public}']
+ process, info = started(args)
+ client = http.client.HTTPConnection('127.0.0.1', info['port'], timeout=60)
+
+ def ask(method, path):
+ client.request(method, path, b'' if method == 'POST' else None)
+ answer = client.getresponse()
+ body = answer.read()
+ assert answer.status == 200, (path, answer.status)
+ return body
+
+ try:
+ for k in range(1, ITEMS + 1):
+ ask('POST', f'/src/inputs/fill?k={k}')
+ began, cpu = time.perf_counter(), cpu_seconds(process.pid)
+ for _ in range(bursts):
+ ask('POST', '/src/inputs/burst?step=1')
+ sent = bursts * ITEMS
+ while int(ask('GET', '/dst/views/count')) < sent:
+ time.sleep(0.005)
+ took, used = time.perf_counter() - began, cpu_seconds(process.pid) - cpu
+ total = int(ask('GET', '/dst/views/sum'))
+ assert int(ask('GET', '/dst/views/count')) == sent and total == bursts * (ITEMS * (ITEMS + 1) // 2 + ITEMS), total
+ finally:
+ client.close()
+ stats = stopped(process)
+ return {'way': way, 'sends': sent, 'seconds': round(took, 3), 'sends_per_second': round(sent / took),
+ 'cpu_us_per_send': round(used * 1e6 / sent, 2), 'handed': stats.get('handed', 0),
+ 'link_packets': stats['link_packets']}
+
+
+def main():
+ serve, rounds = Path(sys.argv[1]).resolve(), int(sys.argv[2])
+ bursts = int(sys.argv[3]) if len(sys.argv) > 3 else 60
+ work = Path(tempfile.mkdtemp(prefix='pure-sends-', dir='/dev/shm'))
+ try:
+ modules = {}
+ for name in ('src', 'dst'):
+ modules[name] = work / f'{name}.pmod'
+ subprocess.run([str(AUP), 'build', str(HERE / f'{name}.aup'), '-o', str(modules[name])], check=True)
+ key = work / 'host.key'
+ (work / 'one.conf').write_text(f"x {modules['dst']} {work / 'keys'}\n")
+ process, info = started([serve, '--apps', work / 'one.conf', '--key', key])
+ public = info['public_key']
+ stopped(process)
+ for round_ in range(1, rounds + 1):
+ for way in (['local', 'link'] if round_ % 2 else ['link', 'local']):
+ runs = work / f'run-{round_}-{way}'
+ runs.mkdir()
+ row = run(way, serve, runs, modules, key, public, bursts)
+ print(json.dumps({'round': round_, **row}), flush=True)
+ shutil.rmtree(runs)
+ finally:
+ shutil.rmtree(work)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/research/experiments/027-apps-sending/src.aup b/research/experiments/027-apps-sending/src.aup
new file mode 100644
index 0000000..6ec7c4c
--- /dev/null
+++ b/research/experiments/027-apps-sending/src.aup
@@ -0,0 +1,16 @@
+record Item {
+ k = 0
+}
+state items: list of Item
+input fill = message { k: u32 }
+input burst = message { step: u32 }
+output out = message { k: u32 }
+on fill {
+ items.append(Item { k: fill.k })
+}
+on burst {
+ for i in items {
+ send out { k: i.k + burst.step }
+ }
+}
+view count = count(i in items where i.k > 0)