diff options
3 files changed, 146 insertions, 2 deletions
diff --git a/docs/research/084-parallel-syncs.md b/docs/research/084-parallel-syncs.md index 43c24df..3f55e85 100644 --- a/docs/research/084-parallel-syncs.md +++ b/docs/research/084-parallel-syncs.md @@ -1,6 +1,7 @@ -# 084 — 소유자를 키로 나누면: 이 장치는 동기화를 한 줄로 세운다 +# 084 — 소유자를 키로 나누면, 저장을 다른 스레드로 넘기면: 이 장치는 동기화를 한 줄로 세운다 -2026-09-27. [로드맵](../roadmap.md) 중기 2 "키 해시 기반 단일 소유자 파티셔닝(쓰기 병목 선형 확장)"을 할지 정한 것. +2026-09-27. [로드맵](../roadmap.md) 중기 2 "키 해시 기반 단일 소유자 파티셔닝(쓰기 병목 선형 확장)"과 단기 2 "저장과 +적용의 겹치기(디스크 쓰기를 전용 I/O 스레드로)"를 할지 정한 것. 둘 다 같은 사실에 걸린다. 착수 기준: @@ -45,3 +46,30 @@ Raspberry Pi 5, SD 카드의 ext4, 4 KiB 커밋, 3초씩, 순서를 돌려 가� - 멀티코어는 읽기에 쓴다. 연결 스레드가 스냅숏으로 보기를 답한다(`--threads auto`, [080](080-snapshot-reads.md)). - 이 장치에서 쓰기를 더 늘리는 길은 동기화의 비용 자체다. 커밋 경로에서 꼬리 늘리기와 압축을 빼면 처리량은 5–8% 늘고 p99는 반이 된다([081](081-tail-records.md) 다음 결정). + +## 저장을 다른 스레드로(단기 2) + +쓰기가 포화일 때 병목은 기기의 동기화 한 줄이다. 커밋 말고 기기가 하는 일, 곧 꼬리에 0을 써 두고 새 크기를 저널에 +커밋하는 일과 압축(살아 있는 노드를 새 파일에 쓰고 동기화, 이름 바꾸기, 디렉터리 동기화)을 다른 스레드로 넘겨도 그 일의 +기기 시간은 사라지지 않는다. 커밋의 동기화와 같은 줄에 서서 그 커밋을 늦출 뿐이다. 소유자가 기다리는 틈(라운드의 25%, +약 1.1 ms, [081](081-tail-records.md))도 저널 커밋 한 번보다 짧다. 적용을 동기화와 겹쳐 가릴 수 있는 몫은 4%다(081). + +그래서 스레드를 늘리는 대신, 그 일을 없앴을 때의 몫을 쟀다. 저장소를 셋으로 빌드해 번갈아 잰 것이다. 그대로, 압축을 뺀 것, +압축을 빼고 꼬리를 만들 때 64 MiB를 써 두어 측정 중에는 늘리지 않는 것이다([variants.py](../../research/experiments/013-parallel-syncs/variants.py), +[원시 결과](../../research/experiments/013-parallel-syncs/2026-09-27-store-variants-ext4-linux-aarch64.jsonl)). 점수판 내구성 POST +64연결, 4초씩 여섯 라운드의 중앙값이다. + +| 저장소 | posts/s | p99 | +|---|---:|---:| +| 그대로 | 22,230 | 17.4 ms | +| 압축 없음 | 21,528 | 18.4 ms | +| 압축 없음, 꼬리 늘리기 없음 | 22,544 | 13.3 ms | + +압축만 빼서는 달라지지 않는다. 꼬리 늘리기까지 빼면 처리량은 +1.4%(앞선 탐색에서는 +5.5%)이고 p99가 24% 준다. 꼬리 +지연을 만드는 것은 새 블록이다. 256 KiB마다 0을 쓰고, 그 크기와 블록을 저널에 커밋하는 동기화다. 0을 크게 한꺼번에 +쓰면 드물어지는 대신 한 번이 길어진다. 압축은 새 파일을 만들 때마다 꼬리를 새로 써야 해서 둘은 따로 떼어 볼 수 없다. + +결정: 저장을 넘길 스레드는 만들지 않는다. 남은 몫(p99 −24%, 처리량 몇 %)을 얻는 길은 공간을 제자리에서 다시 쓰는 +저장소다. 파일을 한 번 0으로 채워 두고 커밋을 고리처럼 돌려 쓰며, 살아 있는 노드를 앞으로 옮기는 청소가 압축을 대신한다. +그러면 커밋은 늘 이미 있는 블록을 덮어쓰고(가장 싼 동기화), 새 파일과 이름 바꾸기도 없다. 저장소 형식을 바꾸는 일이라 +따로 설계한다. diff --git a/research/experiments/013-parallel-syncs/2026-09-27-store-variants-ext4-linux-aarch64.jsonl b/research/experiments/013-parallel-syncs/2026-09-27-store-variants-ext4-linux-aarch64.jsonl new file mode 100644 index 0000000..275281d --- /dev/null +++ b/research/experiments/013-parallel-syncs/2026-09-27-store-variants-ext4-linux-aarch64.jsonl @@ -0,0 +1,21 @@ +{"round": 0, "store": "as it is", "posts_per_second": 21552.25, "p50_us": 2048, "p99_us": 13312, "max_us": 278528, "errors": 0} +{"round": 0, "store": "no compaction", "posts_per_second": 21943.25, "p50_us": 2176, "p99_us": 14336, "max_us": 77824, "errors": 0} +{"round": 0, "store": "no compaction, no growth", "posts_per_second": 21310.75, "p50_us": 2176, "p99_us": 22528, "max_us": 81920, "errors": 0} +{"round": 1, "store": "no compaction", "posts_per_second": 21296.25, "p50_us": 2176, "p99_us": 24576, "max_us": 73728, "errors": 0} +{"round": 1, "store": "no compaction, no growth", "posts_per_second": 22416.25, "p50_us": 2176, "p99_us": 12800, "max_us": 65536, "errors": 0} +{"round": 1, "store": "as it is", "posts_per_second": 22416.25, "p50_us": 2176, "p99_us": 16384, "max_us": 73728, "errors": 0} +{"round": 2, "store": "no compaction, no growth", "posts_per_second": 24080.25, "p50_us": 1984, "p99_us": 13312, "max_us": 59392, "errors": 0} +{"round": 2, "store": "as it is", "posts_per_second": 22864.25, "p50_us": 1984, "p99_us": 19456, "max_us": 65536, "errors": 0} +{"round": 2, "store": "no compaction", "posts_per_second": 19744.25, "p50_us": 2176, "p99_us": 32768, "max_us": 73728, "errors": 0} +{"round": 3, "store": "as it is", "posts_per_second": 22789.75, "p50_us": 1984, "p99_us": 19456, "max_us": 73728, "errors": 0} +{"round": 3, "store": "no compaction", "posts_per_second": 21760.25, "p50_us": 2048, "p99_us": 20480, "max_us": 147456, "errors": 0} +{"round": 3, "store": "no compaction, no growth", "posts_per_second": 22672.25, "p50_us": 2176, "p99_us": 13312, "max_us": 65536, "errors": 0} +{"round": 4, "store": "no compaction", "posts_per_second": 23840.25, "p50_us": 2048, "p99_us": 14848, "max_us": 65536, "errors": 0} +{"round": 4, "store": "no compaction, no growth", "posts_per_second": 21648.25, "p50_us": 2176, "p99_us": 16384, "max_us": 61440, "errors": 0} +{"round": 4, "store": "as it is", "posts_per_second": 22043.25, "p50_us": 2176, "p99_us": 18432, "max_us": 59392, "errors": 0} +{"round": 5, "store": "no compaction, no growth", "posts_per_second": 23056.25, "p50_us": 2176, "p99_us": 12288, "max_us": 63488, "errors": 0} +{"round": 5, "store": "as it is", "posts_per_second": 21997.0, "p50_us": 2176, "p99_us": 16384, "max_us": 73728, "errors": 0} +{"round": 5, "store": "no compaction", "posts_per_second": 21060.25, "p50_us": 2176, "p99_us": 16384, "max_us": 73728, "errors": 0} +{"store": "as it is", "rounds": 6, "posts_per_second_median": 22229.75, "p99_us_median": 17408.0, "max_us": 278528} +{"store": "no compaction", "rounds": 6, "posts_per_second_median": 21528.25, "p99_us_median": 18432.0, "max_us": 147456} +{"store": "no compaction, no growth", "rounds": 6, "posts_per_second_median": 22544.25, "p99_us_median": 13312.0, "max_us": 81920} diff --git a/research/experiments/013-parallel-syncs/variants.py b/research/experiments/013-parallel-syncs/variants.py new file mode 100644 index 0000000..2c48ab6 --- /dev/null +++ b/research/experiments/013-parallel-syncs/variants.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Where the tree store's extra sync work goes (docs/research/084-parallel-syncs.md). + +Builds pure-serve with three stores from store/store-tree.c: as it is; with no +compaction; and with no compaction and a 64 MiB tail written at creation (no +tail growth during a run). Then durable score posts (64 connections), the +builds in rotated order per round, each on a fresh store after 64 warm-up +posts. Prints one JSON line per run and a summary. +""" +import argparse +import json +import select +import shutil +import statistics +import subprocess +import sys +import tempfile +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[2] +HTTP = ROOT / 'benchmarks' / 'http' +PATCHES = { + 'as it is': [], + 'no compaction': [('enum { COMPACT_SLACK = 1048576 };', 'enum { COMPACT_SLACK = 1 << 30 };')], + 'no compaction, no growth': [('enum { COMPACT_SLACK = 1048576 };', 'enum { COMPACT_SLACK = 1 << 30 };'), + ('TAIL_STEP = 262144', 'TAIL_STEP = 67108864')], +} + + +def build(work, name, patches): + source = (ROOT / 'store' / 'store-tree.c').read_text() + for old, new in patches: + assert old in source, old + source = source.replace(old, new) + store = work / f'store-{len(name)}.c' + store.write_text(source) + binary = work / f'pure-serve-{len(name)}' + subprocess.run(['cc', '-O2', '-std=c17', f'-I{ROOT / "core"}', f'-I{ROOT / "serve"}', '-DPURE_ASYNC_EFFECTS', '-pthread', + *[str(ROOT / s) for s in ('serve/serve.c', 'serve/deliver.c', 'serve/message-json.c', 'core/module.c', + 'core/module-list.c', 'core/module-save.c', 'core/module-text.c', + 'core/module-store.c')], str(store), str(ROOT / 'core/runtime.c'), + '-lssl', '-lcrypto', '-lm', '-o', str(binary)], check=True) + return binary + + +def posts(binary, module, directory, seconds): + store = Path(tempfile.mkdtemp(dir=directory, prefix='pure-store-variant-')) + shutil.rmtree(store) + process = subprocess.Popen(['taskset', '-c', '0-2', str(binary), '--module', str(module), '--store', str(store), + '--port', '0'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + ready, _, _ = select.select([process.stdout], [], [], 20) + assert ready, 'pure-serve did not start' + port = json.loads(process.stdout.readline())['port'] + load = ['taskset', '-c', '3', str(HTTP / 'load'), str(port), '64'] + subprocess.run(load + ['1', 'score', '64', 'keep-alive', '16'], capture_output=True, check=True) + measured = json.loads(subprocess.run(load + [str(seconds), 'score', '64', 'keep-alive', '16'], capture_output=True, + text=True, check=True).stdout) + return {'posts_per_second': measured['2xx'] / seconds, 'p50_us': measured['p50_us'], 'p99_us': measured['p99_us'], + 'max_us': measured['max_us'], 'errors': measured['errors'] + measured['4xx'] + measured['5xx']} + finally: + process.terminate() + process.wait() + shutil.rmtree(store, ignore_errors=True) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--directory', type=Path, default=Path('/var/tmp'), help='on the device to measure') + parser.add_argument('--aup', type=Path, default=ROOT.parent / 'aup' / 'build' / 'aup') + parser.add_argument('--rounds', type=int, default=6) + parser.add_argument('--seconds', type=float, default=4) + args = parser.parse_args() + with tempfile.TemporaryDirectory() as work: + work = Path(work) + module = work / 'scores.pmod' + subprocess.run([str(args.aup), 'build', str(HTTP / 'scores.aup'), '-o', str(module)], check=True) + subprocess.run(['cc', '-O2', '-std=c17', '-o', str(HTTP / 'load'), str(HTTP / 'load.c')], check=True) + binaries = {name: build(work, name, patches) for name, patches in PATCHES.items()} + names, runs = list(binaries), {name: [] for name in binaries} + for round_ in range(args.rounds): + for name in names[round_ % len(names):] + names[:round_ % len(names)]: + measured = posts(binaries[name], module, args.directory, args.seconds) + runs[name].append(measured) + print(json.dumps({'round': round_, 'store': name, **measured}), flush=True) + for name in names: + print(json.dumps({'store': name, 'rounds': args.rounds, + 'posts_per_second_median': statistics.median(r['posts_per_second'] for r in runs[name]), + 'p99_us_median': statistics.median(r['p99_us'] for r in runs[name]), + 'max_us': max(r['max_us'] for r in runs[name])})) + + +if __name__ == '__main__': + sys.exit(main()) |
