diff --git a/buftable_bench/.gitignore b/buftable_bench/.gitignore new file mode 100644 index 00000000000..2988e8c3b49 --- /dev/null +++ b/buftable_bench/.gitignore @@ -0,0 +1,2 @@ +_work/ +.builds/ diff --git a/buftable_bench/README.md b/buftable_bench/README.md new file mode 100644 index 00000000000..1ad0cad8122 --- /dev/null +++ b/buftable_bench/README.md @@ -0,0 +1,87 @@ +# buftable_bench + +A micro-benchmark of PostgreSQL's shared **buffer mapping table** ops. Two SQL functions call +`BufTableLookup` / `BufTableInsert` / `BufTableDelete` **directly** on the live shared table and +**bulk-time** each op (one rdtsc pair ÷ N) — no `ReadBuffer`, no page copy, no per-op rdtsc. Both +operate on free buffer slots + synthetic tags and restore the table afterward. + +- `buftable_bench_probe(n, rounds, random)` — steady-state, load-factor ~1 (chains ≈ 1 entry): + insert / lookup_hit / lookup_miss / delete. +- `buftable_bench_collide(chain_len, total_entries, rounds, random, delete_under_lock)` — + **worst-case hash collisions**: forces `total_entries` tags into `total_entries/chain_len` bucket + chains of length `chain_len`, then times `lookup_hit_full` (chains intact) and `delete_drain` + (draining). Motivated by the review concern that the flat table's `BufTableDelete()` runs **while + the buffer-header spinlock is held** in `InvalidateBuffer()` (the dynahash baseline deleted after + releasing it), so `delete_drain` ns *is* the extra spinlock-hold the restructuring adds per + invalidate, and sweeping `chain_len` shows how it scales with collisions. It also reports + **`lock_hold`** — the *real* average buffer-header spinlock hold time: the probe actually calls + `LockBufHdr`/`UnlockBufHdrExt` on a spare buffer around a replica of the `InvalidateBuffer` critical + section, with `BufTableDelete` **inside** the hold when `delete_under_lock=true` (flat) or **after** + the unlock when `false` (origin/dynahash). `delete_drain` is the flat-*added* component; + `lock_hold` is the *total* hold in each arm (single-backend → duration, not contention). + +The whole thing is one self-contained test-module extension — the **only change vs stock +PostgreSQL**. x86_64 only (uses `rdtsc`). + +## Files + +``` +buftable_bench/ +├── run.sh # build the current branch (release) + run + print numbers +├── instrumentation/buftable_bench_module/ # the extension (drop into src/test/modules/) +│ ├── buftable_bench.c # buftable_bench_probe() + buftable_bench_collide() +│ ├── buftable_bench--1.0.sql # CREATE FUNCTION for both +│ ├── buftable_bench.control +│ ├── Makefile +│ └── meson.build +├── scripts/ +│ ├── bench_probe.sh # run the probe against one explicit install +│ ├── compare_probe.sh [sizes...] # flat-vs-dynahash A/B (needs two builds) +│ ├── bench_collide.sh # run the collision sweep against one install +│ └── compare_collide.sh [sizes...] # flat-vs-dynahash collision A/B (needs two builds) +└── results/ + ├── probe_summary.txt # recorded probe A/B numbers + ├── compare_probe.results.txt + └── collide_summary.txt # recorded collision A/B numbers + conclusion +``` +(`.builds/` and `_work/` are local build/scratch caches — gitignored.) + +## How to run + +**The current branch** (builds this repo's `HEAD` as a release, caches it per commit, runs, and +prints the per-op numbers): + +```sh +buftable_bench/run.sh # default shared_buffers = 1GB +buftable_bench/run.sh 256MB 4GB 16GB # any sizes +``` +First run for a commit builds (~30 s–few min); later runs are instant. Output (to stdout), labeled +`branch@commit`: +``` +-- shared_buffers=1GB (n=104857 keys) -- + op | avg_ns | count + insert | 18.555 | 1048570 + lookup_hit | 23.064 | 1048570 + lookup_miss | 21.902 | 1048570 + delete | 24.569 | 1048570 +``` +Knobs: `BUFTABLE_PROBE_ROUNDS` (default 10); `PG_CONFIG=/path/bin/pg_config` to skip the build and +use an existing install. (`run.sh` is **single-arm** — it measures the current branch only.) + +**Flat-vs-dynahash A/B** (optional; needs two installs at `~/pg-bench/{flat,dyna}`): +```sh +buftable_bench/scripts/compare_probe.sh 256MB 4GB 16GB +``` + +**Worst-case collision A/B** (the `InvalidateBuffer` spinlock-hold question; needs the same two +installs, with the module rebuilt into each — `make -C src/test/modules/buftable_bench install`): +```sh +buftable_bench/scripts/compare_collide.sh 256MB 4GB # sweeps chain length G=1..64 +BUFTABLE_COLLIDE_ROUNDS=40 buftable_bench/scripts/compare_collide.sh 256MB +``` +Knobs: `BUFTABLE_COLLIDE_CHAINS` (default `1 2 4 8 16 32 64`), `BUFTABLE_COLLIDE_ROUNDS` (20), +`BUFTABLE_COLLIDE_TOTAL` (0 = auto). `delete_drain` ns is the extra header-spinlock hold the flat +table adds; `lookup_hit_full` (must rise ~linearly in G) is the collision sanity check. Recorded +numbers + conclusion in `results/collide_summary.txt`. **Note:** insert and delete phases use +*independent* random orders on purpose — flat head-inserts while dynahash tail-appends, so a shared +order would unfairly pin flat to the tail (worst) and dynahash to the head (best). diff --git a/buftable_bench/instrumentation/buftable_bench_module/Makefile b/buftable_bench/instrumentation/buftable_bench_module/Makefile new file mode 100644 index 00000000000..75ca30c79fe --- /dev/null +++ b/buftable_bench/instrumentation/buftable_bench_module/Makefile @@ -0,0 +1,22 @@ +# src/test/modules/buftable_bench/Makefile + +PGFILEDESC = "buftable_bench - rdtsc micro-benchmark for the buffer mapping table" + +MODULE_big = buftable_bench +OBJS = \ + $(WIN32RES) \ + buftable_bench.o + +EXTENSION = buftable_bench +DATA = buftable_bench--1.0.sql + +ifdef USE_PGXS +PG_CONFIG = pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) +else +subdir = src/test/modules/buftable_bench +top_builddir = ../../../.. +include $(top_builddir)/src/Makefile.global +include $(top_srcdir)/contrib/contrib-global.mk +endif diff --git a/buftable_bench/instrumentation/buftable_bench_module/buftable_bench--1.0.sql b/buftable_bench/instrumentation/buftable_bench_module/buftable_bench--1.0.sql new file mode 100644 index 00000000000..e4a2a626a9b --- /dev/null +++ b/buftable_bench/instrumentation/buftable_bench_module/buftable_bench--1.0.sql @@ -0,0 +1,35 @@ +/* src/test/modules/buftable_bench/buftable_bench--1.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION buftable_bench" to load this file. \quit + +CREATE FUNCTION buftable_bench_probe( + IN n int8, + IN rounds int8 DEFAULT 1, + IN random bool DEFAULT true, + OUT op text, + OUT avg_ns float8, + OUT count int8) +RETURNS SETOF record +AS 'MODULE_PATHNAME', 'buftable_bench_probe' +LANGUAGE C; + +REVOKE ALL ON FUNCTION buftable_bench_probe(int8, int8, bool) FROM PUBLIC; + +-- Worst-case hash-collision probe: forces chains of length chain_len and times +-- lookup_hit_full + delete_drain (delete_drain == the flat table's extra +-- buffer-header spinlock hold in InvalidateBuffer). See buftable_bench.c. +CREATE FUNCTION buftable_bench_collide( + IN chain_len int8, + IN total_entries int8 DEFAULT 0, + IN rounds int8 DEFAULT 10, + IN random bool DEFAULT true, + IN delete_under_lock bool DEFAULT true, + OUT op text, + OUT avg_ns float8, + OUT count int8) +RETURNS SETOF record +AS 'MODULE_PATHNAME', 'buftable_bench_collide' +LANGUAGE C; + +REVOKE ALL ON FUNCTION buftable_bench_collide(int8, int8, int8, bool, bool) FROM PUBLIC; diff --git a/buftable_bench/instrumentation/buftable_bench_module/buftable_bench.c b/buftable_bench/instrumentation/buftable_bench_module/buftable_bench.c new file mode 100644 index 00000000000..8284e3e9eb0 --- /dev/null +++ b/buftable_bench/instrumentation/buftable_bench_module/buftable_bench.c @@ -0,0 +1,726 @@ +/*------------------------------------------------------------------------- + * + * buftable_bench.c + * Pollution-free in-place benchmark of the shared buffer mapping table. + * + * Throwaway micro-benchmark module (NOT for upstream). One SQL function, + * buftable_bench_probe(n, rounds), times lookup (hit+miss), insert, and delete + * by calling BufTable{Insert,Lookup,Delete} DIRECTLY on the real shared table + * -- no ReadBuffer, no 8 KB page copy, no per-op rdtsc. Each op's loop is + * bulk-timed with a single rdtsc pair, so the measurement isn't polluted by + * page-copy cache traffic or per-op timer overhead. + * + * It works against STOCK PostgreSQL: it only calls the existing public + * BufTable* / BufTableHashCode functions, so no core changes are needed -- the + * two arms being compared are just two stock builds (flat table vs dynahash). + * + * Insert/delete mutate the live table, so we only use FREE buffer slots (their + * mapping entry is guaranteed empty) and restore the table afterward. + * + * There are two functions: + * + * buftable_bench_probe(n, rounds, random) + * load-factor ~1 (chains ~1 entry): insert / lookup_hit / lookup_miss / + * delete, each bulk-timed. + * + * buftable_bench_collide(chain_len, total_entries, rounds, random, + * delete_under_lock) + * WORST-CASE hash-collision test: forces many tags into the same bucket + * chain (length chain_len) and times lookup_hit_full + delete_drain. This + * exists to measure the concern that the flat table's BufTableDelete() + * runs while the buffer-header spinlock is held in InvalidateBuffer() + * (bufmgr.c), so its chain-walk cost IS extra spinlock-hold time -- unlike + * the dynahash baseline, which deleted after releasing the spinlock. The + * delete_drain per-op ns is exactly that added hold; sweeping chain_len + * shows how it scales with collisions. It ALSO reports lock_hold: the + * actual average time the real buffer-header spinlock is held across a + * replica of the InvalidateBuffer critical section, with BufTableDelete + * inside the hold (delete_under_lock=true, flat) or after the unlock + * (false, origin/dynahash) -- the total hold, of which delete_drain is the + * flat-added component. + * + * x86_64 only (rdtsc). + * + * IDENTIFICATION + * src/test/modules/buftable_bench/buftable_bench.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "fmgr.h" +#include "funcapi.h" +#include "miscadmin.h" +#include "port/pg_bitutils.h" +#include "portability/instr_time.h" +#include "storage/buf_internals.h" +#include "storage/bufmgr.h" +#include "utils/builtins.h" +#include "utils/tuplestore.h" + +PG_MODULE_MAGIC; + +PG_FUNCTION_INFO_V1(buftable_bench_probe); +PG_FUNCTION_INFO_V1(buftable_bench_collide); + +#define BUFTABLE_BENCH_PROBE_COLS 3 + +/* synthetic relfilenodes for bench tags — unlikely to collide with anything real */ +#define BENCH_SPC_OID 0xB0B0 +#define BENCH_DB_OID 0xB1B1 +#define BENCH_REL_PRESENT ((RelFileNumber) 0x7E570001) +#define BENCH_REL_ABSENT ((RelFileNumber) 0x7E570002) + +static inline uint64 +bench_rdtsc(void) +{ + uint32 lo, + hi; + + __asm__ __volatile__("rdtsc" : "=a"(lo), "=d"(hi)::"memory"); + return ((uint64) hi << 32) | lo; +} + +/* cycles per nanosecond, measured over a ~2 ms wall-clock window */ +static double +probe_calibrate(void) +{ + instr_time w0, + w1, + d; + uint64 c0, + c1; + double ns; + + INSTR_TIME_SET_CURRENT(w0); + c0 = bench_rdtsc(); + do + { + INSTR_TIME_SET_CURRENT(w1); + d = w1; + INSTR_TIME_SUBTRACT(d, w0); + } while (INSTR_TIME_GET_DOUBLE(d) < 0.002); + c1 = bench_rdtsc(); + ns = INSTR_TIME_GET_DOUBLE(d) * 1e9; + return (ns > 0.0) ? (double) (c1 - c0) / ns : 0.0; +} + +/* + * Collect up to want FREE buffer slots (mapping entry guaranteed empty, so we + * can insert a synthetic tag for that buf_id and restore it by delete). Fills + * bufids[] and returns the count actually found. + */ +static int64 +bench_collect_free_bufids(int *bufids, int64 want) +{ + int64 nfree = 0; + + for (int i = 0; i < NBuffers && nfree < want; i++) + { + BufferDesc *desc = GetBufferDescriptor(i); + uint64 state = pg_atomic_read_u64(&desc->state); + + if (!(state & BM_TAG_VALID)) + bufids[nfree++] = i; + } + return nfree; +} + +/* + * Fill ord[0..n) with a visit order over [0, n): identity when randomize is + * false, else a Fisher-Yates shuffle seeded by `seed` (reproducible). + * + * The insert and delete phases must use INDEPENDENT permutations (different + * seeds). The two arms lay chains out in opposite physical order -- flat + * head-inserts (buf_table.c), dynahash tail-appends (dynahash.c) -- so if the + * delete order matched the insert order, flat would always delete the tail-most + * entry (walk the whole chain, its worst case) while dynahash always deleted the + * head (O(1), its best case), and the comparison would be an artifact of that + * correlation rather than the per-node walk cost. An independent delete order + * makes the deleted entry's chain position uniform for BOTH arms, so each + * samples the same ~(len+1)/2 expected walk -- the fair comparison. + */ +static void +bench_make_order(int64 *ord, int64 n, bool randomize, uint64 seed) +{ + for (int64 i = 0; i < n; i++) + ord[i] = i; + + if (randomize) + { + uint64 rng = seed; + + for (int64 i = n - 1; i > 0; i--) + { + int64 k, + tmp; + + rng ^= rng << 13; + rng ^= rng >> 7; + rng ^= rng << 17; + k = (int64) (rng % (uint64) (i + 1)); + tmp = ord[i]; + ord[i] = ord[k]; + ord[k] = tmp; + } + } +} + +/* + * buftable_bench_probe(n, rounds) -> SETOF (op text, avg_ns float8, count int8) + * + * Rows: insert, lookup_hit, lookup_miss, delete. See file header. + */ +Datum +buftable_bench_probe(PG_FUNCTION_ARGS) +{ + int64 n = PG_GETARG_INT64(0); + int64 rounds = PG_ARGISNULL(1) ? 1 : PG_GETARG_INT64(1); + bool randomize = PG_ARGISNULL(2) ? true : PG_GETARG_BOOL(2); + ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo; + int *bufids; + int64 *ord; + BufferTag *ptag, + *atag; + uint32 *phash, + *ahash; + int64 nfree = 0; + double cyc_per_ns, + denom; + uint64 ins = 0, + lkh = 0, + lkm = 0, + del = 0; + volatile int64 sink = 0; + RelFileLocator rp = {.spcOid = BENCH_SPC_OID,.dbOid = BENCH_DB_OID,.relNumber = BENCH_REL_PRESENT}; + RelFileLocator ra = {.spcOid = BENCH_SPC_OID,.dbOid = BENCH_DB_OID,.relNumber = BENCH_REL_ABSENT}; + const char *names[4] = {"insert", "lookup_hit", "lookup_miss", "delete"}; + double avg[4]; + + if (n <= 0 || rounds <= 0) + ereport(ERROR, (errmsg("n and rounds must be positive"))); + + InitMaterializedSRF(fcinfo, 0); + + /* collect up to n FREE buffer slots (mapping entry guaranteed empty) */ + bufids = palloc(sizeof(int) * n); + for (int i = 0; i < NBuffers && nfree < n; i++) + { + BufferDesc *desc = GetBufferDescriptor(i); + uint64 state = pg_atomic_read_u64(&desc->state); + + if (!(state & BM_TAG_VALID)) + bufids[nfree++] = i; + } + n = nfree; + if (n == 0) + ereport(ERROR, (errmsg("no free buffers to probe with"))); + + /* build present + absent tags and their hashes */ + ptag = palloc(sizeof(BufferTag) * n); + atag = palloc(sizeof(BufferTag) * n); + phash = palloc(sizeof(uint32) * n); + ahash = palloc(sizeof(uint32) * n); + for (int64 j = 0; j < n; j++) + { + InitBufferTag(&ptag[j], &rp, MAIN_FORKNUM, (BlockNumber) j); + InitBufferTag(&atag[j], &ra, MAIN_FORKNUM, (BlockNumber) j); + phash[j] = BufTableHashCode(&ptag[j]); + ahash[j] = BufTableHashCode(&atag[j]); + } + + /* + * Iteration order over the keys: identity (sequential) or a Fisher-Yates + * shuffle (random). A shuffled order makes the timed loops visit keys in + * an order uncorrelated with where their entries/elements live, so BOTH + * arms' entry/element access is random (not just the bucket access, which + * the hash already scatters). Done once in setup (untimed). + */ + ord = palloc(sizeof(int64) * n); + for (int64 i = 0; i < n; i++) + ord[i] = i; + if (randomize) + { + uint64 rng = 0x9E3779B97F4A7C15ULL; /* fixed seed -> reproducible */ + + for (int64 i = n - 1; i > 0; i--) + { + int64 k, + tmp; + + rng ^= rng << 13; + rng ^= rng >> 7; + rng ^= rng << 17; + k = (int64) (rng % (uint64) (i + 1)); + tmp = ord[i]; + ord[i] = ord[k]; + ord[k] = tmp; + } + } + + cyc_per_ns = probe_calibrate(); + + PG_TRY(); + { + for (int64 r = 0; r < rounds; r++) + { + uint64 t0, + t1; + + t0 = bench_rdtsc(); + for (int64 i = 0; i < n; i++) + { + int64 j = ord[i]; + + BufTableInsert(&ptag[j], phash[j], bufids[j]); + } + t1 = bench_rdtsc(); + ins += t1 - t0; + + t0 = bench_rdtsc(); + for (int64 i = 0; i < n; i++) + { + int64 j = ord[i]; + + sink += BufTableLookup(&ptag[j], phash[j]); + } + t1 = bench_rdtsc(); + lkh += t1 - t0; + + t0 = bench_rdtsc(); + for (int64 i = 0; i < n; i++) + { + int64 j = ord[i]; + + sink += BufTableLookup(&atag[j], ahash[j]); + } + t1 = bench_rdtsc(); + lkm += t1 - t0; + + t0 = bench_rdtsc(); + for (int64 i = 0; i < n; i++) + { + int64 j = ord[i]; + + BufTableDelete(&ptag[j], phash[j]); + } + t1 = bench_rdtsc(); + del += t1 - t0; + } + } + PG_CATCH(); + { + /* best-effort restore: remove any present tag still mapped */ + for (int64 j = 0; j < n; j++) + if (BufTableLookup(&ptag[j], phash[j]) >= 0) + BufTableDelete(&ptag[j], phash[j]); + PG_RE_THROW(); + } + PG_END_TRY(); + + denom = (double) n * (double) rounds * cyc_per_ns; + avg[0] = ins / denom; + avg[1] = lkh / denom; + avg[2] = lkm / denom; + avg[3] = del / denom; + + for (int i = 0; i < 4; i++) + { + Datum values[BUFTABLE_BENCH_PROBE_COLS]; + bool nulls[BUFTABLE_BENCH_PROBE_COLS] = {0}; + + values[0] = CStringGetTextDatum(names[i]); + values[1] = Float8GetDatum(avg[i]); + values[2] = Int64GetDatum(n * rounds); + tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values, nulls); + } + + (void) sink; + return (Datum) 0; +} + +/* + * buftable_bench_collide(chain_len, total_entries, rounds, random) + * -> SETOF (op text, avg_ns float8, count int8) + * + * Worst-case hash-collision benchmark. Forces total_entries synthetic tags + * into K = total_entries/chain_len bucket chains, each of length chain_len (G), + * then bulk-times two ops over all K*G present keys, in a randomized visit + * order, per round: + * + * lookup_hit_full BufTableLookup on every key with chains intact. Pure + * chain-walk (no unlink); avg (G+1)/2 BufferTagsEqual compares + * per lookup. Its ns-vs-G slope is the per-comparison cost and + * is the collision sanity check (must grow ~linearly in G). + * delete_drain BufTableDelete on every key, emptying the chains. This is + * the work the flat table now does under the buffer-header + * spinlock in InvalidateBuffer(), so its per-op ns is the extra + * spinlock-hold time the restructuring adds (dynahash deleted + * after releasing the spinlock, so its hold contribution is 0). + * + * Rows returned: insert_build, lookup_hit_full, delete_drain, and + * worst_delete_est (a DERIVED estimate = per-comparison ns * G, i.e. the cost of + * deleting the tail-most entry which walks the whole chain -- reported so the + * random-order average is never misread as the worst case). + * + * Collisions are constructed to land in the SAME bucket in BOTH the flat and the + * dynahash arm: both compute the identical hashcode (tag_hash over the tag), and + * both index the bucket with the low bits of that hashcode. We keep tags whose + * hashcode shares the low B bits, with 2^B >= both arms' bucket counts, so they + * collide regardless of each arm's exact bucketing. B is derived from NBuffers + * alone, so it is identical in both arms. + * + * Like buftable_bench_probe, this uses only FREE buffer slots and restores the + * table via PG_TRY. It calls BufTable* directly, single-backend, holding no + * partition locks (safe: no other backend touches these synthetic tags). + * + * x86_64 only (rdtsc). + */ +Datum +buftable_bench_collide(PG_FUNCTION_ARGS) +{ + int64 G = PG_GETARG_INT64(0); + int64 req_total = PG_ARGISNULL(1) ? 0 : PG_GETARG_INT64(1); + int64 rounds = PG_ARGISNULL(2) ? 10 : PG_GETARG_INT64(2); + bool randomize = PG_ARGISNULL(3) ? true : PG_GETARG_BOOL(3); + bool delete_under_lock = PG_ARGISNULL(4) ? true : PG_GETARG_BOOL(4); + ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo; + int *bufids; + int64 *ord; + int64 *dord; + BufferTag *ptag; + uint32 *phash; + int64 nfree, + E, + K, + total; + int B; + uint32 mod_mask; + int cap_total; + /* per-residue fill state for the collision search */ + int *res_count; /* how many tags collected for each residue */ + int *res_slot; /* group index for a residue: -1 unseen, -2 ignore */ + bool *slot_full; /* slot_full[s] set once group s reaches G */ + int64 groups_started = 0; + int64 ngroups = 0; + double cyc_per_ns, + denom; + uint64 ins = 0, + lkh = 0, + del = 0, + hold = 0; + volatile int64 sink = 0; + BufferTag dummy; /* scratch target for ClearBufferTag under the lock */ + RelFileLocator rp = {.spcOid = BENCH_SPC_OID,.dbOid = BENCH_DB_OID,.relNumber = BENCH_REL_PRESENT}; + const char *names[5] = {"insert_build", "lookup_hit_full", "delete_drain", "worst_delete_est", "lock_hold"}; + double avg[5]; + + if (G <= 0 || rounds <= 0) + ereport(ERROR, (errmsg("chain_len and rounds must be positive"))); + + InitMaterializedSRF(fcinfo, 0); + + /* + * Collision modulus. Both arms bucket on the low bits of the (identical) + * hashcode. Flat uses num_buckets = Max(NUM_BUFFER_PARTITIONS, + * pg_nextpower2_32(NBuffers)); dynahash uses ~next_pow2(NBuffers + + * NUM_BUFFER_PARTITIONS) and consults up to the low log2(2*nbuckets) bits. + * B = ceil_log2(Max(NBuffers,128)) + 3 makes 2^B comfortably exceed both, so + * tags equal in the low B bits collide in the same bucket in BOTH arms. + */ + B = pg_ceil_log2_32((uint32) Max(NBuffers, 128)) + 3; + + /* + * The residue-bookkeeping arrays below are sized 2^B. B grows with NBuffers + * (~log2(shared_buffers)); at the sizes this tool targets (<= a few tens of + * GB) B is <= 26, i.e. arrays <= ~0.5 GB. Refuse absurdly large pools rather + * than clamp B, which would drop 2^B below an arm's bucket count and silently + * break the collision guarantee. + */ + if (B > 26) + ereport(ERROR, + (errmsg("shared_buffers too large for buftable_bench_collide (B=%d)", B), + errhint("This throwaway benchmark targets pools up to a few tens of GB."))); + mod_mask = (1u << B) - 1; + + /* + * Target total entries. Auto/default = min(NBuffers/2, 65536): enough for a + * stable per-op mean (E*rounds samples) while keeping the collision search + * and per-round loops fast and the free-buffer footprint modest. An explicit + * total_entries is still capped at NBuffers/2 (must fit in free slots). + */ + cap_total = (int) Max((int64) 1, (int64) NBuffers / 2); + E = (req_total > 0) ? req_total : Min((int64) cap_total, (int64) 65536); + if (E > cap_total) + E = cap_total; + if (E < G) + ereport(ERROR, (errmsg("total_entries (%ld) must be >= chain_len (%ld)", (long) E, (long) G))); + + /* collect free buffer slots to host the entries */ + bufids = palloc(sizeof(int) * E); + nfree = bench_collect_free_bufids(bufids, E); + if (nfree < G) + ereport(ERROR, (errmsg("only %ld free buffers; need at least chain_len=%ld", + (long) nfree, (long) G))); + if (nfree < E) + { + ereport(WARNING, (errmsg("only %ld free buffers available; reducing total_entries from %ld", + (long) nfree, (long) E))); + E = nfree; + } + + /* K full groups of size G fit into E slots */ + K = E / G; + if (K < 1) + K = 1; + total = K * G; + + /* + * Brute-force search for K residues (low-B-bit values) that each accumulate + * G distinct tags. We vary only blockNum; all other tag fields are fixed, + * so distinct blockNum -> distinct tag. We "start" a group the first time we + * see a residue (up to K groups); a residue seen after K groups are started + * is ignored. Each candidate is written straight into its group's slice of + * ptag[], so a completed group occupies ptag[slot*G .. slot*G+G). + */ + res_count = palloc0(sizeof(int) * ((Size) mod_mask + 1)); + res_slot = palloc(sizeof(int) * ((Size) mod_mask + 1)); + for (int64 r = 0; r <= (int64) mod_mask; r++) + res_slot[r] = -1; + slot_full = palloc0(sizeof(bool) * K); + + ptag = palloc(sizeof(BufferTag) * total); + phash = palloc(sizeof(uint32) * total); + + { + /* + * Scan cap: on average we need G*2^B candidates to fill K groups, but + * groups compete for residues, so allow a generous multiple. If we run + * out, reduce K to what we filled. + */ + uint64 scanned = 0; + uint64 scan_cap = (uint64) (mod_mask + 1) * (uint64) G * 8 + 1000000; + BlockNumber blk = 0; + + while (ngroups < K && scanned < scan_cap) + { + BufferTag tag; + uint32 h, + res; + int slot; + int64 idx; + + scanned++; + if (blk == P_NEW) /* skip InvalidBlockNumber */ + { + blk++; + continue; + } + InitBufferTag(&tag, &rp, MAIN_FORKNUM, blk); + blk++; + + h = BufTableHashCode(&tag); + res = h & mod_mask; + + slot = res_slot[res]; + if (slot == -2) + continue; /* residue's group already full, ignore */ + if (slot == -1) + { + /* first sighting of this residue: start a new group if we can */ + if (groups_started >= K) + { + res_slot[res] = -2; + continue; + } + slot = (int) groups_started++; + res_slot[res] = slot; + } + + /* append this tag as the next member of group `slot` */ + idx = (int64) slot * G + res_count[res]; + ptag[idx] = tag; + phash[idx] = h; + res_count[res]++; + + if (res_count[res] == (int) G) + { + res_slot[res] = -2; /* group full; ignore further hits */ + slot_full[slot] = true; + ngroups++; + } + } + + if (ngroups < K) + { + ereport(WARNING, (errmsg("collision search filled only %ld of %ld groups (chain_len=%ld); reducing", + (long) ngroups, (long) K, (long) G))); + + /* compact the full groups to the front of ptag[]/phash[] */ + { + int64 dst = 0; + + for (int64 s = 0; s < groups_started; s++) + { + if (!slot_full[s]) + continue; + if (dst != s) + { + memcpy(&ptag[dst * G], &ptag[s * G], sizeof(BufferTag) * G); + memcpy(&phash[dst * G], &phash[s * G], sizeof(uint32) * G); + } + dst++; + } + } + K = ngroups; + total = K * G; + } + } + + if (total <= 0) + ereport(ERROR, (errmsg("could not build any full collision chain of length %ld", (long) G))); + + /* + * Two independent visit orders over all present keys: `ord` for the + * insert/lookup phases, `dord` (different seed) for the delete phase, so the + * deleted entry's chain position is uncorrelated with its insertion position + * -- fair to both arms (see bench_make_order). + */ + ord = palloc(sizeof(int64) * total); + dord = palloc(sizeof(int64) * total); + bench_make_order(ord, total, randomize, 0x9E3779B97F4A7C15ULL); + bench_make_order(dord, total, randomize, 0xD1B54A32D192ED03ULL); + + cyc_per_ns = probe_calibrate(); + + PG_TRY(); + { + for (int64 r = 0; r < rounds; r++) + { + uint64 t0, + t1; + + /* build: insert every key (timed for reference, not the headline) */ + t0 = bench_rdtsc(); + for (int64 i = 0; i < total; i++) + { + int64 j = ord[i]; + + BufTableInsert(&ptag[j], phash[j], bufids[j]); + } + t1 = bench_rdtsc(); + ins += t1 - t0; + + /* lookup every key with chains fully populated (pure chain-walk) */ + t0 = bench_rdtsc(); + for (int64 i = 0; i < total; i++) + { + int64 j = ord[i]; + + sink += BufTableLookup(&ptag[j], phash[j]); + } + t1 = bench_rdtsc(); + lkh += t1 - t0; + + /* drain: delete every key (independent order), emptying the chains */ + t0 = bench_rdtsc(); + for (int64 i = 0; i < total; i++) + { + int64 j = dord[i]; + + BufTableDelete(&ptag[j], phash[j]); + } + t1 = bench_rdtsc(); + del += t1 - t0; + + /* + * lock_hold: measure the ACTUAL buffer-header spinlock hold time of + * the InvalidateBuffer() critical section. The drain above emptied + * the chains, so first rebuild them (untimed), then for each key take + * the real header spinlock on that key's (free) buffer, run the exact + * work InvalidateBuffer holds the lock across, and release -- all + * bracketed by one rdtsc pair per key. With delete_under_lock=true + * (flat) BufTableDelete runs INSIDE the hold; with false (origin/ + * dynahash) it runs AFTER the unlock (untimed here), exactly as the + * two InvalidateBuffer variants place it. Same randomized dord order + * as the drain, so both arms sample uniform chain positions. + */ + for (int64 i = 0; i < total; i++) /* rebuild chains, untimed */ + { + int64 j = ord[i]; + + BufTableInsert(&ptag[j], phash[j], bufids[j]); + } + + for (int64 i = 0; i < total; i++) + { + int64 j = dord[i]; + BufferDesc *desc = GetBufferDescriptor(bufids[j]); + uint64 bstate; + + t0 = bench_rdtsc(); + bstate = LockBufHdr(desc); /* real header spinlock acquire */ + /* work InvalidateBuffer does under the lock (cost only) */ + sink += BufferTagsEqual(&desc->tag, &ptag[j]); + sink += (int64) BUF_STATE_GET_REFCOUNT(bstate); + ClearBufferTag(&dummy); /* local scratch; don't mutate desc->tag */ + if (delete_under_lock) /* flat: delete inside the hold */ + BufTableDelete(&ptag[j], phash[j]); + /* unlock with no net state change (only clears BM_LOCKED) */ + UnlockBufHdrExt(desc, bstate, 0, 0, 0); + t1 = bench_rdtsc(); + hold += t1 - t0; + + if (!delete_under_lock) /* origin: delete AFTER unlock, untimed */ + BufTableDelete(&ptag[j], phash[j]); + } + } + } + PG_CATCH(); + { + /* best-effort restore: remove any present tag still mapped */ + for (int64 j = 0; j < total; j++) + if (BufTableLookup(&ptag[j], phash[j]) >= 0) + BufTableDelete(&ptag[j], phash[j]); + PG_RE_THROW(); + } + PG_END_TRY(); + + denom = (double) total * (double) rounds * cyc_per_ns; + avg[0] = ins / denom; /* insert_build */ + avg[1] = lkh / denom; /* lookup_hit_full: avg (G+1)/2 compares */ + avg[2] = del / denom; /* delete_drain: == flat's extra spinlock hold */ + + /* + * worst_delete_est: cost of deleting the tail-most entry (walks all G + * nodes). lookup_hit_full averages (G+1)/2 comparisons, so per-comparison + * ns = avg[1] / ((G+1)/2); the worst single delete does G comparisons. + */ + { + double per_cmp = avg[1] / (((double) G + 1.0) / 2.0); + + avg[3] = per_cmp * (double) G; + } + + /* + * lock_hold: average time the buffer-header spinlock was actually held across + * the replicated InvalidateBuffer critical section (see the timed phase). + * flat lock_hold - origin lock_hold should track delete_drain -- the moved + * BufTableDelete is the only work whose lock placement differs. + */ + avg[4] = hold / denom; + + for (int i = 0; i < 5; i++) + { + Datum values[BUFTABLE_BENCH_PROBE_COLS]; + bool nulls[BUFTABLE_BENCH_PROBE_COLS] = {0}; + + values[0] = CStringGetTextDatum(names[i]); + values[1] = Float8GetDatum(avg[i]); + values[2] = Int64GetDatum(total * rounds); + tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values, nulls); + } + + (void) sink; + return (Datum) 0; +} diff --git a/buftable_bench/instrumentation/buftable_bench_module/buftable_bench.control b/buftable_bench/instrumentation/buftable_bench_module/buftable_bench.control new file mode 100644 index 00000000000..aae6cb1810f --- /dev/null +++ b/buftable_bench/instrumentation/buftable_bench_module/buftable_bench.control @@ -0,0 +1,4 @@ +comment = 'rdtsc micro-benchmark for the shared buffer mapping table' +default_version = '1.0' +module_pathname = '$libdir/buftable_bench' +relocatable = true diff --git a/buftable_bench/instrumentation/buftable_bench_module/meson.build b/buftable_bench/instrumentation/buftable_bench_module/meson.build new file mode 100644 index 00000000000..752773841b2 --- /dev/null +++ b/buftable_bench/instrumentation/buftable_bench_module/meson.build @@ -0,0 +1,22 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +buftable_bench_sources = files( + 'buftable_bench.c', +) + +if host_system == 'windows' + buftable_bench_sources += rc_lib_gen.process(win32ver_rc, extra_args: [ + '--NAME', 'buftable_bench', + '--FILEDESC', 'buftable_bench - rdtsc micro-benchmark for the buffer mapping table',]) +endif + +buftable_bench = shared_module('buftable_bench', + buftable_bench_sources, + kwargs: pg_test_mod_args, +) +test_install_libs += buftable_bench + +test_install_data += files( + 'buftable_bench.control', + 'buftable_bench--1.0.sql', +) diff --git a/buftable_bench/results/collide_summary.txt b/buftable_bench/results/collide_summary.txt new file mode 100644 index 00000000000..7ea89a24e5f --- /dev/null +++ b/buftable_bench/results/collide_summary.txt @@ -0,0 +1,133 @@ +Worst-case hash-collision A/B of the shared buffer mapping table (dynahash vs flat) +================================================================================== + +Motivation +---------- +Review concern: the flat table's BufTableDelete() now runs WHILE the buffer-header +spinlock is held in InvalidateBuffer() (bufmgr.c: delete at :2448, UnlockBufHdrExt +moved to :2454 to close a slot-reuse race). The dynahash baseline deleted AFTER +releasing the spinlock. Since BufTableDelete walks the bucket chain, collisions make +it longer -> longer spinlock hold. This measures that with forced collisions. + +Method +------ +buftable_bench_collide(chain_len G, total_entries, rounds) forces total_entries +synthetic tags into K = total_entries/G bucket chains, each of length G, then +bulk-times (rdtsc) BufTableLookup (chains intact) and BufTableDelete (draining), in +independent randomized orders per phase. Direct BufTable* calls on the real shared +table, single-backend, free buffer slots, restored via PG_TRY. Both arms are STOCK +builds; collisions are portable because both compute the identical tag_hash and +bucket on its low bits (tags share low B bits, 2^B >= both arms' bucket counts). + +delete_drain ns == the extra header-spinlock hold the flat table adds per invalidate +at chain length G. dynahash's hold contribution is 0 (its delete ran outside the lock). + +lookup_hit_full is the fair sanity metric (each key probed once at its fixed chain +position, independent of insert/delete order): ns must rise ~linearly in G in both +arms, confirming collisions are real. + +IMPORTANT methodology note: the insert and delete phases MUST use independent random +orders. A first version reused one permutation; because flat head-inserts and dynahash +tail-appends, that made flat always delete the tail (O(G), its worst) and dynahash +always delete the head (O(1), its best), producing a bogus "flat 7.6x slower at G=64". +With independent orders both arms sample uniform chain positions (~(G+1)/2 compares) and +the comparison is fair. Numbers below are the corrected (independent-order) run. + +Results (speedup = dynahash/flat; >1 => flat faster), rounds=40 +-------------------------------------------------------------- + +shared_buffers = 256MB + delete_drain G: 1 2 4 8 16 32 64 + dynahash ns 33.7 38.4 48.4 55.8 68.7 100.5 152.6 + flat ns 14.4 21.2 30.5 41.3 56.3 79.4 118.8 + speedup(dh/ft) 2.35x 1.81x 1.59x 1.35x 1.22x 1.27x 1.28x + lookup_hit_full G: 1 2 4 8 16 32 64 + dynahash ns 26.2 31.7 40.6 52.9 73.3 130.2 230.0 + flat ns 15.8 22.3 30.4 43.3 65.0 100.6 164.6 + speedup(dh/ft) 1.67x 1.42x 1.34x 1.22x 1.13x 1.29x 1.40x + +shared_buffers = 4GB + delete_drain G: 1 2 4 8 16 32 64 + dynahash ns 57.3 64.9 77.7 103.1 137.5 201.5 316.1 + flat ns 21.8 29.4 40.4 59.2 90.7 131.7 190.9 + speedup(dh/ft) 2.63x 2.21x 1.92x 1.74x 1.52x 1.53x 1.66x + lookup_hit_full G: 1 2 4 8 16 32 64 + dynahash ns 37.4 47.3 60.4 90.1 138.2 236.4 482.1 + flat ns 19.8 29.5 42.2 64.4 105.1 164.0 253.6 + speedup(dh/ft) 1.89x 1.61x 1.43x 1.40x 1.31x 1.44x 1.90x + +shared_buffers = 16GB + delete_drain G: 1 2 4 8 16 32 64 + dynahash ns 87.7 77.8 90.3 113.0 144.6 218.8 327.2 + flat ns 41.3 46.0 53.9 72.6 98.3 135.1 194.1 + speedup(dh/ft) 2.12x 1.69x 1.67x 1.56x 1.47x 1.62x 1.69x + lookup_hit_full G: 1 2 4 8 16 32 64 + dynahash ns 50.0 50.0 64.8 94.5 140.1 253.8 502.3 + flat ns 20.6 30.6 43.7 70.8 109.9 166.0 257.1 + speedup(dh/ft) 2.43x 1.63x 1.48x 1.34x 1.27x 1.53x 1.95x + +(4GB re-run above is from the same batch as the 16GB run, rounds=40; numbers match the +earlier 4GB run within run-to-run noise.) + +lock_hold: REAL average buffer-header spinlock hold (total, both arms), rounds=40 +---------------------------------------------------------------------------------- +Added a `lock_hold` metric that ACTUALLY takes the buffer-header spinlock (LockBufHdr / +UnlockBufHdrExt) on a spare buffer and rdtsc-brackets a faithful replica of the +InvalidateBuffer critical section: tag-compare + refcount read + ClearBufferTag, plus +BufTableDelete INSIDE the hold for flat (delete_under_lock=true) or AFTER the unlock for +dynahash (false) -- exactly how each arm's InvalidateBuffer places it. This is the TOTAL +hold, vs delete_drain which is only the flat-added component. Single-backend => hold +DURATION, not contention. (flat ns / dyna ns / flat-dyna delta): + + 256MB G: 1 2 4 8 16 32 64 + dynahash 49.7 47.8 48.6 47.8 49.7 50.8 57.3 + flat 73.3 75.7 78.9 84.2 92.9 108.2 152.6 + flat-dyna +23.6 +27.9 +30.3 +36.3 +43.2 +57.4 +95.3 (~ flat delete_drain) + + 4GB G: 1 2 4 8 16 32 64 + dynahash 61.9 59.9 63.0 67.3 71.3 81.3 101.0 + flat 106.2 112.5 119.5 131.2 149.4 190.0 240.1 + flat-dyna +44.3 +52.6 +56.5 +63.9 +78.1 +108.7 +139.2 + + 16GB G: 1 2 4 8 16 32 64 + dynahash 64.4 63.4 63.1 68.8 76.0 77.7 102.0 + flat 112.9 118.6 122.3 139.8 159.1 195.0 248.6 + flat-dyna +48.5 +55.2 +59.2 +71.0 +83.1 +117.3 +146.5 + +CONSISTENCY CHECK PASSES: flat-dyna tracks flat delete_drain at every G and size (the +moved BufTableDelete is the only work whose lock placement differs), confirming the +replicated critical section is faithful. + +Two reads of the SAME numbers: +- TOTAL hold: flat holds the header spinlock ~1.5-2.4x LONGER than dynahash across the + board (e.g. 4GB G=1: 106 vs 62 ns; G=64: 240 vs 101 ns). This is the honest cost of the + restructuring on the invalidate path -- the delete moved inside the lock, so flat's total + hold is unavoidably higher than origin's even though flat's delete is individually cheaper. +- ADDED hold (flat-dyna): what the move itself costs -- ~24-48 ns at realistic G=1, up to + ~95-147 ns at a pathological G=64. +Note the absolute holds (tens to low-hundreds of ns) are still short for a spinlock at +realistic load; the concern is real and quantified, not alarming, but flat is NOT cheaper +than origin on hold time (only on the isolated delete op). + +Conclusion +---------- +1. The extra spinlock hold is REAL and grows with collisions: flat's BufTableDelete + under the lock costs ~14 ns at G=1 (256MB) / ~21 ns at G=1 (4GB), rising to ~119 ns + (256MB) / ~195 ns (4GB) at a pathological G=64. Measured as TOTAL hold (lock_hold), + flat holds the spinlock ~1.5-2.4x longer than origin because the delete now sits inside + the critical section. +2. But it is NOT a regression vs dynahash. At EVERY chain length, flat's delete is + 1.3-2.7x FASTER than the same delete would cost in the dynahash table (denser 24B + index-linked entries vs dynahash's pointer-chased 40B elements + function-pointer + key compare). lookup_hit_full confirms the flat chain-walk is intrinsically faster + per node. So placing the delete under the spinlock adds hold time in absolute terms, + but the flat structure minimizes that hold relative to the old table. +3. Practical read: at realistic load (G=1, no collisions) the added hold is ~14-21 ns + (a few hundred cycles). Reaching 64-deep chains needs 64 distinct tags colliding in + one 128-partition-aligned bucket -- pathological. The concern is bounded: the hold + grows with collisions but stays below the dynahash-equivalent cost, so the flat + table is not the thing making the hold long; only the delete's PLACEMENT inside the + lock (vs after it in dynahash) adds hold at all. + +Reproduce: buftable_bench/scripts/compare_collide.sh 256MB 4GB 16GB (BUFTABLE_COLLIDE_ROUNDS=40) + (emits delete_drain + lock_hold + worst_delete_est + lookup_hit_full per size) diff --git a/buftable_bench/results/compare_probe.results.txt b/buftable_bench/results/compare_probe.results.txt new file mode 100644 index 00000000000..c55903132b8 --- /dev/null +++ b/buftable_bench/results/compare_probe.results.txt @@ -0,0 +1,24 @@ +RESULT dyna 256MB insert 28.995 262140 +RESULT dyna 256MB lookup_hit 21.134 262140 +RESULT dyna 256MB lookup_miss 21.257 262140 +RESULT dyna 256MB delete 21.798 262140 +RESULT flat 256MB insert 13.245 262140 +RESULT flat 256MB lookup_hit 16.222 262140 +RESULT flat 256MB lookup_miss 17.498 262140 +RESULT flat 256MB delete 17.805 262140 +RESULT dyna 4GB insert 66.845 4194300 +RESULT dyna 4GB lookup_hit 49.673 4194300 +RESULT dyna 4GB lookup_miss 39.825 4194300 +RESULT dyna 4GB delete 56.704 4194300 +RESULT flat 4GB insert 30.506 4194300 +RESULT flat 4GB lookup_hit 31.717 4194300 +RESULT flat 4GB lookup_miss 28.786 4194300 +RESULT flat 4GB delete 34.971 4194300 +RESULT dyna 16GB insert 104.300 16777210 +RESULT dyna 16GB lookup_hit 82.074 16777210 +RESULT dyna 16GB lookup_miss 62.069 16777210 +RESULT dyna 16GB delete 88.639 16777210 +RESULT flat 16GB insert 55.068 16777210 +RESULT flat 16GB lookup_hit 62.942 16777210 +RESULT flat 16GB lookup_miss 47.260 16777210 +RESULT flat 16GB delete 69.308 16777210 diff --git a/buftable_bench/results/probe_summary.txt b/buftable_bench/results/probe_summary.txt new file mode 100644 index 00000000000..a6ac9ba855d --- /dev/null +++ b/buftable_bench/results/probe_summary.txt @@ -0,0 +1,29 @@ +# Pollution-free in-place A/B of the REAL shared buffer mapping table. +# buftable_bench_probe(n, rounds, random): direct BufTable{Insert,Lookup,Delete} +# calls, bulk-timed (one rdtsc pair per phase), NO ReadBuffer/page-copy, NO +# per-op rdtsc. random=true (DEFAULT): keys visited in a shuffled order so the +# bucket AND entry/element accesses are random for both arms. +# speedup = dynahash/flat (>1 = flat faster). n ~ 0.8*NBuffers, rounds=10. +# +# === RANDOM access (default) === +# sb op dynahash_ns flat_ns speedup +# 256MB insert 28.99 13.25 2.19x +# 256MB lookup_hit 21.13 16.22 1.30x +# 256MB lookup_miss 21.26 17.50 1.21x +# 256MB delete 21.80 17.81 1.22x +# 4GB insert 66.85 30.51 2.19x +# 4GB lookup_hit 49.67 31.72 1.57x +# 4GB lookup_miss 39.83 28.79 1.38x +# 4GB delete 56.70 34.97 1.62x +# 16GB insert 104.30 55.07 1.89x +# 16GB lookup_hit 82.07 62.94 1.30x +# 16GB lookup_miss 62.07 47.26 1.31x +# 16GB delete 88.64 69.31 1.28x +# +# Flat is FASTER on EVERY op at EVERY size (1.2-2.2x). Random access raises +# absolute ns and compresses the ratio vs sequential (a common-mode key-fetch +# cost, equal for both arms) -- the more conservative, realistic number. +# +# For reference, the earlier SEQUENTIAL order (random=false) gave larger ratios: +# insert 2.4/2.8/3.5x, lookup_hit 1.6/1.7/2.3x, lookup_miss 1.3/1.3/1.7x, +# delete 1.4/1.6/2.2x @256MB/4GB/16GB (entry/element access was prefetch-friendly). diff --git a/buftable_bench/run.sh b/buftable_bench/run.sh new file mode 100755 index 00000000000..5ac38801863 --- /dev/null +++ b/buftable_bench/run.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# +# run.sh [size ...] +# +# Build the CURRENTLY CHECKED-OUT BRANCH of this repo (its HEAD) as a release, +# then run the buffer-mapping-table probe and print the per-op numbers for it. +# Single arm (just this branch's buf_table.c) — for the flat-vs-dynahash A/B +# use scripts/compare_probe.sh. +# +# The build is cached per commit under buftable_bench/.builds/, so the +# first run for a commit takes a few minutes and later runs are instant. +# It exports the committed tree (git archive) into a temp dir to build, so your +# working checkout is never touched. +# +# Env: PG_CONFIG (use this prebuilt install instead of building), +# BUFTABLE_PROBE_ROUNDS (default 10), BUFTABLE_BENCH_WORK. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MODSRC="$HERE/instrumentation/buftable_bench_module" +WORK="${BUFTABLE_BENCH_WORK:-$HERE/_work}"; mkdir -p "$WORK" +ROUNDS="${BUFTABLE_PROBE_ROUNDS:-10}" +SIZES="${*:-1GB}" + +repo="$(git -C "$HERE" rev-parse --show-toplevel)" +commit="$(git -C "$repo" rev-parse --short HEAD)" +branch="$(git -C "$repo" rev-parse --abbrev-ref HEAD)" +label="$branch@$commit" + +# ---- locate or build a release install of the current HEAD ------------------ +if [[ -n "${PG_CONFIG:-}" ]]; then + BIN="$("$PG_CONFIG" --bindir)" + echo "==> using PG_CONFIG build: $("$PG_CONFIG" --version) [$BIN]" >&2 +else + prefix="$HERE/.builds/$commit" + if [[ -x "$prefix/bin/pg_config" ]] && \ + [[ -e "$("$prefix/bin/pg_config" --pkglibdir)/buftable_bench.so" ]]; then + echo "==> reusing cached release build of $label [$prefix]" >&2 + else + echo "==> building $label as release (first run for this commit, ~2-4 min)..." >&2 + src="$(mktemp -d "$WORK/src.$commit.XXXX")" + git -C "$repo" archive HEAD | tar -x -C "$src" + mkdir -p "$src/src/test/modules/buftable_bench" + cp "$MODSRC"/* "$src/src/test/modules/buftable_bench"/ + ( + cd "$src" + ./configure --prefix="$prefix" --without-icu --without-zlib --without-readline >/dev/null + make -s -j"$(nproc)" install >/dev/null + make -s -C src/test/modules/buftable_bench install >/dev/null + ) || { echo "build failed; see $src" >&2; exit 1; } + rm -rf "$src" + echo "==> built $label" >&2 + fi + BIN="$prefix/bin" +fi + +# ---- run the probe per size ------------------------------------------------- +size_to_bytes() { + local s="${1^^}" + case "$s" in + *GB) echo $(( ${s%GB} * 1024 * 1024 * 1024 ));; + *MB) echo $(( ${s%MB} * 1024 * 1024 ));; + *KB) echo $(( ${s%KB} * 1024 ));; + *) echo "$s";; + esac +} + +echo +echo "===== buftable_bench: $label (random access, rounds=$ROUNDS) =====" +for SIZE in $SIZES; do + DATADIR="$(mktemp -d "$WORK/pgdata.${SIZE}.XXXX")" + SOCKDIR="$(mktemp -d /tmp/pgb.XXXXXX)" + N="$(awk -v b="$(size_to_bytes "$SIZE")" 'BEGIN{printf "%d", 0.8*b/8192}')" + + "$BIN/initdb" -D "$DATADIR" --no-sync -A trust >/dev/null 2>&1 + cat >> "$DATADIR/postgresql.conf" </dev/null + + echo "-- shared_buffers=$SIZE (n=$N keys) --" + "$BIN/psql" -h "$SOCKDIR" -d postgres -q -P pager=off \ + -c "CREATE EXTENSION buftable_bench;" \ + -c "SELECT op, round(avg_ns::numeric,3) AS avg_ns, count + FROM buftable_bench_probe($N, $ROUNDS) + ORDER BY array_position(ARRAY['insert','lookup_hit','lookup_miss','delete'], op);" + + "$BIN/pg_ctl" -D "$DATADIR" -m immediate stop >/dev/null 2>&1 || true + rm -rf "$DATADIR" "$SOCKDIR" +done +echo "================================================================" diff --git a/buftable_bench/scripts/bench_collide.sh b/buftable_bench/scripts/bench_collide.sh new file mode 100755 index 00000000000..262c1c082e8 --- /dev/null +++ b/buftable_bench/scripts/bench_collide.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# +# bench_collide.sh [delete_under_lock] +# +# WORST-CASE hash-collision benchmark of the shared buffer mapping table, via +# buftable_bench_collide() — which forces many tags into ONE bucket chain and +# bulk-times BufTableLookup (chains intact) and BufTableDelete (draining the +# chains). delete_drain is exactly the work the flat table now does while the +# buffer-header spinlock is held in InvalidateBuffer(); the dynahash baseline +# did its delete AFTER releasing that spinlock, so its hold contribution is 0. +# It also reports lock_hold: the real average buffer-header spinlock hold across +# a replica of the InvalidateBuffer critical section, with BufTableDelete inside +# the hold when delete_under_lock=true (flat) or after it when false (origin). +# +# The optional 3rd arg delete_under_lock (default "true") selects that placement: +# pass "true" for the flat arm, "false" for the dynahash/origin arm. +# +# Sweeps chain length G and emits, per G and op: +# "RESULT " +# ops: insert_build, lookup_hit_full, delete_drain, worst_delete_est, lock_hold. +# +# Env: +# BUFTABLE_COLLIDE_CHAINS chain lengths to sweep (default "1 2 4 8 16 32 64") +# BUFTABLE_COLLIDE_ROUNDS timed rounds per point (default 20) +# BUFTABLE_COLLIDE_TOTAL total entries E; 0 = module auto (default 0) +set -euo pipefail + +PREFIX="${1:?usage: bench_collide.sh [delete_under_lock]}" +SIZE="${2:?usage: bench_collide.sh [delete_under_lock]}" +DUL="${3:-true}" +CHAINS="${BUFTABLE_COLLIDE_CHAINS:-1 2 4 8 16 32 64}" +ROUNDS="${BUFTABLE_COLLIDE_ROUNDS:-20}" +TOTAL="${BUFTABLE_COLLIDE_TOTAL:-0}" +ARM="$(basename "$PREFIX")" +BIN="$PREFIX/bin" + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRATCH="${BUFTABLE_BENCH_WORK:-$HERE/_work}"; mkdir -p "$SCRATCH" +DATADIR="$(mktemp -d "$SCRATCH/pgdata.${ARM}.${SIZE}.co.XXXX")" +SOCKDIR="$(mktemp -d /tmp/pgb.XXXXXX)" +LOG="$DATADIR/server.log" + +log() { echo "[$ARM $SIZE collide] $*" >&2; } +cleanup() { "$BIN/pg_ctl" -D "$DATADIR" -m immediate stop >/dev/null 2>&1 || true; rm -rf "$DATADIR" "$SOCKDIR"; } +trap cleanup EXIT + +"$BIN/initdb" -D "$DATADIR" --no-sync -A trust >/dev/null 2>&1 +cat >> "$DATADIR/postgresql.conf" </dev/null + +"$BIN/psql" -h "$SOCKDIR" -d postgres -q -X -v ON_ERROR_STOP=1 \ + -c "CREATE EXTENSION buftable_bench;" >/dev/null + +for G in $CHAINS; do + OUT="$("$BIN/psql" -h "$SOCKDIR" -d postgres -q -X -At -F' ' -v ON_ERROR_STOP=1 \ + -c "SELECT 'R', op, round(avg_ns::numeric,3), count FROM buftable_bench_collide($G, $TOTAL, $ROUNDS, true, $DUL);" 2>&1)" || { + log "psql failed at G=$G:"; echo "$OUT" >&2; exit 1; + } + echo "$OUT" | awk -v arm="$ARM" -v sz="$SIZE" -v g="$G" \ + '$1=="R"{printf "RESULT %s %s %s %s %s %s\n", arm, sz, g, $2, $3, $4}' +done +log "done" diff --git a/buftable_bench/scripts/bench_probe.sh b/buftable_bench/scripts/bench_probe.sh new file mode 100755 index 00000000000..fde619911b2 --- /dev/null +++ b/buftable_bench/scripts/bench_probe.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# +# bench_probe.sh +# +# Pollution-free, in-place benchmark of the REAL shared buffer mapping table for +# lookup (hit+miss), insert, and delete, via buftable_bench_probe() — which calls +# BufTable{Insert,Lookup,Delete} directly (no ReadBuffer, no 8 KB page copy, no +# per-op rdtsc; each op's loop is bulk-timed). No table/prewarm needed: it uses +# free buffer slots + synthetic tags and restores the table afterward. +# +# Emits "RESULT ". +# Env: BUFTABLE_PROBE_ROUNDS (default 10). +set -euo pipefail + +PREFIX="${1:?usage: bench_probe.sh }" +SIZE="${2:?usage: bench_probe.sh }" +ROUNDS="${BUFTABLE_PROBE_ROUNDS:-10}" +ARM="$(basename "$PREFIX")" +BIN="$PREFIX/bin" + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRATCH="${BUFTABLE_BENCH_WORK:-$HERE/_work}"; mkdir -p "$SCRATCH" +DATADIR="$(mktemp -d "$SCRATCH/pgdata.${ARM}.${SIZE}.pr.XXXX")" +SOCKDIR="$(mktemp -d /tmp/pgb.XXXXXX)" +LOG="$DATADIR/server.log" + +log() { echo "[$ARM $SIZE probe] $*" >&2; } +cleanup() { "$BIN/pg_ctl" -D "$DATADIR" -m immediate stop >/dev/null 2>&1 || true; rm -rf "$DATADIR" "$SOCKDIR"; } +trap cleanup EXIT + +size_to_bytes() { + local s="${1^^}" + case "$s" in + *GB) echo $(( ${s%GB} * 1024 * 1024 * 1024 ));; + *MB) echo $(( ${s%MB} * 1024 * 1024 ));; + *) echo "$s";; + esac +} +SB="$(size_to_bytes "$SIZE")" +N="$(awk -v b="$SB" 'BEGIN{printf "%d", 0.8*b/8192}')" # ~0.8x NBuffers -> load factor ~1 + +"$BIN/initdb" -D "$DATADIR" --no-sync -A trust >/dev/null 2>&1 +cat >> "$DATADIR/postgresql.conf" </dev/null + +OUT="$("$BIN/psql" -h "$SOCKDIR" -d postgres -q -X -At -F' ' -v ON_ERROR_STOP=1 \ + -c "CREATE EXTENSION buftable_bench;" \ + -c "SELECT 'R', op, round(avg_ns::numeric,3), count FROM buftable_bench_probe($N, $ROUNDS);" 2>&1)" || { + log "psql failed:"; echo "$OUT" >&2; exit 1; +} + +echo "$OUT" | awk -v arm="$ARM" -v sz="$SIZE" '$1=="R"{printf "RESULT %s %s %s %s %s\n", arm, sz, $2, $3, $4}' +log "done" diff --git a/buftable_bench/scripts/compare_collide.sh b/buftable_bench/scripts/compare_collide.sh new file mode 100755 index 00000000000..a7470bc224d --- /dev/null +++ b/buftable_bench/scripts/compare_collide.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# +# compare_collide.sh [size ...] +# +# Worst-case hash-collision A/B of the shared buffer mapping table (dynahash vs +# flat), via buftable_bench_collide(). For each size, sweeps chain length G and +# prints, per op, dynahash-ns vs flat-ns and speedup = dynahash/flat (>1 = flat +# faster). +# +# The headline is delete_drain: in the flat table BufTableDelete() runs while the +# buffer-header spinlock is held in InvalidateBuffer(), so flat's delete_drain ns +# IS the extra spinlock-hold the restructuring adds. Dynahash deleted AFTER +# releasing the spinlock, so its contribution to the hold is 0 (its delete_drain +# ns is shown only as the raw table-op cost, for reference). lookup_hit_full is +# the collision sanity check: ns must rise ~linearly in G in BOTH arms. +# +# Sizes default "256MB 4GB" or $BUFTABLE_COLLIDE_SIZES or args. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BENCH="$HERE/bench_collide.sh" +FLAT=/home/dhruv.aron/pg-bench/flat +DYNA=/home/dhruv.aron/pg-bench/dyna +SCRATCH="${BUFTABLE_BENCH_WORK:-$HERE/_work}"; mkdir -p "$SCRATCH" +RESULTS="$SCRATCH/compare_collide.results.txt"; : > "$RESULTS" + +if [[ $# -gt 0 ]]; then SIZES="$*"; else SIZES="${BUFTABLE_COLLIDE_SIZES:-256MB 4GB}"; fi + +for size in $SIZES; do + # Each arm replicates ITS OWN InvalidateBuffer: dynahash deletes AFTER the + # spinlock unlock (delete_under_lock=false), flat deletes UNDER it (true). + echo ">>> dyna @ $size (delete_under_lock=false)" >&2 + bash "$BENCH" "$DYNA" "$size" false | grep '^RESULT ' >> "$RESULTS" + echo ">>> flat @ $size (delete_under_lock=true)" >&2 + bash "$BENCH" "$FLAT" "$size" true | grep '^RESULT ' >> "$RESULTS" +done + +echo +echo "===== worst-case collision A/B (real shared table, direct probe) =====" +awk ' +{ arm=$2; size=$3; g=$4+0; op=$5; v[size,g,op,arm]=$6; + if(!(size in seen_s)){seen_s[size]=1; sorder[++ns]=size} + key=size SUBSEP g; if(!(key in seen_g)){seen_g[key]=1; gcount[size]++; glist[size,gcount[size]]=g} } +END{ + for(si=1; si<=ns; si++){ s=sorder[si]; + # sort this size s chain lengths ascending (simple insertion sort) + n=gcount[s]; + for(a=1;a<=n;a++) arr[a]=glist[s,a]; + for(a=2;a<=n;a++){ x=arr[a]; b=a-1; while(b>=1 && arr[b]>x){arr[b+1]=arr[b];b--}; arr[b+1]=x } + + printf "\n############ shared_buffers = %s ############\n", s; + + printf "\n--- delete_drain (flat ns == EXTRA buffer-header spinlock hold; dynahash hold contribution = 0) ---\n"; + printf "%6s %14s %12s %14s\n","G","dynahash ns","flat ns","speedup(dh/ft)"; + for(a=1;a<=n;a++){ g=arr[a]; + dh=v[s,g,"delete_drain","dyna"]; ft=v[s,g,"delete_drain","flat"]; + if(dh==""||ft==""){ printf "%6d %14s %12s %14s\n",g,(dh==""?"-":dh),(ft==""?"-":ft),"n/a"; continue } + printf "%6d %14.3f %12.3f %13.2fx\n", g, dh, ft, (ft>0?dh/ft:0); + } + + printf "\n--- lock_hold (REAL avg buffer-header spinlock hold across the InvalidateBuffer crit-section) ---\n"; + printf "%6s %14s %12s %12s %18s\n","G","dynahash ns","flat ns","flat-dyna","(cf delete_drain ft)"; + for(a=1;a<=n;a++){ g=arr[a]; + dh=v[s,g,"lock_hold","dyna"]; ft=v[s,g,"lock_hold","flat"]; dd=v[s,g,"delete_drain","flat"]; + if(dh==""||ft==""){ printf "%6d %14s %12s %12s %18s\n",g,(dh==""?"-":dh),(ft==""?"-":ft),"n/a","n/a"; continue } + printf "%6d %14.3f %12.3f %+12.3f %18s\n", g, dh, ft, (ft-dh), (dd==""?"-":sprintf("%.3f",dd)); + } + + printf "\n--- worst_delete_est (derived: cost of deleting the tail-most entry, ~ per-cmp ns * G) ---\n"; + printf "%6s %14s %12s %14s\n","G","dynahash ns","flat ns","speedup(dh/ft)"; + for(a=1;a<=n;a++){ g=arr[a]; + dh=v[s,g,"worst_delete_est","dyna"]; ft=v[s,g,"worst_delete_est","flat"]; + if(dh==""||ft==""){ printf "%6d %14s %12s %14s\n",g,(dh==""?"-":dh),(ft==""?"-":ft),"n/a"; continue } + printf "%6d %14.3f %12.3f %13.2fx\n", g, dh, ft, (ft>0?dh/ft:0); + } + + printf "\n--- lookup_hit_full (collision sanity: ns must rise ~linearly in G in BOTH arms) ---\n"; + printf "%6s %14s %12s %14s\n","G","dynahash ns","flat ns","speedup(dh/ft)"; + for(a=1;a<=n;a++){ g=arr[a]; + dh=v[s,g,"lookup_hit_full","dyna"]; ft=v[s,g,"lookup_hit_full","flat"]; + if(dh==""||ft==""){ printf "%6d %14s %12s %14s\n",g,(dh==""?"-":dh),(ft==""?"-":ft),"n/a"; continue } + printf "%6d %14.3f %12.3f %13.2fx\n", g, dh, ft, (ft>0?dh/ft:0); + } + } +}' "$RESULTS" +echo +echo "======================================================================" +echo "Read: lock_hold is the REAL average buffer-header spinlock hold time of the" +echo "InvalidateBuffer() critical section in each arm (dynahash deletes after the" +echo "unlock, flat under it). flat-dyna is the extra hold the restructuring adds" +echo "and should track flat delete_drain (the moved BufTableDelete is the only" +echo "difference). Single-backend: hold DURATION, not lock contention." diff --git a/buftable_bench/scripts/compare_probe.sh b/buftable_bench/scripts/compare_probe.sh new file mode 100755 index 00000000000..fe98a8ab149 --- /dev/null +++ b/buftable_bench/scripts/compare_probe.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# +# compare_probe.sh [size ...] +# +# Pollution-free in-place A/B of the shared buffer mapping table for all three +# ops (insert / lookup_hit / lookup_miss / delete), via buftable_bench_probe(). +# Prints, per size, dynahash vs flat avg_ns and speedup = dynahash/flat (>1 = +# flat faster). Sizes default "256MB 4GB 16GB" or $BUFTABLE_CAPI_SIZES or args. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BENCH="$HERE/bench_probe.sh" +FLAT=/home/dhruv.aron/pg-bench/flat +DYNA=/home/dhruv.aron/pg-bench/dyna +SCRATCH="${BUFTABLE_BENCH_WORK:-$HERE/_work}"; mkdir -p "$SCRATCH" +RESULTS="$SCRATCH/compare_probe.results.txt"; : > "$RESULTS" + +if [[ $# -gt 0 ]]; then SIZES="$*"; else SIZES="${BUFTABLE_CAPI_SIZES:-256MB 4GB 16GB}"; fi + +for size in $SIZES; do + for prefix in "$DYNA" "$FLAT"; do + echo ">>> $(basename "$prefix") @ $size" >&2 + bash "$BENCH" "$prefix" "$size" | grep '^RESULT ' >> "$RESULTS" + done +done + +echo +echo "===== pollution-free direct-probe A/B (real shared table) =====" +awk ' +{ arm=$2; size=$3; op=$4; v[size,op,arm]=$5; cnt[size,op,arm]=$6; + if(!(size in seen)){seen[size]=1; order[++ni]=size} } +END{ + split("insert lookup_hit lookup_miss delete", ops, " "); + for(i=1;i<=ni;i++){ s=order[i]; + printf "\n=== shared_buffers = %s (samples/op %s) ===\n", s, cnt[s,"insert","flat"]; + printf "%-12s %12s %12s %14s\n","op","dynahash ns","flat ns","speedup(dh/ft)"; + for(j=1;j<=4;j++){ o=ops[j]; + dh=v[s,o,"dyna"]; ft=v[s,o,"flat"]; + if(dh==""||ft==""){ printf "%-12s %12s %12s %14s\n",o,(dh==""?"-":dh),(ft==""?"-":ft),"n/a"; continue } + printf "%-12s %12.3f %12.3f %13.2fx\n", o, dh, ft, (ft>0?dh/ft:0); + } + } +}' "$RESULTS" +echo "==============================================================="