diff options
| author | eunhhu <[email protected]> | 2026-09-25 09:34:34 +0100 |
|---|---|---|
| committer | eunhhu <[email protected]> | 2026-09-25 09:34:34 +0100 |
| commit | 65386f68fb53e5a9a20a284b06afd872172c30eb (patch) | |
| tree | b04133aaa602fbf6a864df1d368ae091ab09b600 /research/experiments/009-runtime-drafts | |
| parent | 6a28879bd56354483fe940c0c80d511eccb89983 (diff) | |
| download | pure-65386f68fb53e5a9a20a284b06afd872172c30eb.tar.gz pure-65386f68fb53e5a9a20a284b06afd872172c30eb.zip | |
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.
Diffstat (limited to 'research/experiments/009-runtime-drafts')
3 files changed, 234 insertions, 0 deletions
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 <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> + +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() |
