From def83ef6584bdc504892f97336cea026201b589c Mon Sep 17 00:00:00 2001 From: Marco Bambini Date: Tue, 4 Aug 2026 22:54:30 +0200 Subject: [PATCH 1/3] diskbench: bypass the page cache on Linux, and say so when it cannot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `diskbench` documented itself as measuring cache-bypassed reads (F_NOCACHE / O_DIRECT) and on Linux did neither: `nocache()` had an `#ifdef __APPLE__` body and nothing else in it, and O_DIRECT appeared nowhere in the file. Reported against a Samsung 970 PRO on Gen3 x4 — 44.67 GB/s sequential and 65.72 GB/s random against a 3.94 GB/s link, 11x and 17x the ceiling. With the flag, 3.15 and 3.33 GB/s, saturating at two threads, which is what that drive should do. This is LEARNED §14 in the one place §14 did not reach. The engine's bypass was written blind on 2026-07-28 and fixed there; the tool that exists to characterise the engine's I/O kept measuring RAM. §46's standing rule — "before claiming anything is disk-bound, run diskbench and divide" — therefore returned a fiction on Linux for that whole window. No number in the docs is affected: every diskbench figure in GATES.md, EFFICIENCY.md and LEARNED §44/§46 was measured on macOS, where F_NOCACHE did work. The flag alone is not enough, for the reason bank_open already knows: O_DIRECT is accepted at open and refused at transfer (tmpfs does this, so would a device wanting a bigger block than we align to), so a bare flag turns a refusing filesystem into "short read -1" and a table of zeroes with no cause given. So this follows bank_open: probe with one aligned transfer, fall back to a plain open plus POSIX_FADV_RANDOM, and label every row — a bench that quietly measures something else is worse than one that says it could not. The write is bypassed too, and that is not symmetry for its own sake. Leaving it buffered — on the argument that it models the conversion landing, and that an O_DIRECT read invalidates the range anyway — was measured wrong on macOS: F_NOCACHE stops new pages being cached but does not evict resident ones, so a buffered write leaves the file in the UBC and every read row below reports RAM. 1 GB file, M5 Pro: 8.07 GB/s sequential read with the write bypassed, 26.04 GB/s with it buffered. The original `nocache()` on the write fd was load-bearing. Also: a sub-page record silently rounded to zero and divided by it, and a failed sequential read just ended the loop and shortened the row. Verified on macOS — unchanged against main within noise (7.85 vs 8.01 GB/s sequential, 6.84 vs 6.74 random 1-thread). The Linux body compiles and runs here only against stubs for O_DIRECT and posix_fadvise, which covers both the probe-succeeds and probe-refused paths and confirms the write probe restores the file byte for byte, but not the kernel's actual O_DIRECT behaviour. The 3.15/3.33 GB/s figures above are the reporter's, on hardware this machine does not have. Reported and diagnosed by fab2s in https://github.com/sqliteai/waste/pull/22, with the O_DIRECT fix as written there. Co-Authored-By: fab2s Co-Authored-By: Claude Opus 5 --- tools/diskbench.c | 128 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 111 insertions(+), 17 deletions(-) diff --git a/tools/diskbench.c b/tools/diskbench.c index 7235a2e..a59d823 100644 --- a/tools/diskbench.c +++ b/tools/diskbench.c @@ -9,6 +9,9 @@ * 3. random record reads, cache-bypassed <- the number that sets tok/s * 4. the same with N threads (the engine's async pool) * + * Every row says whether the bypass was actually obtained, because on a + * filesystem that refuses it these are RAM numbers wearing a disk's label. + * * Build: cc -O2 -o diskbench tools/diskbench.c * Usage: ./diskbench /Volumes/WasteDisk/k3/.bench [file_gb] [rec_mb] [threads] */ @@ -16,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -28,26 +32,100 @@ static double now(void) { return t.tv_sec + t.tv_usec / 1e6; } -static void nocache(int fd) { +/* The bypass, one mechanism under three names — the same contract bank_open + * meets in model.c, and for the same reason: without it the kernel serves the + * file back out of RAM and this tool reports the page cache. It did exactly + * that on Linux until now, where O_DIRECT appeared in the comment above and + * nowhere in the code, so a Gen3 x4 drive benched at 44 GB/s sequential + * against a 3.9 GB/s link (issue #22 — the same bug LEARNED §14 fixed in the + * engine, in the one place it was left behind). + * + * O_DIRECT wants offset, length and buffer aligned to the device's logical + * block. All three come free here: buffers are posix_memalign'd to 4096, the + * record size is masked to a 4096 multiple, and offsets are whole records. */ +#define DIO_ALIGN 4096u + +/* Cleared by any open that could not get the bypass, including the reader + * threads'. Every one of them stores the same value and none reads it back, + * so ordering does not matter — it is atomic to keep the race out of the + * abstract machine, not because anything here needs to synchronise. */ +static _Atomic int g_direct = 1; + +static const char *g_path; +static size_t g_rec, g_file; +static int g_reps; + +#if defined(__linux__) && defined(O_DIRECT) +#define DIO_FLAG O_DIRECT +/* O_DIRECT and FILE_FLAG_NO_BUFFERING both accept the open and then fail + * every misaligned transfer, so eligibility is necessary and not sufficient: + * tmpfs takes the flag and refuses the read, and so would a device wanting a + * bigger block than we align to. The engine confirms with a transfer rather + * than with the open; do the same, or a refusing filesystem turns into + * "short read -1" and a table of zeroes with no cause given. */ +static int dio_probe(int fd, int writing) { + void *buf = NULL; + if (posix_memalign(&buf, DIO_ALIGN, DIO_ALIGN)) return 0; + memset(buf, 0, DIO_ALIGN); + const ssize_t got = writing ? pwrite(fd, buf, DIO_ALIGN, 0) + : pread(fd, buf, DIO_ALIGN, 0); + free(buf); + if (writing && got == (ssize_t)DIO_ALIGN) ftruncate(fd, 0); /* undo the probe */ + return got == (ssize_t)DIO_ALIGN; +} +#endif + +/* Open the working file with the page cache out of the way, and fall back + * rather than fail when the filesystem will not have it — a bench that + * quietly measures something else is worse than one that says it could not + * (LEARNED §14). Falling back clears g_direct, and the rows say so. + * + * The write goes through the same door as the reads, and that is not + * cosmetic: F_NOCACHE only stops *new* pages being cached, it does not evict + * what is already resident, so a buffered write leaves the whole file in the + * UBC and every read row below then measures RAM. Measured on an M5 Pro, 1 GB + * file: 8.07 GB/s sequential read with the write bypassed, 26.04 GB/s with it + * buffered. Linux would have survived the same mistake, since an O_DIRECT + * read writes back and invalidates the range first — which is exactly why it + * has to be a rule here and not a judgement call per platform. */ +static int open_path(int writing) { + const int rw = writing ? (O_WRONLY | O_CREAT | O_TRUNC) : O_RDONLY; +#if defined(__linux__) && defined(O_DIRECT) + int dfd = open(g_path, rw | DIO_FLAG, 0644); + if (dfd >= 0) { + if (dio_probe(dfd, writing)) return dfd; + close(dfd); + } + g_direct = 0; +#endif + int fd = open(g_path, rw, 0644); + if (fd < 0) return fd; #ifdef __APPLE__ - fcntl(fd, F_NOCACHE, 1); + /* F_NOCACHE has no alignment contract, so this is the whole bypass on + * macOS and its failure is the difference between the number the row + * claims and the number it prints. */ + if (fcntl(fd, F_NOCACHE, 1) < 0) g_direct = 0; fcntl(fd, F_RDAHEAD, 0); +#elif defined(__linux__) + /* No bypass to be had: at least stop the kernel reading ahead into pages + * nothing will ask for, which is what the engine settles for too. */ + posix_fadvise(fd, 0, 0, POSIX_FADV_RANDOM); #endif + return fd; } -static const char *g_path; -static size_t g_rec, g_file; -static int g_reps; +static const char *bypass_note(void) { + return g_direct ? "cache bypassed" : "PAGE CACHE, not the disk"; +} typedef struct { int id, nthreads; double bytes; } targ; static void *rand_reader(void *p) { targ *a = (targ *)p; - int fd = open(g_path, O_RDONLY); + int fd = open_path(0); if (fd < 0) { perror("open"); return NULL; } - nocache(fd); void *buf = NULL; - if (posix_memalign(&buf, 4096, g_rec)) { close(fd); return NULL; } + if (posix_memalign(&buf, DIO_ALIGN, g_rec)) { close(fd); return NULL; } size_t nrec = g_file / g_rec; unsigned seed = 12345u + a->id * 7919u; double got = 0; @@ -55,7 +133,7 @@ static void *rand_reader(void *p) { seed = seed * 1103515245u + 12345u; off_t off = (off_t)(seed % nrec) * g_rec; ssize_t r = pread(fd, buf, g_rec, off); - if (r != (ssize_t)g_rec) { fprintf(stderr, "short read %zd\n", r); break; } + if (r != (ssize_t)g_rec) { fprintf(stderr, "short read %zd: %s\n", r, strerror(errno)); break; } got += r; } a->bytes = got; @@ -71,15 +149,20 @@ int main(int argc, char **argv) { int maxthreads = argc > 4 ? atoi(argv[4]) : 8; g_file = (size_t)(file_gb * (1u << 30)); g_rec = (size_t)(rec_mb * (1u << 20)) & ~4095UL; + /* A record under one page rounds to zero and divides by it two screens + * further down, as a crash rather than as a usage error. */ + if (!g_rec || g_file < g_rec) { + fprintf(stderr, "record must be >= 4 KiB and file must hold at least one\n"); + return 1; + } printf("file %.1f GB, record %.1f MB, path %s\n", file_gb, rec_mb, g_path); /* 1. sequential write */ - int fd = open(g_path, O_WRONLY | O_CREAT | O_TRUNC, 0644); + int fd = open_path(1); if (fd < 0) { perror("open write"); return 1; } - nocache(fd); void *buf; - if (posix_memalign(&buf, 4096, g_rec)) return 1; + if (posix_memalign(&buf, DIO_ALIGN, g_rec)) return 1; memset(buf, 0xA5, g_rec); double t0 = now(); size_t written = 0; while (written < g_file) { @@ -89,14 +172,16 @@ int main(int argc, char **argv) { } fsync(fd); close(fd); double dt = now() - t0; - printf("seq write : %6.2f GB/s\n", written / dt / (1u << 30)); + printf("seq write : %6.2f GB/s (%s)\n", written / dt / (1u << 30), bypass_note()); /* 2. sequential read, cache-bypassed */ - fd = open(g_path, O_RDONLY); nocache(fd); + fd = open_path(0); + if (fd < 0) { perror("open read"); return 1; } t0 = now(); size_t rd = 0; ssize_t r; while ((r = read(fd, buf, g_rec)) > 0) rd += r; + if (r < 0) perror("read"); /* a failed read otherwise just ends the loop and shortens the row */ dt = now() - t0; close(fd); - printf("seq read : %6.2f GB/s (cache bypassed)\n", rd / dt / (1u << 30)); + printf("seq read : %6.2f GB/s (%s)\n", rd / dt / (1u << 30), bypass_note()); /* 3+4. random record reads, 1..maxthreads */ g_reps = 40; @@ -111,8 +196,17 @@ int main(int argc, char **argv) { for (int i = 0; i < nt; i++) { pthread_join(th[i], NULL); tot += ta[i].bytes; } dt = now() - t0; double gbs = tot / dt / (1u << 30); - printf("rand %2d thr : %6.2f GB/s -> %.2f tok/s at 12.5 GB/token cold\n", - nt, gbs, gbs / 12.5); + printf("rand %2d thr : %6.2f GB/s -> %.2f tok/s at 12.5 GB/token cold (%s)\n", + nt, gbs, gbs / 12.5, bypass_note()); + } + + if (!g_direct) { + fflush(stdout); /* or the diagnostic overtakes the table it is about */ + fprintf(stderr, + "\nthe page cache could not be bypassed on this filesystem, so the rows\n" + "above are partly the kernel serving RAM back and are not the disk. The\n" + "engine falls back the same way and reports it as direct_io=0; move the\n" + "working file to the filesystem the container will live on.\n"); } free(buf); From cd1ab749ba71f09eaf3792818da492e548dc9ec7 Mon Sep 17 00:00:00 2001 From: Marco Bambini Date: Wed, 5 Aug 2026 06:44:27 +0200 Subject: [PATCH 2/3] =?UTF-8?q?LEARNED=20=C2=A749:=20the=20bench=20that=20?= =?UTF-8?q?certified=20the=20disk=20was=20reading=20RAM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §14 fixed the engine's Linux bypass and left the tool that measures it, so §46's rule — before claiming anything is disk-bound, run diskbench and divide — returned a fiction on Linux for that whole window. Records the reporter's before/after against the drive's link ceiling, the F_NOCACHE finding that came out of reviewing the fix (it does not evict resident pages, so a buffered write makes every read row report RAM: 26.04 against 7.9-8.1 GB/s on the same binary), and what is still stub-verified rather than measured. Co-Authored-By: Claude Opus 5 --- docs/LEARNED.md | 78 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/docs/LEARNED.md b/docs/LEARNED.md index eab2ffa..6ecb9a9 100644 --- a/docs/LEARNED.md +++ b/docs/LEARNED.md @@ -2742,3 +2742,81 @@ automatic budget: It stays here and not in `README.md`. Every number there was measured on the commit it ships with, and dual EPYC is a class of machine this repo cannot verify on. + +## 49. The bench that certified the disk was reading RAM (2026-08-05) + +§14 found `O_DIRECT` in a comment and nowhere in the code, and fixed the +engine. It did not look at `tools/diskbench.c`, which carries the same +sentence in its own header — "with the page cache bypassed (F_NOCACHE / +O_DIRECT)" — and had the same hole: `nocache()`'s body was `#ifdef +__APPLE__` with nothing else in it, and all three opens were unqualified. + +Reported by `fab2s` as PR #22, **on hardware this repo does not have** — +Samsung 970 PRO, PCIe Gen3 x4, Ubuntu 26.04, 16 GB file, 3 MB records: + +| | before | after | link ceiling | +|---|---|---|---| +| seq read | 44.67 GB/s | 3.15 GB/s | 3.94 GB/s | +| random, 1 thread | 36.75 | 2.91 | | +| random, saturated | 65.72 | 3.33 | | + +11x and 17x over the link. The tell was there in every run and nobody +divided: a Gen3 x4 drive cannot deliver 65 GB/s whatever the benchmark +says, and after the fix it saturates at two threads and 85% of the +ceiling, which is what that drive should do. + +**What it cost.** §46 ends with a rule — before claiming anything is +disk-bound, run `diskbench` and divide. On Linux that rule returned a +fiction from 2026-07-28 until now. No published number moves: every +`diskbench` figure in `docs/GATES.md`, `docs/EFFICIENCY.md` and §44/§46 +was measured here, on macOS, where `F_NOCACHE` did work. But the rule had +no force on the platform most users are on, and Gate H is exactly the +class of decision — 1.5 TB onto the wrong device — it exists to protect. + +**The general form: the engine's rules bind the tools that measure the +engine.** `bank_open` bounds its bypass, probes it with a real transfer +and reports when it did not get it. `diskbench` asserted one in a header +comment. That is now three instances of one bug class in this repo — +issue #4 (an alignment test that was false for every container that +exists), §14 (the flag that lived only in a comment), and now the tool the +disk-bound claim rests on. + +**`F_NOCACHE` does not evict, and that is not a detail.** Reviewing the +fix, the write looked like it should stay buffered: row 1 stands for the +download and the conversion landing, and those write through the page +cache like everything else. On Linux that holds — a subsequent `O_DIRECT` +read writes back and invalidates the range first, so the leftovers cannot +flatter the read rows (reasoned, not measured; no Linux here). On macOS it +is wrong, and measurably so. `F_NOCACHE` stops *new* pages being cached; it +does not evict resident ones. A buffered write leaves the whole file in +the UBC and every read row below then measures RAM. Same binary, 1 GB +working file, 4 MB records, internal SSD, differing only in whether the +write fd got the bypass: + +| | write bypassed | write buffered | +|---|---|---| +| seq read | 7.9-8.1 GB/s | **26.04 GB/s** | +| random, 1 thread | 6.8-7.0 | **24.34** | + +3.2x and 3.5x of pure fiction, on the row that sets tok/s. The original +`nocache()` on the write fd was load-bearing and looked ornamental. The +bypass covers the whole file's lifetime or it covers nothing. + +**So the fix is not the flag.** `O_DIRECT` is accepted at open and refused +at transfer — tmpfs does this, and so would a device wanting a bigger +block than the tool aligns to — so a bare flag turns a refusing filesystem +into `short read -1` and a table of zeroes with no cause given. It now +does what `bank_open` does: probe with one aligned transfer, fall back to +a plain open plus `POSIX_FADV_RANDOM`, and label every row `(cache +bypassed)` or `(PAGE CACHE, not the disk)` with a trailer explaining it. +A measurement that quietly means something different is worse than one +that is missing — §14 said that about the engine and it is truer of the +tool, because the tool is what the claim rests on. + +**What is verified, and what is not.** macOS is unchanged against `main` +within noise. The Linux body compiles and runs here only against stubs for +`O_DIRECT` and `posix_fadvise` — covering both the probe-succeeds and the +probe-refused paths, and confirming the write probe restores the file byte +for byte — which is the same limitation §14 recorded for the engine, for +the same reason. The three-column table above is the reporter's. **The +platform still has not been measured from here.** From c65e13df4f68f5e003da38ce600cec5c3900308f Mon Sep 17 00:00:00 2001 From: Marco Bambini Date: Wed, 5 Aug 2026 06:52:41 +0200 Subject: [PATCH 3/3] diskbench: the tok/s column takes the model's GB/token, or does not print MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The derived column read `-> %.2f tok/s at 12.5 GB/token cold` with 12.5 in the format string. That is K3's figure, so every run answered for K3 whatever container was being sized — ~8x off on a 48B model at 1.61 GB/token measured, and silent about the assumption, which is the part that makes it a trap rather than an approximation. It is now the fifth positional argument and has no default: without it the column is not printed at all. Defaulting to 12.5 would have kept the trap, and a tool cannot derive bytes-per-token from a scratch file — that number belongs to a container, and `waste bench` already reports it as "disk N GB total, M GB/token". The usage line says so. docs/GATES.md's Gate H table keeps its "tok/s @12.5 GB/token" header: it was a K3 decision, the figure is stated in the header rather than hidden in a format string, and the numbers under it were measured with it. Reported by fab2s alongside the O_DIRECT bug in https://github.com/sqliteai/waste/pull/22. Co-Authored-By: Claude Opus 5 --- tools/diskbench.c | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/tools/diskbench.c b/tools/diskbench.c index a59d823..7b5654c 100644 --- a/tools/diskbench.c +++ b/tools/diskbench.c @@ -12,8 +12,15 @@ * Every row says whether the bypass was actually obtained, because on a * filesystem that refuses it these are RAM numbers wearing a disk's label. * + * The last argument turns GB/s into tok/s for a model that reads that many + * GB per token cold. It has no default: the column used to assume K3's 12.5 + * unconditionally, which is ~8x off on a 48B model (1.61 GB/token measured) + * and says so nowhere. `waste bench` prints the real figure for a container + * as "disk N GB total, M GB/token"; pass M here, or leave it out and read + * the GB/s. + * * Build: cc -O2 -o diskbench tools/diskbench.c - * Usage: ./diskbench /Volumes/WasteDisk/k3/.bench [file_gb] [rec_mb] [threads] + * Usage: ./diskbench /Volumes/WasteDisk/k3/.bench [file_gb] [rec_mb] [threads] [gb_per_token] */ #define _GNU_SOURCE #include @@ -142,11 +149,21 @@ static void *rand_reader(void *p) { } int main(int argc, char **argv) { - if (argc < 2) { fprintf(stderr, "usage: %s PATH [file_gb] [rec_mb] [threads]\n", argv[0]); return 1; } + if (argc < 2) { + fprintf(stderr, "usage: %s PATH [file_gb] [rec_mb] [threads] [gb_per_token]\n" + " gb_per_token: from `waste bench` on the container you are\n" + " sizing for; omitted, the tok/s column is omitted with it\n", argv[0]); + return 1; + } g_path = argv[1]; double file_gb = argc > 2 ? atof(argv[2]) : 8.0; double rec_mb = argc > 3 ? atof(argv[3]) : 12.0; int maxthreads = argc > 4 ? atoi(argv[4]) : 8; + /* No default, deliberately. This was K3's 12.5 hardcoded into the format + * string, so every run printed a K3 answer whatever the container being + * sized — off by ~8x on a 48B model, and silent about it. A column that + * cannot be derived from what was measured does not get printed. */ + double gb_tok = argc > 5 ? atof(argv[5]) : 0.0; g_file = (size_t)(file_gb * (1u << 30)); g_rec = (size_t)(rec_mb * (1u << 20)) & ~4095UL; /* A record under one page rounds to zero and divides by it two screens @@ -196,8 +213,11 @@ int main(int argc, char **argv) { for (int i = 0; i < nt; i++) { pthread_join(th[i], NULL); tot += ta[i].bytes; } dt = now() - t0; double gbs = tot / dt / (1u << 30); - printf("rand %2d thr : %6.2f GB/s -> %.2f tok/s at 12.5 GB/token cold (%s)\n", - nt, gbs, gbs / 12.5, bypass_note()); + if (gb_tok > 0) + printf("rand %2d thr : %6.2f GB/s -> %.2f tok/s at %.4g GB/token cold (%s)\n", + nt, gbs, gbs / gb_tok, gb_tok, bypass_note()); + else + printf("rand %2d thr : %6.2f GB/s (%s)\n", nt, gbs, bypass_note()); } if (!g_direct) {