From 65386f68fb53e5a9a20a284b06afd872172c30eb Mon Sep 17 00:00:00 2001 From: eunhhu Date: Fri, 25 Sep 2026 09:34:34 +0100 Subject: Let the runtime own working versions as drafts state.h gives any app a runtime-owned working version of a stored value on the tree store. A draft shares unchanged extents with the stored version, edits in memory by path copying, lends ordered extents without copying, and commits only its new nodes when the key is still at its base revision. A failed commit changes neither the draft nor the store; a draft whose file was replaced by compaction copies its stored extents on commit. Unreachable arena nodes are reclaimed once they outweigh max(reachable, 32 KiB). Typing into the middle of a 1 MiB document and saving each character peaked at about 1,660 KiB RSS with a draft against about 4,480 KiB for an app copy compared with storage on save; saves were only 1-2 ms faster. This corrects 073, which had attributed the TUI's remaining 8 ms to that comparison. Drafts are not yet charged to the runtime budget or visible to .pure definitions. --- README.md | 9 + docs/research/073-single-file-persistent-tree.md | 10 +- docs/research/074-runtime-owned-drafts.md | 97 +++++++++ prototypes/connections/Makefile | 2 +- prototypes/connections/README.md | 2 + prototypes/connections/state.h | 31 +++ prototypes/connections/store-tree.c | 193 +++++++++++++++++- prototypes/connections/test-tree-store.c | 218 ++++++++++++++++++++- .../2026-09-25-ext4-linux-aarch64.json | 69 +++++++ research/experiments/009-runtime-drafts/bench.c | 120 ++++++++++++ research/experiments/009-runtime-drafts/run.py | 45 +++++ 11 files changed, 782 insertions(+), 14 deletions(-) create mode 100644 docs/research/074-runtime-owned-drafts.md create mode 100644 prototypes/connections/state.h create mode 100644 research/experiments/009-runtime-drafts/2026-09-25-ext4-linux-aarch64.json create mode 100644 research/experiments/009-runtime-drafts/bench.c create mode 100755 research/experiments/009-runtime-drafts/run.py diff --git a/README.md b/README.md index 2ae94db..4516cce 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,15 @@ unchanged runtime, splice, async and real-PTY TUI checks pass on it larger, contiguous reads of multi-leaf documents copy, and space is reclaimed only by compaction, so it remains an opt-in profile. +On that store the runtime can own an app's working version +([drafts](docs/research/074-runtime-owned-drafts.md), `state.h`). A draft +shares unchanged extents with the stored version, lends ordered extents +without copying, and commits only its new nodes. Typing into the middle of a +1 MiB document and saving each character peaked at 1,648–1,664 KiB RSS with +drafts. An app copy that is compared with storage on save peaked at +4,464–4,496 KiB. Saves were only about 1–2 ms faster; drafts are not yet +exposed to `.pure` definitions or charged to the runtime budget. + The current execution profile has separate `core`, `fixed-editor`, `editor`, and `server` build targets. `make all` produces the core and the parser-free CLI from the build-time prepared `editor.pure`; it also builds a compiler tool that diff --git a/docs/research/073-single-file-persistent-tree.md b/docs/research/073-single-file-persistent-tree.md index e2a2ee9..092b6ad 100644 --- a/docs/research/073-single-file-persistent-tree.md +++ b/docs/research/073-single-file-persistent-tree.md @@ -116,11 +116,11 @@ TUI의 실제 PTY 검사(1 MiB, UTF-8, 충돌, 1,024개 문서 목록)다. 기 ## 다음 결정 -TUI의 17 ms 중 저장소 자체는 약 8.5 ms다. 나머지는 저장본 전체를 연속 뷰로 다시 읽어 -초안과 비교하는 "초안 대 저장본 비교" 관례의 비용이다. 편집기는 무엇을 바꿨는지 이미 알고 있다. +TUI의 17 ms 중 저장소 자체는 약 8.5 ms다. 처음에는 나머지 전부를 저장본을 다시 읽어 초안과 +비교하는 비용으로 추정했으나 틀렸다. [074](074-runtime-owned-drafts.md)에서 저장소 API 수준으로 +재 보니 그 비용은 약 1–2 ms였다. 나머지는 화면 갱신·PTY·런타임 경로이며 아직 분해하지 않았다. -1. 편집 연산을 그대로 전달하고, 초안을 같은 트리의 커밋되지 않은 루트로 둔다. 그러면 - 전체 비교, 전체 복사, 별도 gap buffer가 사라지는지 확인한다. -2. 연속 뷰 대신 익스텐트 열을 소비자에게 준다(HTTP `writev`, 피어 전송, TUI 렌더). +1. 작업 중인 값을 앱이 따로 복사해 두지 않고 런타임이 소유한다 — [074](074-runtime-owned-drafts.md)에서 구현했다. +2. 연속 뷰 대신 익스텐트 열을 소비자에게 준다(HTTP `writev`, 피어 전송, 렌더). 3. Pure 간 동기화에서 상대에게 없는 노드만 보낸다. 4. 코드 크기를 줄인다. 기본 저장소 승격 여부는 1–4의 결과와 macOS·전원 차단 검증 뒤에 판단한다. diff --git a/docs/research/074-runtime-owned-drafts.md b/docs/research/074-runtime-owned-drafts.md new file mode 100644 index 0000000..71ac8c9 --- /dev/null +++ b/docs/research/074-runtime-owned-drafts.md @@ -0,0 +1,97 @@ +# 074 — 작업 중인 값을 런타임이 소유한다 + +2026-09-25. [073](073-single-file-persistent-tree.md)의 선택형 트리 저장소 위에 런타임 API +[`state.h`](../../prototypes/connections/state.h)를 추가했다. 기본 빌드와 기본 저장 형식은 바뀌지 않았다. + +Pure는 JVM과 같은 위치의 런타임이다. 앱 기능은 별도 저장소에서 만들고, 이 저장소의 CLI·TUI· +HTTP 호스트는 런타임을 검사하는 호스트다. 따라서 이번 단계는 편집기 기능이 아니라 모든 앱이 쓰는 +런타임 능력으로 만들었다. + +착수 기준: + +- 바꿀 결정: 앱이 작업 중인 값을 자기 메모리에 복사해 두고 저장할 때 저장본과 비교하는 관례를 + 유지할지, 런타임이 작업 버전(draft)을 소유하고 앱은 연산만 보낼지. +- 남은 질문: 초안이 저장본과 익스텐트를 공유하면 메모리가 실제로 줄어드는가? 긴 편집 세션에서 + 경로 복사로 생긴 쓰레기가 쌓이지 않는가? 충돌·고장·압축에서도 계약이 유지되는가? +- 최소 확인: 같은 저장소에서 "앱 복사 + 비교" 방식과 draft 방식을 같은 편집으로 비교하고, + 무작위 오라클·충돌·고장·파일 교체 검사를 통과시킨다. +- 중단 기준: 메모리나 저장 시간이 줄지 않거나, 실패한 커밋이 draft나 저장본을 바꾸면 닫는다. + +## 의심한 관례 + +| 관례 | 바꾼 것 | +|---|---| +| 앱이 값의 사본(버퍼·모델)을 가진다 | 런타임이 작업 버전을 소유한다. 바뀌지 않은 부분은 저장본의 불변 익스텐트를 그대로 가리킨다 | +| 저장할 때 사본과 저장본을 비교해 바뀐 곳을 찾는다 | 편집 연산이 곧 변경이다. 커밋은 draft가 새로 만든 노드만 쓴다 | +| 읽기 = 연속 버퍼로 복사 | 읽기 = 순서대로 빌려주는 익스텐트 열(무복사) | +| "저장 안 된 편집"은 앱의 상태 | 커밋되지 않은 루트는 런타임 상태이며 커밋 전까지 파일에 쓰지 않는다 | + +## API + +- `pure_draft_open(path, key)`: 커밋된 버전에서 시작한다. 키가 없으면 revision 0의 빈 draft다. +- `pure_draft_splice(offset, remove, insert)`: 메모리에서만 바뀐 leaf와 경로를 복사한다. +- `pure_draft_read(offset, length, visit)`: 저장본 매핑 또는 draft 메모리의 익스텐트를 복사 없이 순서대로 빌려준다. +- `pure_draft_commit()`: 키가 draft의 기준 revision이면 새 노드만 추가하고 공개한다. 성공하면 + draft는 새 revision에서 이어가고, 실패하면 draft와 저장본 모두 그대로다. + +커밋 쓰기는 draft의 노드 메모리를 바꾸지 않는다. 그래서 동기화 전에 실패해도 draft를 다시 커밋할 수 있다. +다른 키의 쓰기로 압축이 일어나 파일이 교체되면 draft의 저장 참조는 옛 파일을 가리킨다. 이때 커밋은 +draft가 가진 옛 매핑에서 필요한 노드를 새 파일로 복사한다(한 번 전체 쓰기). + +편집할 때마다 경로를 새로 만들므로 도달할 수 없는 노드가 생긴다. 이 쓰레기가 +`max(도달 가능한 바이트, 32 KiB)`를 넘으면 표시·회수한다. + +## 측정 + +같은 Raspberry Pi 5, ext4 `/dev/mmcblk0p2`, 저장소는 둘 다 `store-tree.c`다. 1 MiB 문서 가운데에 +한 글자를 입력하고 저장하기를 24회 반복했고, 방식마다 3회 실행했다. 문서 생성은 별도 +프로세스에서 하여 편집 세션의 피크만 쟀다(Linux `VmHWM`). +[측정 코드](../../research/experiments/009-runtime-drafts/bench.c), +[원시 결과](../../research/experiments/009-runtime-drafts/2026-09-25-ext4-linux-aarch64.json). + +| 1 MiB 문서 | 앱 사본 + 비교 | 런타임 draft | +|---|---:|---:| +| 저장 중앙값 (실행별) | 10.08 · 10.53 · 13.57 ms | 9.25 · 8.64 · 9.13 ms | +| 편집 세션 피크 RSS | 4,464–4,496 KiB | **1,648–1,664 KiB** | +| 글자 하나 반영 | 0.04–0.06 µs (gap buffer) | 3.07–3.59 µs | + +주된 이득은 메모리다. draft는 문서 전체를 앱 메모리에 올리지 않고, 저장할 때 1 MiB를 다시 +복사하지도 않는다. 저장 시간은 약 1–2 ms 줄었을 뿐이다. 남은 대부분은 두 번의 `fsync`다. +글자 하나 반영은 gap buffer보다 느리지만(leaf 하나와 경로 복사), 수 µs라 입력 한 번의 비용으로는 작다. + +## 검증 + +[`test-tree-store.c`](../../prototypes/connections/test-tree-store.c) 추가 검사는 ASan·UBSan을 포함해 통과했다. + +- 20,000회 무작위 편집을 draft에서 평면 오라클과 비교하고 1,000회마다 커밋했다(최종 419,324 B, 커밋 20회). + 커밋 뒤에는 저장본 일치, revision 증가, 빈 arena를 확인했다. +- 1 MiB 문서 가운데에서 30,000번 입력·삭제(순증 10,000 B): arena 피크 47,339 B. + 커밋은 14,952 B만 추가했다. +- 두 draft가 같은 키를 수정하면 두 번째 커밋은 충돌을 내고 자기 편집을 유지한다. +- 다른 키의 쓰기로 파일이 압축·교체된 뒤에도 draft 커밋이 올바른 내용을 공개한다. +- 다섯 고장 단계: 동기화 전 실패는 저장본·파일 크기·draft를 바꾸지 않는다. 결과 불명 뒤의 재커밋은 + 충돌로 드러나고, 다시 열면 공개된 내용을 본다. +- 범위 밖 편집·읽기는 draft를 바꾸지 않는다. 읽기는 방문자가 멈추면 중단된다. + +`make check-tree` 전체(기존 런타임·부분 수정·비동기·실제 PTY 검사 포함)도 다시 통과했다. + +## 비용과 한계 + +- `store-tree.c` 오브젝트 `text`가 15,899 B에서 19,321 B로 늘었다. draft를 쓰지 않는 트리 CLI도 같은 + 번역 단위라 함께 링크된다(34,913 B). 분리하거나 줄여야 한다. +- 아직 런타임 코어(`runtime.c`)의 작업·예산·완료 모델이나 `.pure` 정의에서 draft를 다룰 수 없다. + 지금은 호스트 수준 C API다. draft 메모리는 런타임 작업 예산에 계상되지 않는다. +- 기존 TUI는 그대로 자기 gap buffer와 비교 방식을 쓴다. 앱 쪽 전환은 앱 저장소의 일이다. +- 커밋은 기준 revision이 다르면 충돌로 끝난다. 두 draft의 연산을 자동으로 합치지 않는다. +- draft는 기준 파일의 매핑을 끝까지 유지한다. 압축으로 교체된 옛 파일의 공간은 draft를 닫을 때까지 + 회수되지 않는다. 최신 head가 전원 차단 등으로 손상돼 이전 커밋으로 되돌아가면, 그 뒤를 매핑한 draft의 + 읽기는 잘린 파일 영역에 닿을 수 있다(기존 저장소의 외부 절단과 같은 신뢰 경계). +- 한 draft는 한 스레드가 소유한다. 물리 전원 차단, macOS, x86_64는 검증하지 않았다. + +## 다음 결정 + +1. draft를 런타임 코어의 자원으로 만든다. 작업 예산에 계상하고, `.pure` 정의가 draft 연산과 + 커밋을 효과로 선언하게 한다. 앱은 C API가 아니라 런타임 계약으로 작업 버전을 다룬다. +2. 호스트에 익스텐트 열을 그대로 넘긴다(HTTP `writev`, 피어 전송). 연속 복사는 호환 경계에만 남긴다. +3. 같은 키의 동시 draft에서 서로 겹치지 않는 편집을 합칠지(연산 재기준화) 판단한다. +4. 코드 크기를 줄이고 draft를 쓰지 않는 호스트에서 빠지게 한다. diff --git a/prototypes/connections/Makefile b/prototypes/connections/Makefile index 661fbcd..a416bee 100644 --- a/prototypes/connections/Makefile +++ b/prototypes/connections/Makefile @@ -11,7 +11,7 @@ HOST = $(STORE) store-port.c evaluate.c # Document browsers list keys through the store. The tree store keeps its own # catalog; per-object file stores share a directory listing. KEYS = $(if $(filter store-tree.c,$(STORE)),,store-keys.c) -HEADERS = runtime.h program.h store.h store-port.h flow.h result.h +HEADERS = runtime.h program.h store.h store-port.h state.h flow.h result.h all: core fixed-editor core: $(BUILD)/libpure-execution.a async-core: $(BUILD)/libpure-execution-async.a diff --git a/prototypes/connections/README.md b/prototypes/connections/README.md index f0b041e..94d05f0 100644 --- a/prototypes/connections/README.md +++ b/prototypes/connections/README.md @@ -148,6 +148,8 @@ python3 prototypes/connections/export_flat_store.py \ 트리로 둔다. 편집은 바뀐 leaf와 경로만 기록하고, 읽기는 잠그지 않는다. 기존 런타임·부분 수정· 비동기·실제 PTY 검사와 자체 고장·오라클 검사를 함께 실행한다. 형식이 달라 별도 `--store` 디렉터리를 쓴다. [설계·측정·한계](../../docs/research/073-single-file-persistent-tree.md). +같은 저장소에서 런타임이 앱의 작업 버전을 소유하는 draft API는 [`state.h`](state.h)이며, +설계와 측정은 [074](../../docs/research/074-runtime-owned-drafts.md)에 있다. ```sh make -C prototypes/connections check-tree diff --git a/prototypes/connections/state.h b/prototypes/connections/state.h new file mode 100644 index 0000000..d1bc0fa --- /dev/null +++ b/prototypes/connections/state.h @@ -0,0 +1,31 @@ +#ifndef PURE_STATE_H +#define PURE_STATE_H +#include "flow.h" +#include "result.h" +#include + +/* Runtime-owned working versions of stored values (selected tree store). + * A draft starts at the committed version of a key and changes only in + * memory: an edit copies the leaf and path it touches and shares every other + * extent with the stored version. An app therefore keeps no copy of the value + * and never compares a copy with storage to find what it changed. Commit + * appends only the draft's new extents. One owner thread per draft. */ +typedef struct PureDraft PureDraft; + +/* A missing key opens as an empty draft at revision 0; commit creates it. */ +PureResult pure_draft_open(const char *path, const char *key, PureDraft **out); +/* Memory only. Fails without change on a bad range or when memory runs out. */ +PureResult pure_draft_splice(PureDraft *, size_t offset, size_t remove, PureSpan insert); +size_t pure_draft_size(const PureDraft *); +/* The committed revision this draft is based on; 0 if the key was absent. */ +int64_t pure_draft_revision(const PureDraft *); +/* Visit [offset, offset + length) in order as borrowed extents, without + * copying. Returning false stops early. Spans stay valid until the next + * splice, commit or close of this draft. */ +PureResult pure_draft_read(const PureDraft *, size_t offset, size_t length, + bool (*visit)(void *, PureSpan), void *context); +/* Publish if the key is still at the draft's base revision. On success the + * draft continues from the new revision; on any failure it is unchanged. */ +PureResult pure_draft_commit(PureDraft *, int64_t *revision); +void pure_draft_close(PureDraft *); +#endif diff --git a/prototypes/connections/store-tree.c b/prototypes/connections/store-tree.c index 618028a..1c0ad91 100644 --- a/prototypes/connections/store-tree.c +++ b/prototypes/connections/store-tree.c @@ -10,6 +10,7 @@ #define _DARWIN_C_SOURCE #endif #include "store.h" +#include "state.h" #include #include #include @@ -20,6 +21,7 @@ #include #include +enum { FREE = 0xffff }; enum { SLOT = 64, DATA = 2 * SLOT, LEAF_MAX = 4096, LEAF_MIN = 1024, FAN_MAX = 32, FAN_MIN = 8, RECORD = 96, KEY = 64, HEIGHT_MAX = 12, BUFFER = 65536 }; @@ -43,14 +45,15 @@ typedef struct { Entry *items; size_t count, capacity; } List; typedef struct { unsigned char *bytes; /* leaf only */ Entry *children; /* branch only */ - size_t size; /* leaf bytes or child count */ - unsigned height; + size_t size; /* leaf bytes, child count, or next free slot + 1 */ + unsigned height; /* FREE for a reclaimed slot */ } Node; typedef struct { const unsigned char *map; uint64_t end; Node *nodes; - size_t count, capacity; + size_t count, capacity, free_head, free_count; /* free_head is slot + 1 */ + size_t bytes; /* payload held by arena nodes, including unreachable ones */ } Tree; typedef struct { uint64_t seq, end, catalog, catalog_bytes, live; } Head; typedef struct { @@ -309,11 +312,23 @@ static bool push(List *list, Entry entry) { return true; } +static size_t node_bytes(const Node *node) { + return node->bytes ? node->size : node->size * sizeof(Entry); +} + static bool add_node(Tree *tree, Node node, uint64_t *index) { + tree->bytes += node_bytes(&node); + if (tree->free_head) { + *index = tree->free_head - 1; + tree->free_head = tree->nodes[*index].size; + --tree->free_count; + tree->nodes[*index] = node; + return true; + } if (tree->count == tree->capacity) { size_t capacity = tree->capacity ? 2 * tree->capacity : 64; Node *nodes = realloc(tree->nodes, capacity * sizeof(*nodes)); - if (!nodes) return false; + if (!nodes) { tree->bytes -= node_bytes(&node); return false; } tree->nodes = nodes; tree->capacity = capacity; } @@ -537,8 +552,9 @@ static uint64_t put_branch(Writer *writer, const Entry *kids, size_t count, unsi } /* Append a subtree's arena nodes, children first, and set entry to the - * stored reference without changing the arena. With relocate, stored nodes - * are copied too; compaction uses this to rewrite reachable nodes. */ + * stored reference. The arena is not changed, so a failed commit leaves a + * draft intact. With relocate, stored nodes are copied too (compaction, or a + * draft whose base file was replaced by compaction). */ static bool write_subtree(const Tree *tree, Writer *writer, Entry *entry, unsigned height, bool relocate) { if (!entry->bytes || (!(entry->ref & MEMORY_BIT) && !relocate)) return true; @@ -918,6 +934,160 @@ PureResult pure_store_keys(const char *path, bool (*visit)(void *, const char *k return result; } +struct PureDraft { + char *path; + char key[KEY + 1]; + Tree tree; /* the base file's mapping plus this draft's node arena */ + dev_t device; /* identity of the base file; compaction replaces it */ + ino_t inode; + Entry root; + int64_t revision; + size_t live; /* arena bytes reachable at the last collection */ +}; + +/* Rebase the draft on the committed version of its key. */ +static PureResult load_draft(PureDraft *draft) { + int directory = open(draft->path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (directory < 0) return PURE_STORE_ERROR; + int fd = openat(directory, tree_name, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + close(directory); + if (fd < 0) return PURE_STORE_ERROR; + Tree tree = {0}; + Head head; + struct stat info; + Entry document; + uint64_t index; + int64_t revision; + bool found; + bool ok = !fstat(fd, &info) && read_head(fd, &head) && map_tree(fd, &head, &tree) && + find(&tree, (Entry){head.catalog, head.catalog_bytes}, draft->key, &index, &found, + &document, &revision); + close(fd); + if (!ok) { release_tree(&tree); return PURE_STORE_ERROR; } + release_tree(&draft->tree); + draft->tree = tree; + draft->device = info.st_dev; + draft->inode = info.st_ino; + draft->root = document; + draft->revision = revision; + draft->live = 0; + return PURE_OK; +} + +PureResult pure_draft_open(const char *path, const char *key, PureDraft **out) { + *out = NULL; + if (!path || !valid_key(key)) return PURE_INVALID; + PureDraft *draft = calloc(1, sizeof(*draft)); + if (!draft || !(draft->path = strdup(path))) { free(draft); return PURE_ALLOCATION; } + memcpy(draft->key, key, strlen(key) + 1); + PureResult result = load_draft(draft); + if (result != PURE_OK) { pure_draft_close(draft); return result; } + *out = draft; + return PURE_OK; +} + +void pure_draft_close(PureDraft *draft) { + if (!draft) return; + release_tree(&draft->tree); + free(draft->path); + free(draft); +} + +size_t pure_draft_size(const PureDraft *draft) { return (size_t)draft->root.bytes; } + +int64_t pure_draft_revision(const PureDraft *draft) { return draft->revision; } + +static size_t mark(const Tree *tree, Entry entry, unsigned char *marks) { + if (!(entry.ref & MEMORY_BIT)) return 0; + size_t at = (size_t)(entry.ref & LOCATION); + if (marks[at]) return 0; + marks[at] = 1; + const Node *node = &tree->nodes[at]; + size_t bytes = node_bytes(node); + if (!node->bytes) + for (size_t i = 0; i < node->size; ++i) bytes += mark(tree, node->children[i], marks); + return bytes; +} + +/* Every edit copies a leaf and path, so reclaim arena nodes the root no + * longer reaches once they outweigh the reachable ones (and 32 KiB). */ +static void collect(PureDraft *draft) { + Tree *tree = &draft->tree; + size_t allowance = draft->live > 32768 ? draft->live : 32768; + if (tree->bytes <= draft->live || tree->bytes - draft->live <= allowance) return; + unsigned char *marks = calloc(tree->count, 1); + if (!marks) return; + draft->live = mark(tree, draft->root, marks); + for (size_t i = 0; i < tree->count; ++i) { + Node *node = &tree->nodes[i]; + if (marks[i] || node->height == FREE) continue; + tree->bytes -= node_bytes(node); + free(node->bytes); + free(node->children); + *node = (Node){.size = tree->free_head, .height = FREE}; + tree->free_head = i + 1; + ++tree->free_count; + } + free(marks); +} + +PureResult pure_draft_splice(PureDraft *draft, size_t offset, size_t remove, PureSpan insert) { + uint64_t size = draft->root.bytes; + if (insert.size && !insert.data) return PURE_INVALID; + if (offset > size || remove > size - offset || insert.size > PURE_OBJECT_LIMIT - (size - remove)) + return PURE_LIMIT; + if (!remove && !insert.size) return PURE_OK; + Entry next; + bool ok = rebuild(&draft->tree, draft->root, offset, remove, insert, &next); + if (ok) draft->root = next; + collect(draft); + return ok ? PURE_OK : PURE_STORE_ERROR; +} + +PureResult pure_draft_read(const PureDraft *draft, size_t offset, size_t length, + bool (*visit)(void *, PureSpan), void *context) { + unsigned height; + if (offset > draft->root.bytes || length > draft->root.bytes - offset) return PURE_LIMIT; + if (!length) return PURE_OK; + if (!root_height(&draft->tree, draft->root, &height)) return PURE_STORE_ERROR; + return visit_range(&draft->tree, draft->root, height, offset, length, visit, context) ? + PURE_OK : PURE_STORE_ERROR; +} + +PureResult pure_draft_commit(PureDraft *draft, int64_t *revision) { + *revision = draft->revision; + Session session; + Writer *writer = NULL; + Entry current, root = draft->root; + uint64_t index; + int64_t stored; + bool found; + PureResult result = open_session(draft->path, &session); + if (result != PURE_OK) goto finish; + result = PURE_STORE_ERROR; + if (!find(&session.tree, (Entry){session.head.catalog, session.head.catalog_bytes}, draft->key, + &index, &found, ¤t, &stored)) goto finish; + if (stored != draft->revision) { *revision = stored; result = PURE_CONFLICT; goto finish; } + if (stored == INT64_MAX) { result = PURE_LIMIT; goto finish; } + /* After compaction the draft's stored references name the old file. */ + bool relocate = session.file.st_dev != draft->device || session.file.st_ino != draft->inode; + writer = malloc(sizeof(*writer)); + if (!writer) goto finish; + *writer = (Writer){.fd = session.fd, .at = session.head.end}; + if (!write_root(&draft->tree, writer, &root, relocate)) { + if (ftruncate(session.fd, (off_t)session.head.end) == 0) sync_fd(session.fd); + goto finish; + } + result = publish(&session, writer, draft->key, index, found, root, revision); + /* Rebase while still holding the writer lock; the arena is no longer needed. + * If remapping fails the draft keeps equal content at the new revision. */ + if (result == PURE_OK && load_draft(draft) != PURE_OK) draft->revision = *revision; +finish: + free(writer); + close_session(&session); + return result; +} + PureResult pure_store_put(const char *path, const char *key, int64_t expected, PureSpan body, int64_t *revision) { return mutate(path, key, expected, body, false, 0, 0, PURE_OBJECT_LIMIT, revision); @@ -946,6 +1116,17 @@ static bool shape(const Tree *tree, Entry entry, unsigned height, bool root, return true; } +/* Test-only: live arena nodes and their payload bytes. */ +void pure_draft_test_arena(const PureDraft *draft, size_t *nodes, size_t *bytes) { + *nodes = *bytes = 0; + for (size_t i = 0; i < draft->tree.count; ++i) { + const Node *node = &draft->tree.nodes[i]; + if (node->height == FREE) continue; + ++*nodes; + *bytes += node->bytes ? node->size : node->size * sizeof(Entry); + } +} + /* Test-only: the stored height, leaf count and number of underfull nodes. */ bool pure_tree_test_shape(const char *path, const char *key, unsigned *height, uint64_t *leaves, uint64_t *small, uint64_t *file_end) { diff --git a/prototypes/connections/test-tree-store.c b/prototypes/connections/test-tree-store.c index 3a1e8f0..8cf1f1e 100644 --- a/prototypes/connections/test-tree-store.c +++ b/prototypes/connections/test-tree-store.c @@ -1,5 +1,6 @@ #define _POSIX_C_SOURCE 200809L #include "store.h" +#include "state.h" #include #include #include @@ -22,6 +23,8 @@ int pure_store_test_fault(PureStoreStage stage) { bool pure_tree_test_shape(const char *path, const char *key, unsigned *height, uint64_t *leaves, uint64_t *small, uint64_t *file_end); +void pure_draft_test_arena(const PureDraft *draft, size_t *nodes, size_t *bytes); + enum { KEYS = 4, CAPACITY = 1 << 20 }; typedef struct { unsigned char *bytes; size_t size; int64_t revision; } Oracle; @@ -366,6 +369,215 @@ static void keys(const char *path) { CHECK(pure_store_keys(path, count_key, &count) == PURE_OK && count == 5); } +typedef struct { unsigned char *out; size_t spans, largest, limit; } Gather; + +static bool gather(void *context, PureSpan span) { + Gather *g = context; + memcpy(g->out, span.data, span.size); + g->out += span.size; + if (span.size > g->largest) g->largest = span.size; + return ++g->spans != g->limit; +} + +static void expect_draft(const PureDraft *draft, const Oracle *o, unsigned char *scratch) { + Gather g = {scratch, 0, 0, 0}; + CHECK(pure_draft_size(draft) == o->size && pure_draft_revision(draft) == o->revision); + CHECK(pure_draft_read(draft, 0, o->size, gather, &g) == PURE_OK); + CHECK((size_t)(g.out - scratch) == o->size && (!o->size || !memcmp(scratch, o->bytes, o->size))); + CHECK(g.largest <= 4096); +} + +static void apply(Oracle *o, size_t offset, size_t remove, const unsigned char *insert, size_t count) { + memmove(o->bytes + offset + count, o->bytes + offset + remove, o->size - offset - remove); + if (count) memcpy(o->bytes + offset, insert, count); + o->size = o->size - remove + count; +} + +/* Edits live only in the draft until commit; each commit continues from the + * published version with an empty arena. */ +static void drafts(const char *path) { + Oracle o = {malloc(CAPACITY), 0, 0}; + unsigned char *scratch = malloc(CAPACITY), *insert = malloc(65536); + CHECK(o.bytes && scratch && insert); + PureDraft *draft; + PureStoredView view; + CHECK(pure_draft_open(path, "draft", &draft) == PURE_OK); + expect_draft(draft, &o, scratch); + uint32_t state = 0x1234567u; + for (unsigned step = 0; step < 20000; ++step) { + uint32_t kind = random_value(&state) % 100; + size_t count = kind < 80 ? random_value(&state) % 8 : + kind < 98 ? random_value(&state) % 4000 : random_value(&state) % 65536; + size_t offset = o.size ? random_value(&state) % (o.size + 1) : 0; + size_t remove = o.size - offset ? random_value(&state) % (o.size - offset + 1) : 0; + if (random_value(&state) % 64) remove %= 16; + if (o.size - remove + count > CAPACITY) count = 0; + fill(insert, count, &state); + CHECK(pure_draft_splice(draft, offset, remove, (PureSpan){insert, count}) == PURE_OK); + apply(&o, offset, remove, insert, count); + if (step % 97 == 0) expect_draft(draft, &o, scratch); + if (step % 1000 == 999) { + if (o.revision) CHECK(pure_store_get(path, "draft", PURE_OBJECT_LIMIT, &view) == PURE_OK && + view.revision == o.revision); + if (o.revision) pure_store_release(&view); + int64_t revision; + CHECK(pure_draft_commit(draft, &revision) == PURE_OK && revision == o.revision + 1); + o.revision = revision; + expect(path, "draft", &o); + expect_draft(draft, &o, scratch); + size_t nodes, bytes; + pure_draft_test_arena(draft, &nodes, &bytes); + CHECK(nodes == 0); + } + } + CHECK(o.size > 100000); + Gather g = {scratch, 0, 0, 3}; + CHECK(pure_draft_read(draft, 0, o.size, gather, &g) == PURE_OK && g.spans == 3); + CHECK(pure_draft_splice(draft, o.size + 1, 0, (PureSpan){0}) == PURE_LIMIT); + CHECK(pure_draft_splice(draft, 0, o.size + 1, (PureSpan){0}) == PURE_LIMIT); + CHECK(pure_draft_read(draft, o.size, 1, gather, &g) == PURE_LIMIT); + expect_draft(draft, &o, scratch); + pure_draft_close(draft); + printf("{\"draft_steps\":20000,\"draft_commits\":%lld,\"draft_bytes\":%zu}\n", + (long long)o.revision, o.size); + free(o.bytes); + free(scratch); + free(insert); +} + +/* Long typing and deleting at one place keeps only the live path in memory, + * and its commit appends the typed bytes and path, not the document. */ +static void typing(const char *path) { + size_t size = 1048576; + Oracle o = {malloc(size + 40000), size, 0}; + unsigned char *scratch = malloc(size + 40000); + uint32_t state = 5; + CHECK(o.bytes && scratch); + fill(o.bytes, size, &state); + CHECK(pure_store_put(path, "typed", 0, (PureSpan){o.bytes, size}, &o.revision) == PURE_OK); + PureDraft *draft; + CHECK(pure_draft_open(path, "typed", &draft) == PURE_OK); + size_t cursor = size / 2, nodes, bytes, peak = 0; + for (unsigned i = 0; i < 30000; ++i) { + unsigned char byte = (unsigned char)('a' + i % 26); + if (i % 3 == 2) { + CHECK(pure_draft_splice(draft, cursor - 1, 1, (PureSpan){0}) == PURE_OK); + apply(&o, --cursor, 1, NULL, 0); + } else { + CHECK(pure_draft_splice(draft, cursor, 0, (PureSpan){&byte, 1}) == PURE_OK); + apply(&o, cursor++, 0, &byte, 1); + } + pure_draft_test_arena(draft, &nodes, &bytes); + if (bytes > peak) peak = bytes; + } + expect_draft(draft, &o, scratch); + /* Reachable: 10,000 net typed bytes, the leaves they landed in and one + * path. Unreachable copies are reclaimed past max(reachable, 32 KiB). */ + CHECK(peak < 2 * (10000 + 4 * 4096 + 2048) + 32768 + 8192); + uint64_t before = file_size(path); + int64_t revision; + CHECK(pure_draft_commit(draft, &revision) == PURE_OK); + o.revision = revision; + CHECK(file_size(path) - before < 10000 + 16384); + expect(path, "typed", &o); + pure_draft_close(draft); + printf("{\"typing_keystrokes\":30000,\"peak_arena_bytes\":%zu,\"commit_appended_bytes\":%llu}\n", + peak, (unsigned long long)(file_size(path) - before)); + free(o.bytes); + free(scratch); +} + +/* Two drafts of one key: the second commit conflicts and keeps its edits. */ +static void draft_conflict(const char *path) { + unsigned char scratch[64]; + int64_t revision; + CHECK(pure_store_put(path, "shared", 0, (PureSpan){(const unsigned char *)"base", 4}, &revision) == PURE_OK); + PureDraft *first, *second; + CHECK(pure_draft_open(path, "shared", &first) == PURE_OK && pure_draft_open(path, "shared", &second) == PURE_OK); + CHECK(pure_draft_splice(first, 4, 0, (PureSpan){(const unsigned char *)"-one", 4}) == PURE_OK); + CHECK(pure_draft_splice(second, 0, 0, (PureSpan){(const unsigned char *)"two-", 4}) == PURE_OK); + CHECK(pure_draft_commit(first, &revision) == PURE_OK && revision == 2); + CHECK(pure_draft_commit(second, &revision) == PURE_CONFLICT && revision == 2); + Oracle kept = {(unsigned char *)"two-base", 8, 1}; + expect_draft(second, &kept, scratch); + Oracle stored = {(unsigned char *)"base-one", 8, 2}; + expect(path, "shared", &stored); + pure_draft_close(second); + CHECK(pure_draft_open(path, "shared", &second) == PURE_OK); + expect_draft(second, &stored, scratch); + CHECK(pure_store_put(path, "shared", 2, (PureSpan){(const unsigned char *)"x", 1}, &revision) == PURE_OK); + CHECK(pure_draft_commit(first, &revision) == PURE_CONFLICT && revision == 3); + pure_draft_close(first); + pure_draft_close(second); +} + +/* Another key's writes compact the file under an open draft; the draft's + * stored extents are copied from its old mapping on commit. */ +static void draft_relocation(const char *path) { + unsigned char *big = malloc(300000), scratch[20000]; + uint32_t state = 11; + CHECK(big); + fill(big, 300000, &state); + Oracle o = {malloc(20000), 12000, 0}; + CHECK(o.bytes); + fill(o.bytes, o.size, &state); + CHECK(pure_store_put(path, "kept", 0, (PureSpan){o.bytes, o.size}, &o.revision) == PURE_OK); + PureDraft *draft; + CHECK(pure_draft_open(path, "kept", &draft) == PURE_OK); + CHECK(pure_draft_splice(draft, 6000, 10, (PureSpan){(const unsigned char *)"relocated", 9}) == PURE_OK); + apply(&o, 6000, 10, (const unsigned char *)"relocated", 9); + char name[512]; + snprintf(name, sizeof(name), "%s/pure.tree", path); + struct stat before, after; + CHECK(stat(name, &before) == 0); + int64_t revision = 0; + for (unsigned i = 0; i < 8; ++i) + CHECK(pure_store_put(path, "other", revision, (PureSpan){big, 300000 - i}, &revision) == PURE_OK); + CHECK(stat(name, &after) == 0 && after.st_ino != before.st_ino); + CHECK(pure_draft_commit(draft, &revision) == PURE_OK && revision == o.revision + 1); + o.revision = revision; + expect(path, "kept", &o); + expect_draft(draft, &o, scratch); + Oracle other = {big, 300000 - 7, 8}; + expect(path, "other", &other); + pure_draft_close(draft); + free(o.bytes); + free(big); +} + +/* A failed commit changes neither the store nor the draft. */ +static void draft_faults(const char *path) { + unsigned char scratch[64]; + int64_t revision; + CHECK(pure_store_put(path, "faulty", 0, (PureSpan){(const unsigned char *)"stable", 6}, &revision) == PURE_OK); + PureDraft *draft; + CHECK(pure_draft_open(path, "faulty", &draft) == PURE_OK); + CHECK(pure_draft_splice(draft, 6, 0, (PureSpan){(const unsigned char *)"+edit", 5}) == PURE_OK); + Oracle stored = {(unsigned char *)"stable", 6, 1}, edited = {(unsigned char *)"stable+edit", 11, 1}; + uint64_t size = file_size(path); + for (fail_stage = PURE_AFTER_HEADER; fail_stage <= PURE_BEFORE_RENAME; ++fail_stage) { + CHECK(pure_draft_commit(draft, &revision) == PURE_STORE_ERROR); + expect(path, "faulty", &stored); + expect_draft(draft, &edited, scratch); + CHECK(file_size(path) == size); + } + fail_stage = PURE_BEFORE_DIR_SYNC; + CHECK(pure_draft_commit(draft, &revision) == PURE_UNCERTAIN && revision == 2); + fail_stage = -1; + expect_draft(draft, &edited, scratch); + CHECK(pure_draft_commit(draft, &revision) == PURE_CONFLICT && revision == 2); + pure_draft_close(draft); + CHECK(pure_draft_open(path, "faulty", &draft) == PURE_OK); + edited.revision = 2; + expect_draft(draft, &edited, scratch); + CHECK(pure_draft_splice(draft, 0, 6, (PureSpan){0}) == PURE_OK); + CHECK(pure_draft_commit(draft, &revision) == PURE_OK && revision == 3); + Oracle final = {(unsigned char *)"+edit", 5, 3}; + expect(path, "faulty", &final); + pure_draft_close(draft); + CHECK(pure_draft_open(path, "bad/key", &draft) == PURE_INVALID && !draft); +} + static void clean(const char *path) { DIR *entries = opendir(path); CHECK(entries); @@ -380,7 +592,8 @@ static void clean(const char *path) { int main(void) { void (*checks[])(const char *) = {oracle, amplification, faults, crashes, torn_head, - isolation, corruption, many_keys, revision_limit, keys}; + isolation, corruption, many_keys, revision_limit, keys, + drafts, typing, draft_conflict, draft_relocation, draft_faults}; for (size_t i = 0; i < sizeof(checks) / sizeof(*checks); ++i) { char path[] = "/tmp/pure-tree-check-XXXXXX"; CHECK(mkdtemp(path) && pure_store_init(path) == PURE_OK); @@ -389,6 +602,7 @@ int main(void) { clean(path); } puts("{\"tree_store\":\"random-oracle-balance-held-views-one-byte-amplification-faults-" - "process-crash-torn-head-lockless-isolation-compaction-corruption-1024-keys-revision-limit-key-listing\"}"); + "process-crash-torn-head-lockless-isolation-compaction-corruption-1024-keys-revision-limit-key-listing-" + "draft-oracle-typing-arena-conflict-relocation-commit-faults\"}"); return 0; } diff --git a/research/experiments/009-runtime-drafts/2026-09-25-ext4-linux-aarch64.json b/research/experiments/009-runtime-drafts/2026-09-25-ext4-linux-aarch64.json new file mode 100644 index 0000000..e85ec38 --- /dev/null +++ b/research/experiments/009-runtime-drafts/2026-09-25-ext4-linux-aarch64.json @@ -0,0 +1,69 @@ +{ + "device": "/dev/mmcblk0p2", + "filesystem": "ext4", + "machine": "aarch64", + "kernel": "6.18.39+rpt-rpi-2712", + "store": "store-tree.c", + "runs": [ + { + "mode": "copy", + "document_bytes": 1048576, + "saves": 24, + "keystroke_median_us": 0.06, + "save_median_ms": 10.083, + "save_p90_ms": 12.536, + "peak_rss_kib": 4496, + "final_revision": 25 + }, + { + "mode": "draft", + "document_bytes": 1048576, + "saves": 24, + "keystroke_median_us": 3.18, + "save_median_ms": 9.245, + "save_p90_ms": 27.849, + "peak_rss_kib": 1664, + "final_revision": 25 + }, + { + "mode": "draft", + "document_bytes": 1048576, + "saves": 24, + "keystroke_median_us": 3.07, + "save_median_ms": 8.643, + "save_p90_ms": 18.492, + "peak_rss_kib": 1648, + "final_revision": 25 + }, + { + "mode": "copy", + "document_bytes": 1048576, + "saves": 24, + "keystroke_median_us": 0.04, + "save_median_ms": 10.528, + "save_p90_ms": 12.78, + "peak_rss_kib": 4480, + "final_revision": 25 + }, + { + "mode": "copy", + "document_bytes": 1048576, + "saves": 24, + "keystroke_median_us": 0.04, + "save_median_ms": 13.571, + "save_p90_ms": 21.87, + "peak_rss_kib": 4464, + "final_revision": 25 + }, + { + "mode": "draft", + "document_bytes": 1048576, + "saves": 24, + "keystroke_median_us": 3.59, + "save_median_ms": 9.128, + "save_p90_ms": 50.847, + "peak_rss_kib": 1664, + "final_revision": 25 + } + ] +} diff --git a/research/experiments/009-runtime-drafts/bench.c b/research/experiments/009-runtime-drafts/bench.c new file mode 100644 index 0000000..e486d68 --- /dev/null +++ b/research/experiments/009-runtime-drafts/bench.c @@ -0,0 +1,120 @@ +/* Where an app's working value lives, measured on one store (store-tree.c). + * + * copy: the app loads its own copy into a gap buffer, types into it, and on + * save reads the stored value back, finds the changed range by + * comparing prefix and suffix, then submits that splice (the pattern + * the current terminal editor uses). + * draft: the runtime owns the working version; typing is a draft splice and + * save is a commit of the draft's new extents. + * + * Each save follows one typed character in the middle of the document. + * "create" makes the stored document in a separate process, so the peak RSS + * of copy and draft covers only the editing session. */ +#define _POSIX_C_SOURCE 200809L +#include "state.h" +#include "store.h" +#include +#include +#include +#include + +static double now_us(void) { + struct timespec t; + clock_gettime(CLOCK_MONOTONIC, &t); + return (double)t.tv_sec * 1e6 + (double)t.tv_nsec / 1e3; +} + +static int compare(const void *a, const void *b) { + double x = *(const double *)a, y = *(const double *)b; + return (x > y) - (x < y); +} + +/* Linux VmHWM belongs to this exec's address space; ru_maxrss would carry + * the launching interpreter's peak across exec. */ +static long peak_kib(void) { + FILE *file = fopen("/proc/self/status", "r"); + char line[256]; + long value = -1; + while (file && fgets(line, sizeof(line), file)) + if (!strncmp(line, "VmHWM:", 6)) value = strtol(line + 6, NULL, 10); + if (file) fclose(file); + return value; +} + +typedef struct { unsigned char *text; size_t capacity, gap_start, gap_end; } Gap; + +static unsigned char at(const Gap *g, size_t i) { + return i < g->gap_start ? g->text[i] : g->text[i + (g->gap_end - g->gap_start)]; +} + +int main(int argc, char **argv) { + if (argc != 5 || (strcmp(argv[1], "copy") && strcmp(argv[1], "draft") && strcmp(argv[1], "create"))) { + fputs("bench create|copy|draft DIRECTORY SIZE SAVES\n", stderr); + return 2; + } + const char *mode = argv[1], *path = argv[2]; + size_t size = strtoull(argv[3], NULL, 10), saves = strtoull(argv[4], NULL, 10); + int64_t revision = 1; + if (!strcmp(mode, "create")) { + unsigned char *body = malloc(size); + if (!body || pure_store_init(path) != PURE_OK) return 1; + for (size_t i = 0; i < size; ++i) body[i] = (unsigned char)('a' + i % 26); + return pure_store_put(path, "doc", 0, (PureSpan){body, size}, &revision) != PURE_OK; + } + double *save = malloc(saves * sizeof(*save)), *key = malloc(saves * sizeof(*key)); + if (!save || !key) return 1; + size_t cursor = size / 2, length = size; + if (!strcmp(mode, "copy")) { + PureStoredView view; + if (pure_store_get(path, "doc", PURE_OBJECT_LIMIT, &view) != PURE_OK) return 1; + Gap g = {malloc(size + 4096), size + 4096, cursor, cursor + 4096}; + if (!g.text) return 1; + memcpy(g.text, view.body.data, cursor); + memcpy(g.text + g.gap_end, view.body.data + cursor, size - cursor); + pure_store_release(&view); + for (size_t i = 0; i < saves; ++i) { + double start = now_us(); + g.text[g.gap_start++] = (unsigned char)('A' + i % 26); + ++length; + key[i] = now_us() - start; + start = now_us(); + if (pure_store_get(path, "doc", PURE_OBJECT_LIMIT, &view) != PURE_OK) return 1; + size_t old = view.body.size, prefix = 0, suffix = 0; + while (prefix < old && prefix < length && view.body.data[prefix] == at(&g, prefix)) ++prefix; + while (suffix < old - prefix && suffix < length - prefix && + view.body.data[old - suffix - 1] == at(&g, length - suffix - 1)) ++suffix; + size_t remove = old - prefix - suffix, insert = length - prefix - suffix; + unsigned char *bytes = malloc(insert ? insert : 1); + for (size_t k = 0; k < insert; ++k) bytes[k] = at(&g, prefix + k); + pure_store_release(&view); + PureResult result = pure_store_splice(path, "doc", revision, prefix, remove, + (PureSpan){bytes, insert}, PURE_OBJECT_LIMIT, &revision); + free(bytes); + save[i] = now_us() - start; + if (result != PURE_OK) return 1; + } + free(g.text); + } else { + PureDraft *draft; + if (pure_draft_open(path, "doc", &draft) != PURE_OK) return 1; + for (size_t i = 0; i < saves; ++i) { + unsigned char byte = (unsigned char)('A' + i % 26); + double start = now_us(); + if (pure_draft_splice(draft, cursor++, 0, (PureSpan){&byte, 1}) != PURE_OK) return 1; + key[i] = now_us() - start; + start = now_us(); + if (pure_draft_commit(draft, &revision) != PURE_OK) return 1; + save[i] = now_us() - start; + } + pure_draft_close(draft); + } + qsort(save, saves, sizeof(*save), compare); + qsort(key, saves, sizeof(*key), compare); + printf("{\"mode\":\"%s\",\"document_bytes\":%zu,\"saves\":%zu,\"keystroke_median_us\":%.2f," + "\"save_median_ms\":%.3f,\"save_p90_ms\":%.3f,\"peak_rss_kib\":%ld,\"final_revision\":%lld}\n", + mode, size, saves, key[saves / 2], save[saves / 2] / 1e3, save[saves * 9 / 10] / 1e3, + peak_kib(), (long long)revision); + free(save); + free(key); + return 0; +} diff --git a/research/experiments/009-runtime-drafts/run.py b/research/experiments/009-runtime-drafts/run.py new file mode 100755 index 0000000..08ed55d --- /dev/null +++ b/research/experiments/009-runtime-drafts/run.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Run bench.c in copy and draft modes on a real device, alternating order.""" +import argparse +import json +import platform +import shutil +import subprocess +from pathlib import Path + +HERE = Path(__file__).resolve().parent +CONNECTIONS = HERE.parents[2] / 'prototypes' / 'connections' + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--work', type=Path, default=CONNECTIONS / 'build' / 'bench-drafts') + parser.add_argument('--size', type=int, default=1048576) + parser.add_argument('--saves', type=int, default=24) + parser.add_argument('--rounds', type=int, default=3) + parser.add_argument('--output', type=Path) + args = parser.parse_args() + shutil.rmtree(args.work, ignore_errors=True) + args.work.mkdir(parents=True) + device = subprocess.check_output(['findmnt', '-n', '-T', str(args.work), '-o', 'SOURCE,FSTYPE'], text=True).split() + assert device[1] != 'tmpfs', 'fsync is free on tmpfs' + binary = args.work / 'bench' + subprocess.run(['cc', '-std=c17', '-O2', '-Wall', '-Wextra', '-Werror', f'-I{CONNECTIONS}', + str(HERE / 'bench.c'), str(CONNECTIONS / 'store-tree.c'), '-o', str(binary)], check=True) + runs = [] + for round_number in range(args.rounds): + for mode in (('copy', 'draft') if round_number % 2 == 0 else ('draft', 'copy')): + directory = args.work / f'{mode}-{round_number}' + subprocess.run([str(binary), 'create', str(directory), str(args.size), '0'], check=True) + line = subprocess.check_output([str(binary), mode, str(directory), str(args.size), str(args.saves)], text=True) + runs.append(json.loads(line)) + print(line.strip(), flush=True) + shutil.rmtree(directory) + report = {'device': device[0], 'filesystem': device[1], 'machine': platform.machine(), + 'kernel': platform.release(), 'store': 'store-tree.c', 'runs': runs} + if args.output: + args.output.write_text(json.dumps(report, indent=2) + '\n') + + +if __name__ == '__main__': + main() -- cgit v1.2.3