aboutsummaryrefslogtreecommitdiffstats
path: root/research/experiments
diff options
context:
space:
mode:
Diffstat (limited to 'research/experiments')
-rw-r--r--research/experiments/012-tail-records/2026-09-26-ext4-linux-aarch64.json184
-rw-r--r--research/experiments/012-tail-records/probe.c71
-rwxr-xr-xresearch/experiments/012-tail-records/run.py69
3 files changed, 324 insertions, 0 deletions
diff --git a/research/experiments/012-tail-records/2026-09-26-ext4-linux-aarch64.json b/research/experiments/012-tail-records/2026-09-26-ext4-linux-aarch64.json
new file mode 100644
index 0000000..457111d
--- /dev/null
+++ b/research/experiments/012-tail-records/2026-09-26-ext4-linux-aarch64.json
@@ -0,0 +1,184 @@
+{
+ "rounds": [
+ {
+ "probe": [
+ {
+ "way": "bytes, head in a slot at the start, fdatasync",
+ "bytes": 2048,
+ "p50_ms": 2.4,
+ "p90_ms": 3.11,
+ "max_ms": 62.79
+ },
+ {
+ "way": "head just before the bytes, fdatasync",
+ "bytes": 2048,
+ "p50_ms": 1.23,
+ "p90_ms": 3.85,
+ "max_ms": 12.27
+ },
+ {
+ "way": "bytes only, fdatasync",
+ "bytes": 2048,
+ "p50_ms": 1.23,
+ "p90_ms": 3.86,
+ "max_ms": 12.2
+ },
+ {
+ "way": "bytes, head in a slot at the start, fdatasync",
+ "bytes": 8192,
+ "p50_ms": 2.41,
+ "p90_ms": 4.51,
+ "max_ms": 24.86
+ },
+ {
+ "way": "head just before the bytes, fdatasync",
+ "bytes": 8192,
+ "p50_ms": 1.57,
+ "p90_ms": 4.36,
+ "max_ms": 56.75
+ },
+ {
+ "way": "bytes only, fdatasync",
+ "bytes": 8192,
+ "p50_ms": 1.18,
+ "p90_ms": 1.58,
+ "max_ms": 1.7
+ }
+ ],
+ "commits": {
+ "heads in slots": {
+ "commits_per_second": 220,
+ "p50_ms": 2.48,
+ "p90_ms": 5.99,
+ "p99_ms": 59.68
+ },
+ "heads as records": {
+ "commits_per_second": 371,
+ "p50_ms": 1.29,
+ "p90_ms": 3.93,
+ "p99_ms": 52.98
+ }
+ }
+ },
+ {
+ "probe": [
+ {
+ "way": "bytes, head in a slot at the start, fdatasync",
+ "bytes": 2048,
+ "p50_ms": 2.44,
+ "p90_ms": 5.4,
+ "max_ms": 13.3
+ },
+ {
+ "way": "head just before the bytes, fdatasync",
+ "bytes": 2048,
+ "p50_ms": 1.19,
+ "p90_ms": 1.76,
+ "max_ms": 12.16
+ },
+ {
+ "way": "bytes only, fdatasync",
+ "bytes": 2048,
+ "p50_ms": 1.2,
+ "p90_ms": 1.56,
+ "max_ms": 15.32
+ },
+ {
+ "way": "bytes, head in a slot at the start, fdatasync",
+ "bytes": 8192,
+ "p50_ms": 2.71,
+ "p90_ms": 8.51,
+ "max_ms": 23.39
+ },
+ {
+ "way": "head just before the bytes, fdatasync",
+ "bytes": 8192,
+ "p50_ms": 1.18,
+ "p90_ms": 1.6,
+ "max_ms": 32.89
+ },
+ {
+ "way": "bytes only, fdatasync",
+ "bytes": 8192,
+ "p50_ms": 1.21,
+ "p90_ms": 3.9,
+ "max_ms": 74.29
+ }
+ ],
+ "commits": {
+ "heads as records": {
+ "commits_per_second": 372,
+ "p50_ms": 1.29,
+ "p90_ms": 4.04,
+ "p99_ms": 30.55
+ },
+ "heads in slots": {
+ "commits_per_second": 229,
+ "p50_ms": 2.52,
+ "p90_ms": 5.54,
+ "p99_ms": 61.86
+ }
+ }
+ },
+ {
+ "probe": [
+ {
+ "way": "bytes, head in a slot at the start, fdatasync",
+ "bytes": 2048,
+ "p50_ms": 2.44,
+ "p90_ms": 3.94,
+ "max_ms": 53.75
+ },
+ {
+ "way": "head just before the bytes, fdatasync",
+ "bytes": 2048,
+ "p50_ms": 1.21,
+ "p90_ms": 1.85,
+ "max_ms": 4.43
+ },
+ {
+ "way": "bytes only, fdatasync",
+ "bytes": 2048,
+ "p50_ms": 1.21,
+ "p90_ms": 1.57,
+ "max_ms": 4.0
+ },
+ {
+ "way": "bytes, head in a slot at the start, fdatasync",
+ "bytes": 8192,
+ "p50_ms": 2.4,
+ "p90_ms": 3.15,
+ "max_ms": 36.75
+ },
+ {
+ "way": "head just before the bytes, fdatasync",
+ "bytes": 8192,
+ "p50_ms": 1.21,
+ "p90_ms": 4.48,
+ "max_ms": 33.15
+ },
+ {
+ "way": "bytes only, fdatasync",
+ "bytes": 8192,
+ "p50_ms": 1.12,
+ "p90_ms": 1.56,
+ "max_ms": 32.79
+ }
+ ],
+ "commits": {
+ "heads in slots": {
+ "commits_per_second": 233,
+ "p50_ms": 2.55,
+ "p90_ms": 6.04,
+ "p99_ms": 34.19
+ },
+ "heads as records": {
+ "commits_per_second": 396,
+ "p50_ms": 1.26,
+ "p90_ms": 3.9,
+ "p99_ms": 28.43
+ }
+ }
+ }
+ ]
+}
diff --git a/research/experiments/012-tail-records/probe.c b/research/experiments/012-tail-records/probe.c
new file mode 100644
index 0000000..83c1ed6
--- /dev/null
+++ b/research/experiments/012-tail-records/probe.c
@@ -0,0 +1,71 @@
+#define _GNU_SOURCE
+/* What a small commit's sync costs by where its head goes, on one device:
+ * a commit of SIZE bytes written over zeros written earlier (no new size or
+ * blocks, so no journal commit), 40 times per way. The ways run round-robin
+ * so device drift reaches all of them alike.
+ * probe DIRECTORY [SIZE] -> one JSON object per way (milliseconds). */
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+static double now_ms(void) {
+ struct timespec t;
+ clock_gettime(CLOCK_MONOTONIC, &t);
+ return t.tv_sec * 1e3 + t.tv_nsec / 1e6;
+}
+static int order(const void *a, const void *b) {
+ double x = *(const double *)a, y = *(const double *)b;
+ return x < y ? -1 : x > y;
+}
+enum { WAYS = 3, ROUNDS = 40, HEAD = 128, DATA = 8192 };
+static const char *names[WAYS] = {
+ "bytes, head in a slot at the start, fdatasync",
+ "head just before the bytes, fdatasync",
+ "bytes only, fdatasync",
+};
+int main(int argc, char **argv) {
+ const char *directory = argc > 1 ? argv[1] : "/var/tmp";
+ size_t size = argc > 2 ? (size_t)atoi(argv[2]) : 8192;
+ char path[WAYS][512];
+ int fd[WAYS];
+ off_t end[WAYS];
+ char *bytes = malloc(size), head[HEAD];
+ if (!bytes) return 1;
+ memset(bytes, 'r', size);
+ for (int w = 0; w < WAYS; ++w) {
+ snprintf(path[w], sizeof(path[w]), "%s/pure-record-probe-%d", directory, w);
+ fd[w] = open(path[w], O_RDWR | O_CREAT | O_TRUNC, 0600);
+ if (fd[w] < 0) return 1;
+ size_t zeros_size = DATA + (size_t)(ROUNDS + 2) * (size + HEAD);
+ char *zeros = calloc(1, zeros_size);
+ if (!zeros || pwrite(fd[w], zeros, zeros_size, 0) != (ssize_t)zeros_size) return 1;
+ free(zeros);
+ fsync(fd[w]);
+ end[w] = DATA;
+ }
+ double t[WAYS][ROUNDS];
+ for (int r = 0; r < ROUNDS; ++r)
+ for (int w = 0; w < WAYS; ++w) {
+ memset(head, 'h' + r % 2, sizeof(head));
+ double start = now_ms();
+ if (w == 1 && pwrite(fd[w], head, HEAD, end[w]) != HEAD) return 1;
+ off_t at = end[w] + (w == 1 ? HEAD : 0);
+ if (pwrite(fd[w], bytes, size, at) != (ssize_t)size) return 1;
+ end[w] = at + (off_t)size;
+ if (w == 0 && pwrite(fd[w], head, HEAD, (r % 2) * 4096) != HEAD) return 1;
+ fdatasync(fd[w]);
+ t[w][r] = now_ms() - start;
+ }
+ for (int w = 0; w < WAYS; ++w) {
+ qsort(t[w], ROUNDS, sizeof(double), order);
+ printf("{\"way\":\"%s\",\"bytes\":%zu,\"p50_ms\":%.2f,\"p90_ms\":%.2f,\"max_ms\":%.2f}\n", names[w], size,
+ t[w][ROUNDS / 2], t[w][ROUNDS * 9 / 10], t[w][ROUNDS - 1]);
+ close(fd[w]);
+ unlink(path[w]);
+ }
+ free(bytes);
+ return 0;
+}
diff --git a/research/experiments/012-tail-records/run.py b/research/experiments/012-tail-records/run.py
new file mode 100755
index 0000000..7fc342e
--- /dev/null
+++ b/research/experiments/012-tail-records/run.py
@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+"""Commit cost by where the head goes (see docs/research/081-tail-records.md).
+
+Runs probe.c (a small commit's sync with its head in a slot at the start, or
+just before its bytes) for 2 KiB and 8 KiB commits, and ../010-one-sync-commit
+/commits.c (tree store commit latency) built against the store of --before
+(heads in slots) and the current one (heads as records before the bytes),
+alternating their order per round so device drift reaches both alike. Prints
+one JSON document.
+"""
+import argparse
+import json
+import shutil
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent
+ROOT = HERE.parents[2]
+PURE = ROOT / 'prototypes' / 'connections'
+COMMITS = HERE.parent / '010-one-sync-commit' / 'commits.c'
+
+
+def build(output, sources):
+ subprocess.run(['cc', '-O2', '-std=c17', f'-I{PURE}', '-o', str(output)] + [str(s) for s in sources] + ['-lpthread'],
+ check=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('--before', default='2ef0d40', help='a revision with heads in slots')
+ parser.add_argument('--rounds', type=int, default=3)
+ parser.add_argument('--commits', type=int, default=400)
+ args = parser.parse_args()
+ with tempfile.TemporaryDirectory() as work:
+ work = Path(work)
+ before = work / 'store-tree-before.c'
+ before.write_text(subprocess.run(['git', '-C', str(ROOT), 'show', f'{args.before}:prototypes/connections/store-tree.c'],
+ capture_output=True, text=True, check=True).stdout)
+ build(work / 'probe', [HERE / 'probe.c'])
+ builds = {'heads in slots': work / 'commits-before', 'heads as records': work / 'commits-after'}
+ build(builds['heads in slots'], [COMMITS, before])
+ build(builds['heads as records'], [COMMITS, PURE / 'store-tree.c'])
+ results = {'rounds': []}
+ for round_ in range(args.rounds):
+ measured = {'probe': [], 'commits': {}}
+ for size in (2048, 8192):
+ probe = subprocess.run([str(work / 'probe'), str(args.directory), str(size)], capture_output=True,
+ text=True, check=True)
+ measured['probe'] += [json.loads(line) for line in probe.stdout.splitlines()]
+ names = list(builds) if round_ % 2 == 0 else list(builds)[::-1]
+ for name in names:
+ store = Path(tempfile.mkdtemp(dir=args.directory, prefix='pure-commit-cost-'))
+ shutil.rmtree(store)
+ try:
+ run = subprocess.run([str(builds[name]), str(store), str(args.commits)], capture_output=True, text=True,
+ check=True)
+ measured['commits'][name] = json.loads(run.stdout)
+ finally:
+ shutil.rmtree(store, ignore_errors=True)
+ results['rounds'].append(measured)
+ print(json.dumps(measured), file=sys.stderr, flush=True)
+ print(json.dumps(results, indent=1))
+
+
+if __name__ == '__main__':
+ sys.exit(main())