diff options
| author | eunhhu <[email protected]> | 2026-09-28 11:12:37 +0100 |
|---|---|---|
| committer | eunhhu <[email protected]> | 2026-09-28 11:12:37 +0100 |
| commit | d416c5b2392da7b2dc365e646ab5423292f770ce (patch) | |
| tree | 2fa03e595ea11911a582bf76e8448e57a8735477 | |
| parent | 3c8cc4e887c861c392223a54fd5be2b49db29874 (diff) | |
| download | pure-d416c5b2392da7b2dc365e646ab5423292f770ce.tar.gz pure-d416c5b2392da7b2dc365e646ab5423292f770ce.zip | |
Check TPC-C's consistency from one snapshot a side, and measure TPC-C on RAM and an SD card
| -rw-r--r-- | CHECKPOINT.md | 3 | ||||
| -rw-r--r-- | README.md | 14 | ||||
| -rw-r--r-- | benchmarks/tpcc/run.py | 41 | ||||
| -rw-r--r-- | docs/research/092-tpcc.md | 187 | ||||
| -rw-r--r-- | research/experiments/018-tpcc/2026-09-28-tpcc-ram-linux-aarch64.jsonl | 7 | ||||
| -rw-r--r-- | research/experiments/018-tpcc/2026-09-28-tpcc-sd-linux-aarch64.jsonl | 7 |
6 files changed, 235 insertions, 24 deletions
diff --git a/CHECKPOINT.md b/CHECKPOINT.md index 018ade0..1a3bd8b 100644 --- a/CHECKPOINT.md +++ b/CHECKPOINT.md @@ -13,12 +13,13 @@ | 영역 | 확인한 것 | 근거 | |---|---|---| | 앱 표현 | 상태(수, text와 커서·마크, 레코드의 리스트와 키 있는 리스트, 인덱스), 입력(키, text, 필드 있는 메시지, 타이머), 원자적 이벤트 핸들러, 순수 보기(조건식, 표와 걸러진 표, count·sum·max·min·first), `fn`·`use` 라이브러리, text 비교, 시간(`now`, `every`, `floor`, `%`). 로드할 때 타입·스택·합류·커서와 인덱스의 소속을 모두 검증하고 이벤트의 실행량에 상한이 있다 | 008, aup `docs/design/002-dsl-direction.md`, aup `make check`(파이썬 참조 모델 대조, 진단 132건) | -| 영속 | 선언한 상태가 곧 영속 상태다. 트리 저장소에 커밋당 동기화 한 번(헤드를 바이트 바로 앞에 두어 한 연속 구간), 이벤트 묶음마다 저장 한 번. `kill -9` 뒤 확인 응답한 이벤트가 모두 남는다. 레코드·키가 바뀐 모듈로 바꿔도 데이터가 이름으로 옮겨진다 | [076](docs/research/076-one-sync-commit.md), [081](docs/research/081-tail-records.md), 008 상태 영속·교체 | +| 영속 | 선언한 상태가 곧 영속 상태다. 목록은 이미지와 로그(저장마다 바뀐 기록 한 항목, 재사용하는 할당 구간, 앞 항목의 다이제스트로 이어짐)에, 나머지는 트리 저장소에(커밋당 한 연속 구간과 동기화 한 번). 이벤트 묶음마다 저장 한 번, 이미지는 소유자 밖에서 체크포인트. `kill -9`와 커밋 각 단계의 죽음 뒤 확인 응답한 이벤트가 모두 남는다. 레코드·키가 바뀐 모듈로 바꿔도 데이터가 이름으로 옮겨진다 | [076](docs/research/076-one-sync-commit.md), [081](docs/research/081-tail-records.md), [092](docs/research/092-tpcc.md), 008 상태 영속·교체 | | 인터페이스 | 한 명세가 TUI(셀 단위 갱신), CLI, 문장(NUI), 웹 GUI가 된다. 저장하면 실행 중인 앱이 상태를 이어 새 모듈로 바뀐다(`aup watch`, `vega … --watch`) | Vega `docs/design/001-first-component.md`, Vega `make check`(가상 터미널, 헤드리스 Chromium 포함) | | HTTP 서비스 | 보기가 GET, 입력이 POST다(JSON 본문과 그 안의 그룹 객체를 제자리에서 읽는다, 라우터 코드 없음). 앱의 `send`는 이벤트와 함께 저장되고, `--send 출력=URL`로 다른 앱이나 HTTP 끝점에 순서대로 묶어 전해지며, 받는 앱은 번호마다 한 번 적용한다(서버를 죽였다 살려도 정확히 한 번). `--seal 출력=공개키`면 받는 노드의 키로 봉인해(HPKE, 전달선마다 세션 하나, 보내는 노드의 키로 인증) 사이의 중계가 읽지도 바꾸지도 못하고, 받는 노드는 `--sender 이름=키`로 보낸 이를 가린다(봉인 전달 462k/s, 봉인 없이 468k/s). 답은 저장 뒤에 나간다. 상태를 가진 앱은 소유자 스레드 하나가 입력을 적용하고, 연결 스레드는 마지막 저장의 스냅숏으로 보기를 답한다(쓰기가 동기화하는 동안에도 읽기는 기다리지 않는다) | 008 HTTP 호스트, [077](docs/research/077-apps-as-http-services.md), [080](docs/research/080-snapshot-reads.md), [082](docs/research/082-json-in-place.md), [083](docs/research/083-delivery-over-http.md), [086](docs/research/086-sealed-sends.md), [088](docs/research/088-sealed-sessions.md) | | 성능(Pi 5) | 2026-09-27, 같은 기기에서 axum·drogon과 함께 쟀다. 문서 서버(한 스레드): 64 B GET 139k/s(CPU 1초당 140k; axum 127k·63k, drogon 147k·63k), 1 MiB GET 2,362/s(axum 1,304, drogon 754), 충돌 안전 PUT은 axum-atomic의 5.1–5.8배. 앱 서비스: 내구성 있는 쓰기 64연결 21.3k/s(axum·drogon 내구 약 200/s), 합계 GET 126k/s·두 스레드 153k(axum 136k, drogon 151k), 20행 페이지 두 스레드 148k(axum 93k, drogon 138k), 쓰기와 섞인 읽기 119k/s(axum·drogon 내구 1.1–1.4k/s). 유휴 RSS 2.3 MiB(axum 2.8, drogon 10.9), 최대 3.2–5.8 MiB(axum 6.6–9.7, drogon 11.9–78.8) | [075](docs/research/075-http-host-against-axum.md), [077](docs/research/077-apps-as-http-services.md), [080](docs/research/080-snapshot-reads.md), [081](docs/research/081-tail-records.md), [087](docs/research/087-small-reads-and-idle-memory.md) | | 표면과 메신저 | 사람을 위한 표면이 노드 안에서 돈다(`pure-serve --surface`): 마지막 저장의 스냅숏을 제자리에서 읽고, 친 키와 글자는 HTTP 요청과 같은 줄로 소유자에게 간다. Vega의 디스플레이·픽셀이 그 표면이다. 두 노드의 메신저(Vega `examples/messenger.aup`)는 서로에게 봉인·인증해 보내고, 한 줄이 상대 노드에 보이기까지 RAM 저장소에서 p50 0.79 ms·p99 1.1 ms, SD 카드에서 p50 3.5 ms(꼬리는 SD의 동기화) | [설계 010](docs/design/010-surfaces.md), [089](docs/research/089-messenger.md) | | 표 크기의 상태 | 목록이 영속 B+트리다(변경·키 탐색·저장본 위치 O(log n), 스냅숏 O(1), 1,600만 항목, 런타임 상태 1 GiB). pgbench의 TPC-B 같은 이체(세 표의 행과 이력, 한 트랜잭션)가 10만 계좌에서 RAM 24.5k/s(전: 3만 계좌 3.2k/s), SD 카드 내구 1,339/s(SQLite WAL FULL 84–153/s) | [090](docs/research/090-tables.md) | +| 일반 트랜잭션 | TPC-C의 다섯 트랜잭션을 aup 앱 하나로 쓴다(여러 표 읽기·쓰기, 조각 훑기, 중복 없는 셈, 실패하면 통째 되돌림). 같은 입력에서 SQLite와 아홉 표의 모든 행이 같다. W=1에서 RAM 초당 39,449건(SQLite FULL 9,493, 트랜잭션당 CPU 45.5–50 µs, HTTP·JSON 포함), SD 카드 내구 3,073건(SQLite FULL 55, NORMAL 372) | [092](docs/research/092-tpcc.md) | | 질의 | 질의는 앱의 상태 위에 보기를 더한 모듈이다(`aup query`, `PUT·GET /queries/이름`). 검증한 뒤 스냅숏에서 돌고, 열 안의 fold가 그 행을 읽는다(상관 부질의). 잎이 항목을 필드별로 들고, fold와 필터는 잎 하나씩 돌며(결함이 날 잎만 항목별로), 열 안의 그룹·순위 fold는 목록을 한 번 지나 모든 행에 답한다. 10만 계좌에서 지점별 합계 4.9 ms(SQLite 45, 인덱스를 주면 14.3), 조인 0.48 ms(1.47), 자기 조인 순위 6.8 ms(2,840, 인덱스를 주면 7.6). Pure에는 인덱스 선언이 없다 | [091](docs/research/091-queries.md) | 키로 나눈 소유자와 저장을 넘길 스레드는 하지 않기로 했다. 이 장치는 동기화를 한 줄로 세워, 나누면 쓰기가 23% 줄고 넘겨도 @@ -2,10 +2,12 @@ **Current app model (2026-09-27).** Apps are written in [aup](/srv/git/aup.git) and run as Pure modules ([format v5](docs/design/008-module-format.md)), verified completely before they execute: declared states persist -in Pure's tree store, every event is a transaction, views are pure functions of state. The same spec becomes a +in Pure's store (lists as images and a log of their changes, the rest in its tree), every event is a transaction, +views are pure functions of state. The same spec becomes a terminal UI, a CLI, spoken sentences and a web page through [Vega](/srv/git/vega.git), and an HTTP service through `pure-serve`: views are GETs and inputs are POSTs, with JSON bodies (groups included) read in place; answers are -durable, a commit being one contiguous write and one sync; views are read from a snapshot of the last save, so +durable, a save being one log entry (and a tree commit when more than lists changed), each one write and one sync; +views are read from a snapshot of the last save, so reads never wait for a sync. What an app sends is saved with its event and delivered over HTTP in order and in batches, and a receiving app applies each message once; sealed to the receiver's key (HPKE, one session a wire, authenticated by the sender's key), no relay between them can read or change it, at the speed of unsealed sends. @@ -14,13 +16,17 @@ shows a line on the other node's screen in about a millisecond). Queries are mod their own (`aup query`), verified when registered and shown from snapshots. Folds run a leaf of a list at a time and a fold in a table's column is answered for every row from one pass over its list, so on pgbench's tables of 100,000 accounts a group-by, a join and a correlated rank run 3–420x faster than on SQLite, and faster than SQLite given a -DBA's indexes but for one count, with no index declared. Measurements against axum and drogon on a Raspberry Pi 5 +DBA's indexes but for one count, with no index declared. TPC-C's five transactions, written as one aup app, leave +SQLite's state row for row from the same inputs, and run 4x faster than SQLite in RAM (39,449 against 9,493 a +second, HTTP and JSON included) and 56x faster on an SD card (3,073 against 55), both syncing each commit +([092](docs/research/092-tpcc.md)). Measurements against axum and drogon on a Raspberry Pi 5 are in [075](docs/research/075-http-host-against-axum.md), [077](docs/research/077-apps-as-http-services.md), [080](docs/research/080-snapshot-reads.md), [081](docs/research/081-tail-records.md), [082](docs/research/082-json-in-place.md), [083](docs/research/083-delivery-over-http.md), [086](docs/research/086-sealed-sends.md), [087](docs/research/087-small-reads-and-idle-memory.md), [088](docs/research/088-sealed-sessions.md), [089](docs/research/089-messenger.md), -[090](docs/research/090-tables.md) and [091](docs/research/091-queries.md); the status table is +[090](docs/research/090-tables.md), [091](docs/research/091-queries.md) and [092](docs/research/092-tpcc.md); the +status table is [CHECKPOINT.md](CHECKPOINT.md). The source follows the principle below, a small core with the rest selected around it diff --git a/benchmarks/tpcc/run.py b/benchmarks/tpcc/run.py index f24126f..1e18100 100644 --- a/benchmarks/tpcc/run.py +++ b/benchmarks/tpcc/run.py @@ -16,7 +16,7 @@ WAL), both kept in --directory. Then: SQLite's from each count of --sqlite-threads threads, each with its own connection, on cores 0-2, synchronous --sync (FULL syncs each commit before it is answered, as Pure does). -3. The consistency conditions of clause 3.3.2 on both after each run. +3. The consistency conditions of clause 3.3.2 on both after each run, each side read from one snapshot. Prints one JSON line a step. """ @@ -172,20 +172,28 @@ DUMPS = { 'SELECT (h_c_w_id * 10 + h_c_d_id) * 3000 + h_c_id, h_w_id * 10 + h_d_id, h_amount FROM history'), } -# Clause 3.3.2.2 and 3.3.2.3, a district a row: its next order, its newest order, and its oldest and newest new orders -# and how many (orders and new orders by their numbers in the district). +# Clause 3.3.2.1-4, a district a row, from one snapshot (one query, one SELECT): its next order, its newest +# order, its oldest and newest new orders and how many (orders by their numbers in the district), the lines its +# orders count and the lines it has, and the warehouses' and districts' year-to-date totals. DISTRICTS = ('each d in districts {\n id = d.id\n next_order = d.next_order\n' ' newest = max(o.id for o in orders[d.id * 10000000 .. d.id * 10000000 + 10000000]) - d.id * 10000000\n' ' newest_new = max(n.id for n in new_orders[d.id * 10000000 .. d.id * 10000000 + 10000000])' ' - d.id * 10000000\n' ' oldest_new = min(n.id for n in new_orders[d.id * 10000000 .. d.id * 10000000 + 10000000])' ' - d.id * 10000000\n' - ' new_orders = count(n in new_orders[d.id * 10000000 .. d.id * 10000000 + 10000000])\n}', + ' new_orders = count(n in new_orders[d.id * 10000000 .. d.id * 10000000 + 10000000])\n' + ' ordered = sum(o.lines for o in orders[d.id * 10000000 .. d.id * 10000000 + 10000000])\n' + ' lines = count(l in lines[d.id * 160000000 .. d.id * 160000000 + 160000000])\n' + ' warehouse_ytd = sum(w.ytd for w in warehouses)\n' + ' district_ytd = sum(x.ytd for x in districts)\n}', 'SELECT d_w_id * 10 + d_id, d_next_o_id, ' '(SELECT max(o_id) FROM orders WHERE o_w_id = d_w_id AND o_d_id = d_id), ' '(SELECT max(no_o_id) FROM new_order WHERE no_w_id = d_w_id AND no_d_id = d_id), ' '(SELECT min(no_o_id) FROM new_order WHERE no_w_id = d_w_id AND no_d_id = d_id), ' - '(SELECT count(*) FROM new_order WHERE no_w_id = d_w_id AND no_d_id = d_id) FROM district') + '(SELECT count(*) FROM new_order WHERE no_w_id = d_w_id AND no_d_id = d_id), ' + '(SELECT sum(o_ol_cnt) FROM orders WHERE o_w_id = d_w_id AND o_d_id = d_id), ' + '(SELECT count(*) FROM order_line WHERE ol_w_id = d_w_id AND ol_d_id = d_id), ' + '(SELECT sum(w_ytd) FROM warehouse), (SELECT sum(d_ytd) FROM district) FROM district') def register(pure, aup, work, name, view): @@ -218,23 +226,18 @@ def compare(table, pure_rows, sqlite_rows_): def consistency(pure, db): - """Clause 3.3.2.1-4 on both: the conditions each fails (none when consistent).""" + """Clause 3.3.2.1-4 on both, each read from one snapshot: the conditions failed (none when consistent).""" failures = [] - for system, ytd, district_ytd, districts, ordered, lines in ( - ('pure', pure.view('warehouse_ytd'), pure.view('district_ytd'), asked(pure, 'district_check'), - pure.view('ordered_lines'), pure.view('line_count')), - ('sqlite', *db.execute('SELECT (SELECT sum(w_ytd) FROM warehouse), (SELECT sum(d_ytd) FROM district)').fetchone(), - db.execute(DISTRICTS[1]).fetchall(), *db.execute( - 'SELECT (SELECT sum(o_ol_cnt) FROM orders), (SELECT count(*) FROM order_line)').fetchone())): - if not same(ytd, district_ytd): - failures.append((system, '3.3.2.1', ytd, district_ytd)) - for key, next_order, newest, newest_new, oldest_new, new_orders in districts: + for system, districts in (('pure', asked(pure, 'district_check')), ('sqlite', db.execute(DISTRICTS[1]).fetchall())): + for key, next_order, newest, newest_new, oldest_new, new_orders, ordered, lines, ytd, district_ytd in districts: + if not same(ytd, district_ytd): + failures.append((system, '3.3.2.1', ytd, district_ytd)) if not next_order - 1 == newest == newest_new: failures.append((system, '3.3.2.2', key, next_order, newest, newest_new)) if newest_new - oldest_new + 1 != new_orders: failures.append((system, '3.3.2.3', key, oldest_new, newest_new, new_orders)) - if ordered != lines: - failures.append((system, '3.3.2.4', ordered, lines)) + if ordered != lines: + failures.append((system, '3.3.2.4', key, ordered, lines)) return failures @@ -303,7 +306,7 @@ def main(): 'failed': [sum(pure_run[k]['failed'] for k in kinds), sum(sqlite_run[k]['failed'] for k in kinds)], 'differing': differing, 'consistency_failures': consistency(pure, db)}), flush=True) - for terminals in (int(t) for t in args.terminals.split(',')): + for terminals in (int(t) for t in args.terminals.split(',') if t): used = pure.cpu_seconds() result = json.loads(subprocess.run( ['taskset', '-c', '3', str(work / 'tpcc-load'), str(pure.port), str(terminals), str(args.seconds), @@ -314,7 +317,7 @@ def main(): 'cpu_us_per_transaction': round(used / max(done, 1) * 1e6, 1), 'resident_mib': pure.resident_mib(), 'consistency_failures': consistency(pure, db)}), flush=True) - for threads in (int(t) for t in args.sqlite_threads.split(',')): + for threads in (int(t) for t in args.sqlite_threads.split(',') if t): result = json.loads(subprocess.run( ['taskset', '-c', '0-2', str(work / 'sqlite-tpcc'), str(database), str(args.warehouses), str(threads), str(args.seconds), args.sync, str(args.seed + threads)], capture_output=True, text=True, diff --git a/docs/research/092-tpcc.md b/docs/research/092-tpcc.md new file mode 100644 index 0000000..fec38f4 --- /dev/null +++ b/docs/research/092-tpcc.md @@ -0,0 +1,187 @@ +# 092 — TPC-C: 같은 트랜잭션, 같은 상태, RAM에서 SQLite의 4배, SD 카드에서 56배 + +2026-09-28. 외부 피드백 002의 둘째 항목(범용성)의 마지막 조각. [090](090-tables.md)이 표 크기의 상태와 한 트랜잭션, +[091](091-queries.md)이 임의 질의를 세웠다면, 여기서는 OLTP의 표준 부하인 TPC-C의 다섯 트랜잭션(New-Order, Payment, +Order-Status, Delivery, Stock-Level)을 aup 앱 하나로 쓰고, 같은 트랜잭션을 SQL로 쓴 SQLite와 겨룬다. 키-값이 아닌 일반 +트랜잭션이다. 한 트랜잭션이 아홉 표 가운데 여러 표를 읽고, 행을 여럿 바꾸고, 목록을 조각내 훑고, 실패하면 통째로 +되돌려진다. + +착수 기준: + +- 바꿀 결정: TPC-C를 앱 코드로 쓸 수 있는가(언어), 같은 입력에서 SQLite와 같은 상태로 끝나는가(의미), 그리고 어디서 + 느린가(런타임과 저장). 표현이 모자라면 aup를, 느리면 런타임을, 저장이 막으면 저장소를 고친다. +- 최소 확인: 같은 입력열(한 터미널, 같은 시드)을 두 쪽에 주고 모든 표의 모든 행을 비교한다(시간만 빼고). 그 뒤에 RAM과 SD + 카드에서 트랜잭션 비율 45/43/4/4/4로 처리량과 지연을 잰다. 두 쪽 모두 커밋마다 동기화한다(SQLite는 WAL과 + `synchronous=FULL`). +- 중단 기준: 상태가 한 행이라도 다르면 재지 않는다. + +## TPC-C를 aup로 + +[tpcc.aup](../../benchmarks/tpcc/tpcc.aup)는 표 아홉 개를 키 있는 목록으로 두고 복합 키를 수 하나로 접는다(구역 +`w*10+d`, 고객 `구역*3000+c`, 주문 `구역*10^7+o`, 주문 줄 `주문*16+번호`, 재고 `w*100000+i`). 다섯 트랜잭션은 규격 2절의 +절차 그대로다. New-Order는 구역의 다음 주문 번호를 올리고, 줄마다 상품 가격을 읽고, 재고를 줄이거나 채우고, 주문 줄을 +넣는다. 없는 상품(주문의 1%)이 나오면 이벤트가 실패하고 그때까지의 변경이 모두 되돌려진다. Delivery는 구역의 가장 오래된 +미배송 주문의 줄들을 조각(`lines[주문*16 .. 주문*16+16]`)으로 훑어 금액을 더한다. Stock-Level은 구역의 마지막 20 주문의 +줄을 조각으로 훑어 재고가 기준보다 적은 상품을 중복 없이 센다(`distinct`). + +```aup +on new_order { + let district = districts[new_order.w * 10 + new_order.d] + let customer = customers[district.id * 3000 + new_order.c] + let order = district.id * 10000000 + district.next_order + district.next_order = district.next_order + 1 + let local = 1 + let count = 0 + for l in new_order.lines { + let price = items[l.item].price + let s = stock[l.supply * 100000 + l.item] + if s.quantity >= l.quantity + 10 { + s.quantity = s.quantity - l.quantity + } else { + s.quantity = s.quantity - l.quantity + 91 + } + ... + lines.add(Line { id: order * 16 + count, item: l.item, supply: l.supply, quantity: l.quantity, + amount: l.quantity * price }) + } + orders.add(Order { id: order, customer: customer.id, entry: now, lines: count, local: local }) + new_orders.add(NewOrder { id: order }) + customer.last_order = order +} +``` + +규격과 다른 점: 고객은 늘 번호로 고른다(성으로 고르지 않는다). 트랜잭션이 읽기만 하는 문자열(이름·주소·데이터)은 뺐다. +고객은 마지막 주문을 기억한다(Order-Status가 가장 큰 주문 번호를 찾는 대신). Delivery는 구역 하나가 이벤트 하나다 +(2.7.4가 허용). 생각 시간과 입력 시간이 없으므로 수는 tpmC가 아니다(New-Order 수는 적는다). SQLite 쪽 +([sqlite-tpcc.c](../../benchmarks/tpcc/sqlite-tpcc.c))은 규격의 표와 복합 기본 키(`WITHOUT ROWID`)를 쓰고, 같은 차이를 +같은 SQL로 둔다. 터미널마다 연결 하나, 쓰기는 `BEGIN IMMEDIATE`, 읽기(Order-Status, Stock-Level)는 스냅숏 하나에서 한다. + +## 같은 트랜잭션, 같은 상태 + +[run.py](../../benchmarks/tpcc/run.py)는 두 드라이버가 같은 순서로 난수를 뽑게 한다. 한 터미널과 같은 시드면 두 쪽에 +정확히 같은 트랜잭션이 간다. 그 뒤 Pure의 상태는 등록한 질의(`PUT /queries`)로, SQLite의 상태는 SELECT로 읽어 표마다 모든 +행을 비교한다. 이 비교가 처음 잡은 것은 SQLite 드라이버의 버그였다. `sqlite3_bind`를 `reset` 전에 불러 바인딩이 무시됐고 +(`SQLITE_MISUSE`), 구역 번호가 첫 New-Order의 것으로 굳었다. 고친 뒤 2만 트랜잭션(RAM)과 2천 트랜잭션(SD)에서 **아홉 +표의 모든 행이 같다**(주문 9,036, 결제 8,518, 되돌린 주문 90이 두 쪽 모두 같다). 규격 3.3.2의 일관성 조건(창고 YTD = 구역 +YTD 합, 구역의 다음 주문 = 최신 주문 = 최신 미배송 주문 + 1, 미배송 주문 수 = 번호 범위, 주문의 줄 수 합 = 주문 줄 수)은 두 +쪽 모두 모든 측정 뒤에 성립한다. + +## 찾은 것 + +첫 측정(RAM, 16터미널)에서 Pure는 초당 4,858건이었다. 같은 RAM에서 SQLite(프로세스 안, HTTP 없음)는 9,504건이었다. Pure는 +트랜잭션당 CPU를 213 µs 썼다. 샘플러([sample.c](../../benchmarks/sample.c))가 보인 것은 트랜잭션이 아니라 **저장**이었다. +libc의 memcpy가 41%였고, 트리 저장소의 편집(`chunk_bytes`, `children`, `digest_add`, `write_subtree`)이 그 뒤를 이었다. 목록의 +저장본은 트리 저장소의 문서 하나였고, 필드 하나(8바이트)가 바뀔 때마다 그 문서를 경로 복사로 고쳤다. 4 KiB 잎 하나와 +층마다 자식 배열을 복사하는 일이다. New-Order 하나가 그런 편집을 40–60번 했다. 저장본 반영을 꺼 본 실험에서는 16,210건이 +나왔다. 비용의 70%가 저장이었다. + +## 바꾼 것 + +**1. 목록의 변경을 기록으로(저널).** 목록 연산은 메모리의 항목을 바꾸고 몇 바이트짜리 기록만 남긴다(종류 u8, 위치 u32, +삽입이면 항목, 설정이면 필드와 값). 이벤트가 실패하면 기록을 표시한 길이로 자르고, 저장은 기록을 붙인다. 이것으로 13,820건. + +**2. 안내 노드.** 남은 비용은 B+트리 탐색이었다(`pure_list_seek` 25%). 내부 노드의 이진 탐색이 탐침마다 자식 머리와 그 첫 +잎을 따라가 캐시 미스를 두 번 냈고, 위치로 찾을 때는 자식 32개의 항목 수를 하나씩 역참조했다. 내부 노드가 이제 자식 옆에 +안내(guide)를 둔다. 자식마다 앞선 항목 수(누적)와 첫 키다. 빈 칸은 어떤 위치·키보다 크게 채워, 한 층을 길이가 고정된 +분기 없는 이진 탐색(조건부 선택)으로 찾는다. 큰 목록(13만 항목 초과)의 잎은 키 줄 네 개를 한꺼번에 가져온다. 키 필드가 +아닌 필드의 설정은 스레드의 손가락(마지막 잎)이 공유되지 않은 잎을 가리키면 그 자리에서 바꾼다. 무작위 탐색 시간: + +| 항목 | 전 | 후 | +|---:|---:|---:| +| 1,000 | 129 ns | 46 ns | +| 30,000 | 193 ns | 73 ns | +| 300,000 | 384 ns | 200–233 ns | +| 1,000,000 | 667 ns | 252 ns | + +**3. 수의 읽기와 쓰기.** JSON의 수는 strtod로, 답의 정수 아닌 수는 정밀도 1–17을 차례로 `snprintf`+`strtod`로 시험해 썼다. +읽기는 이제 Clinger의 빠른 길을 쓴다. 가수 < 2^53이고 10의 지수가 22 이하면 곱셈이나 나눗셈 한 번이 정확히 반올림한다. +쓰기는 소수 k자리 정수 m을 고른다. 곱의 정확한 오차를 `fma`로 구해 가장 가까운 m을 찾고, m/10^k가 값으로 돌아오면 그 자리가 +`%g`의 자리다. 딱 절반이면 옛 길로 간다. 무작위 값 300만 개(쓰기)와 900만 개(읽기)에서 옛 결과와 비트 단위로 같다 +([test-message-json.c](../../serve/test-message-json.c), `number_texts`). + +**4. 모으기는 장치가 기다린 만큼.** 스레드별 CPU를 재니 소유자는 72%만 일하고 있었다. 저장한 라운드 뒤 소유자는 돌아오는 +요청을 모으려고 최대 1 ms(100 µs 조용할 때까지) 기다렸다([081](081-tail-records.md)). 동기화가 수 ms인 SD 카드에서는 저장 +한 번에 더 담게 해 주지만, 동기화가 공짜인 RAM에서는 쉬는 시간이다. 이제 모으는 시간은 직전 저장이 장치를 기다린 시간(벽 +시계 시간에서 스레드 CPU 시간을 뺀 것)을 넘지 않는다. 16/64/128터미널에서 17.9k/25.6k/30.1k건이 22.8k/31.8k/35.8k건이 됐다. + +**5. SD 카드가 말한 것.** 같은 저장 구조로 SD 카드에서 재자 30초 가운데 16초 동안 처리가 0이었다. `strace`로 보니 트리 +저장소의 압축이었다. 살아 있는 데이터(25 MB)에 다음 주기의 0 채움까지 42 MB를 새 파일에 쓰고 한 번에 `fsync`했는데, 그 +`fsync`가 10초를 넘겼다. 이 카드는 처음 약 16 MB를 초당 29 MB로 받고 그 뒤로는 **초당 3–8 MB**로 떨어진다. 64 MB를 쓰고 +동기화하는 데 20초가 걸렸다. 커밋의 동기화 지연은 제자리 덮어쓰기 p50 1.2 ms, 파일 끝 덧붙이기 p50 4.5 ms(p90 25 ms)다. 목록 +문서에 기록을 붙이는 방식도 커밋마다 목록마다 꼬리 잎(최대 4 KiB)과 경로를 다시 써서 쓰레기를 계속 만들었고, 그것이 압축을 +불렀다. 수십 MB를 커밋 경로에서 한 번에 쓰는 설계는 이 장치에서 수 초의 정지가 된다. + +**6. 이미지와 로그.** 그래서 목록은 트리를 떠났다([008](../design/008-module-format.md) 상태 영속, +[log.h](../../core/log.h), [store-log.c](../../store/store-log.c)). + +- **로그.** 목록을 바꾼 저장마다 항목 하나를 구간 파일(`log-0`, `log-1`, ...)에 붙이고 동기화한다. 구간은 처음 만들 때 통째로 + 할당하고, 이미지가 모두 담은 항목만 가진 구간은 다시 쓴다. 그래서 덧붙이기는 파일을 키우지도 새 블록을 받지도 않고, + 동기화가 데이터만 쓴다. 항목은 저장 번호, 앞 항목의 번호와 **다이제스트**, 트리도 커밋했는지, 페이로드(바뀐 목록마다 이름과 + 기록)다. 번호는 건너뛰고(목록을 바꾸지 않은 저장) 다시 쓰이므로(거둔 항목), 항목은 번호가 아니라 앞 항목의 다이제스트로 + 이어진다. 구간은 고유한 세대와 이어받는 구간의 세대를 머리에 적는다. 그래서 찢긴 쓰기도, 재사용한 구간에 남은 옛 바이트도 + 이어지지 않는다. +- **이미지.** 목록의 항목을 파일 하나(`image-이름`)에 통째로 쓴다. 옆에 써서 동기화하고 이름을 바꿔 끼우며, 파일 전체를 + 다이제스트로 확인한다. 이미지는 자기가 담은 저장 번호를 적고, 시작은 이미지를 읽은 뒤 그 번호 뒤의 기록만 다시 한다. 쓰기는 + 256 KiB씩 장치로 흘려보낸다(`sync_file_range`). 그래서 로그의 동기화는 기다릴 것이 적다. +- **체크포인트.** 목록의 로그 기록이 항목 바이트(최소 1 MiB)를 넘으면 스냅숏에서 이미지를 새로 쓴다. 로그 전체가 모든 목록의 + 항목 바이트(최소 64 MiB)를 넘어도, 로그를 가장 오래 붙잡은 목록의 이미지를 쓴다. pure-serve는 이것을 소유자 밖의 스레드에서 + 한다. 목록이 스냅숏에서 공유되므로 이미지를 쓰는 동안에도 소유자는 계속 이벤트를 적용한다. 목록은 로그에 변경이 적히기 전에 + 이미지를 가진다. 그래서 기록은 언제나 그 목록 이미지의 필드로 읽힌다. +- **원자성.** 로그 항목이 있고 트리도 커밋하는 저장(수·text·링크·문서도 바꾼 저장)은 번호를 트리의 상태 `seq`에 함께 쓴다. + 트리도 커밋하는 저장의 항목은 트리가 그 번호(이상)를 가질 때만 센다. 그래서 로그를 먼저 쓰고 트리를 커밋하는 사이에 + 죽어도 둘 다 남거나 둘 다 없다. 트리 커밋이 확실히 실패하면 항목을 거두고, 다음 저장이 같은 번호로 그 자리에 쓴다. + 동기화가 실패해 저장 여부를 모르면(불확실) 앱은 다시 시작할 때까지 저장하지 않는다. +- **검증.** [test-log.c](../../store/test-log.c)는 로그를 항목의 모델과 겨룬다. 크기(구간을 넘는 것까지), 거두기, 해제, + 끝 자르기, 다시 열기를 6,000단계 섞고(다시 열기 1,045번), 시드 40개를 ASan·UBSan으로 돌린다. 구간 파일은 15개를 넘지 + 않는다. [test-module.c](../../core/test-module.c)는 저장 앱과 메모리 앱을 같은 무작위 이벤트로 돌리며 수시로 다시 시작해, + 체크포인트가 끼어도 매 단계 같음을 본다. 프로세스를 커밋의 모든 단계(로그 쓰기 전, 로그 동기화 전, 트리의 각 단계)에서 + 죽여도, 다시 시작하면 전부 이전 아니면 전부 이후다. 실패(죽지 않고)는 그 저장만 없던 일이 되고, 불확실은 다시 시작할 + 때까지 저장을 막는다. 이전 형식(`PLS1`, `PLS2`)의 저장소는 시작할 때 이미지로 옮겨진다. + +## 측정 + +W=1(창고 하나, 초기 행 약 57만), Raspberry Pi 5(4코어), 서버는 코어 0–2, Pure의 부하 생성기 +([tpcc-load.c](../../benchmarks/tpcc/tpcc-load.c), HTTP/1.1과 JSON)는 코어 3. SQLite 3.46.1은 코어 0–2에서 프로세스 안으로 +돈다(HTTP도 JSON도 없다). 20초(RAM)와 30초(SD), 원시 결과는 [018-tpcc](../../research/experiments/018-tpcc/). + +**RAM(tmpfs).** 동기화가 공짜이므로 CPU를 겨룬다. + +| | 초당 트랜잭션 | 분당 New-Order | New-Order p50 / p99 | 트랜잭션당 서버 CPU | +|---|---:|---:|---:|---:| +| Pure, 16터미널 | 35,863 | 959,979 | 0.45 / 1.3 ms | 45.5 µs | +| Pure, 64터미널 | 38,564 | 1,031,566 | 1.7 / 3.8 ms | 48.7 µs | +| Pure, 128터미널 | **39,449** | 1,052,334 | 3.3 / 7.4 ms | 50.0 µs | +| SQLite FULL, 1스레드 | 9,493 | 254,310 | 0.15 / 1.2 ms | | +| SQLite FULL, 4스레드 | 9,141 | 244,796 | 0.16 / 1.2 ms | | + +16터미널은 바꾸기 전 4,858건에서 7.4배가 됐다. 트랜잭션당 CPU는 213 µs에서 45.5 µs로 줄었다. 이 CPU에는 HTTP와 JSON이 +들어 있다. 적재(57만 행)는 Pure 0.72초(이진 행을 한 요청에 1.4 MB씩), SQLite 1.56초(파이썬 `executemany`)다. + +**SD 카드(ext4).** 두 쪽 모두 커밋이 장치에 닿은 뒤 답한다. + +| | 초당 트랜잭션 | 분당 New-Order | New-Order p50 / p99 | +|---|---:|---:|---:| +| Pure, 16터미널 | 1,465 | 39,149 | 5.6 / 74 ms | +| Pure, 64터미널 | 3,008 | 80,545 | 8.7 / 221 ms | +| Pure, 128터미널 | **3,073** | 82,063 | 11.8 / 623 ms | +| SQLite FULL, 1스레드 | 37 | 1,012 | 5.1 / 557 ms | +| SQLite FULL, 4스레드 | 55 | 1,425 | 5.1 / 1,245 ms | + +카드는 상태에 따라 흔들린다. 같은 카드에서 세 번 잰 128터미널은 3,073–5,396건, SQLite FULL은 48–57건이었다. 로그로 옮기기 +전(목록을 트리에 두던 때)에는 30초 가운데 16초가 0이었고, 이제는 매초 처리한다. + +SQLite의 `synchronous=NORMAL`(WAL 체크포인트 때만 동기화해 전원이 끊기면 최근 커밋을 잃는다)은 초당 36건(1스레드), +372건(4스레드)이다. `OFF`(동기화 없음)는 8,941건이다. Pure는 내구성을 낮추지 않고도 NORMAL의 8배다. + +## 다음 + +- **기록을 더 작게.** 로그는 트랜잭션당 약 0.52 KB다. 이 카드의 지속 쓰기(초당 3–8 MB)에 닿는 양이다. 수가 모두 + binary64(8바이트)로 적히지만 TPC-C의 값은 대부분 작은 정수다. 정수를 가변 길이로 적고 한 저장에서 같은 항목의 여러 설정을 + 한 기록으로 합치면 절반 아래로 준다. +- **덧붙는 목록의 이미지.** 주문 줄처럼 뒤에 붙기만 하는 목록도 체크포인트가 이미지 전체를 다시 쓴다. 바뀐 잎만 새로 쓰는 + 이미지(이전 이미지와 잎을 나눠 쓰는)가 다음이다. +- **메모리.** 16/64/128터미널을 차례로 20초씩 돌린 뒤 서버의 상주 메모리는 0.5/0.9/1.3 GB다. 대부분 늘어난 주문 줄(약 1,000만 + 행)이지만 데이터의 약 두 배다. 잎의 채움과 스냅숏이 붙잡는 옛 잎을 잴 것. +- **W > 1과 진짜 tpmC.** 창고가 여럿이면 원격 재고·원격 결제가 생기고, 생각 시간을 두면 규격의 tpmC가 된다. +- **트리 저장소의 압축.** 목록은 이제 트리를 쓰지 않지만 문서와 text는 쓴다. 압축은 여전히 커밋 경로에서 살아 있는 데이터를 + 통째로 복사한다. 배경에서 복사하고 그동안의 커밋만 옮기는 압축이 필요하다. diff --git a/research/experiments/018-tpcc/2026-09-28-tpcc-ram-linux-aarch64.jsonl b/research/experiments/018-tpcc/2026-09-28-tpcc-ram-linux-aarch64.jsonl new file mode 100644 index 0000000..abd4870 --- /dev/null +++ b/research/experiments/018-tpcc/2026-09-28-tpcc-ram-linux-aarch64.jsonl @@ -0,0 +1,7 @@ +{"step": "load", "directory": "/dev/shm", "warehouses": 1, "rows": 568758, "generate_s": 0.55, "pure_load_s": 0.72, "sqlite_load_s": 1.56, "pure_resident_mib": 40.8, "sqlite_file_mib": 13.8} +{"step": "same transactions", "transactions": 20000, "pure": {"new_order": 9036, "payment": 8518, "order_status": 811, "delivery": 796, "stock_level": 839}, "sqlite": {"new_order": 9036, "payment": 8518, "order_status": 811, "delivery": 796, "stock_level": 839}, "undone_new_orders": [90, 90], "failed": [0, 0], "differing": {}, "consistency_failures": []} +{"step": "run", "system": "pure", "warehouses": 1, "terminals": 16, "transactions_per_second": 35863, "new_orders_per_minute": 959979, "undone_new_orders": 3268, "errors": 0, "new_order": {"done": 323261, "failed": 0, "p50_us": 448, "p99_us": 1280}, "payment": {"done": 308146, "failed": 0, "p50_us": 416, "p99_us": 1216}, "order_status": {"done": 28732, "failed": 0, "p50_us": 96, "p99_us": 544}, "delivery": {"done": 28543, "failed": 0, "p50_us": 432, "p99_us": 1216}, "stock_level": {"done": 28586, "failed": 0, "p50_us": 144, "p99_us": 640}, "server_threads": "3", "cpu_us_per_transaction": 45.5, "resident_mib": 480.6, "consistency_failures": []} +{"step": "run", "system": "pure", "warehouses": 1, "terminals": 64, "transactions_per_second": 38564, "new_orders_per_minute": 1031566, "undone_new_orders": 3300, "errors": 0, "new_order": {"done": 347158, "failed": 0, "p50_us": 1664, "p99_us": 3840}, "payment": {"done": 331554, "failed": 0, "p50_us": 1600, "p99_us": 3840}, "order_status": {"done": 30996, "failed": 0, "p50_us": 272, "p99_us": 1792}, "delivery": {"done": 30934, "failed": 0, "p50_us": 1664, "p99_us": 3840}, "stock_level": {"done": 30644, "failed": 0, "p50_us": 304, "p99_us": 1920}, "server_threads": "3", "cpu_us_per_transaction": 48.7, "resident_mib": 892.9, "consistency_failures": []} +{"step": "run", "system": "pure", "warehouses": 1, "terminals": 128, "transactions_per_second": 39449, "new_orders_per_minute": 1052334, "undone_new_orders": 3568, "errors": 0, "new_order": {"done": 354349, "failed": 0, "p50_us": 3328, "p99_us": 7424}, "payment": {"done": 339392, "failed": 0, "p50_us": 3200, "p99_us": 7424}, "order_status": {"done": 31880, "failed": 0, "p50_us": 496, "p99_us": 2816}, "delivery": {"done": 31563, "failed": 0, "p50_us": 3200, "p99_us": 7424}, "stock_level": {"done": 31802, "failed": 0, "p50_us": 512, "p99_us": 3072}, "server_threads": "3", "cpu_us_per_transaction": 50.0, "resident_mib": 1282.7, "consistency_failures": []} +{"step": "run", "system": "sqlite 3.46.1", "synchronous": "full", "warehouses": 1, "terminals": 1, "transactions_per_second": 9493, "new_orders_per_minute": 254310, "undone_new_orders": 894, "new_order": {"done": 85669, "failed": 0, "p50_us": 152, "p99_us": 1152}, "payment": {"done": 81386, "failed": 0, "p50_us": 25, "p99_us": 50}, "order_status": {"done": 7574, "failed": 0, "p50_us": 18, "p99_us": 31}, "delivery": {"done": 7659, "failed": 0, "p50_us": 60, "p99_us": 108}, "stock_level": {"done": 7579, "failed": 0, "p50_us": 272, "p99_us": 416}, "consistency_failures": []} +{"step": "run", "system": "sqlite 3.46.1", "synchronous": "full", "warehouses": 1, "terminals": 4, "transactions_per_second": 9141, "new_orders_per_minute": 244796, "undone_new_orders": 865, "new_order": {"done": 82571, "failed": 0, "p50_us": 160, "p99_us": 1216}, "payment": {"done": 78584, "failed": 0, "p50_us": 25, "p99_us": 160}, "order_status": {"done": 7284, "failed": 0, "p50_us": 19, "p99_us": 38}, "delivery": {"done": 7320, "failed": 0, "p50_us": 62, "p99_us": 1024}, "stock_level": {"done": 7293, "failed": 0, "p50_us": 288, "p99_us": 608}, "consistency_failures": []} diff --git a/research/experiments/018-tpcc/2026-09-28-tpcc-sd-linux-aarch64.jsonl b/research/experiments/018-tpcc/2026-09-28-tpcc-sd-linux-aarch64.jsonl new file mode 100644 index 0000000..46bf04b --- /dev/null +++ b/research/experiments/018-tpcc/2026-09-28-tpcc-sd-linux-aarch64.jsonl @@ -0,0 +1,7 @@ +{"step": "load", "directory": "/home/eunhhu/tpcc-sd", "warehouses": 1, "rows": 568758, "generate_s": 0.56, "pure_load_s": 6.31, "sqlite_load_s": 1.61, "pure_resident_mib": 40.8, "sqlite_file_mib": 13.8} +{"step": "same transactions", "transactions": 2000, "pure": {"new_order": 887, "payment": 870, "order_status": 82, "delivery": 84, "stock_level": 77}, "sqlite": {"new_order": 887, "payment": 870, "order_status": 82, "delivery": 84, "stock_level": 77}, "undone_new_orders": [6, 6], "failed": [0, 0], "differing": {}, "consistency_failures": []} +{"step": "run", "system": "pure", "warehouses": 1, "terminals": 16, "transactions_per_second": 1465, "new_orders_per_minute": 39149, "undone_new_orders": 188, "errors": 0, "new_order": {"done": 19763, "failed": 0, "p50_us": 5632, "p99_us": 73728}, "payment": {"done": 18841, "failed": 0, "p50_us": 5632, "p99_us": 73728}, "order_status": {"done": 1759, "failed": 0, "p50_us": 128, "p99_us": 432}, "delivery": {"done": 1832, "failed": 0, "p50_us": 5632, "p99_us": 69632}, "stock_level": {"done": 1769, "failed": 0, "p50_us": 192, "p99_us": 480}, "server_threads": "3", "cpu_us_per_transaction": 61.2, "resident_mib": 67.7, "consistency_failures": []} +{"step": "run", "system": "pure", "warehouses": 1, "terminals": 64, "transactions_per_second": 3008, "new_orders_per_minute": 80545, "undone_new_orders": 401, "errors": 0, "new_order": {"done": 40685, "failed": 0, "p50_us": 8704, "p99_us": 221184}, "payment": {"done": 38759, "failed": 0, "p50_us": 8704, "p99_us": 212992}, "order_status": {"done": 3711, "failed": 0, "p50_us": 352, "p99_us": 1280}, "delivery": {"done": 3594, "failed": 0, "p50_us": 8704, "p99_us": 221184}, "stock_level": {"done": 3521, "failed": 0, "p50_us": 400, "p99_us": 1280}, "server_threads": "3", "cpu_us_per_transaction": 55.9, "resident_mib": 236.9, "consistency_failures": []} +{"step": "run", "system": "pure", "warehouses": 1, "terminals": 128, "transactions_per_second": 3073, "new_orders_per_minute": 82063, "undone_new_orders": 405, "errors": 0, "new_order": {"done": 41521, "failed": 0, "p50_us": 11776, "p99_us": 622592}, "payment": {"done": 39762, "failed": 0, "p50_us": 11776, "p99_us": 622592}, "order_status": {"done": 3770, "failed": 0, "p50_us": 608, "p99_us": 2304}, "delivery": {"done": 3732, "failed": 0, "p50_us": 11264, "p99_us": 589824}, "stock_level": {"done": 3589, "failed": 0, "p50_us": 640, "p99_us": 2304}, "server_threads": "3", "cpu_us_per_transaction": 52.0, "resident_mib": 414.9, "consistency_failures": []} +{"step": "run", "system": "sqlite 3.46.1", "synchronous": "full", "warehouses": 1, "terminals": 1, "transactions_per_second": 37, "new_orders_per_minute": 1012, "undone_new_orders": 6, "new_order": {"done": 512, "failed": 0, "p50_us": 5120, "p99_us": 557056}, "payment": {"done": 474, "failed": 0, "p50_us": 2432, "p99_us": 163840}, "order_status": {"done": 45, "failed": 0, "p50_us": 34, "p99_us": 64}, "delivery": {"done": 41, "failed": 0, "p50_us": 2432, "p99_us": 65536}, "stock_level": {"done": 53, "failed": 0, "p50_us": 464, "p99_us": 576}, "consistency_failures": []} +{"step": "run", "system": "sqlite 3.46.1", "synchronous": "full", "warehouses": 1, "terminals": 4, "transactions_per_second": 55, "new_orders_per_minute": 1425, "undone_new_orders": 12, "new_order": {"done": 730, "failed": 0, "p50_us": 5120, "p99_us": 1245184}, "payment": {"done": 732, "failed": 0, "p50_us": 3072, "p99_us": 2621440}, "order_status": {"done": 65, "failed": 0, "p50_us": 34, "p99_us": 184}, "delivery": {"done": 69, "failed": 0, "p50_us": 4352, "p99_us": 491520}, "stock_level": {"done": 68, "failed": 0, "p50_us": 480, "p99_us": 928}, "consistency_failures": []} |
