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     <prefix> <size>    # run the probe against one explicit install
+│   ├── compare_probe.sh   [sizes...]         # flat-vs-dynahash A/B (needs two builds)
+│   ├── bench_collide.sh   <prefix> <size>    # 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/<commit>, 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" <<CONF
+shared_buffers = '$SIZE'
+jit = off
+autovacuum = off
+fsync = off
+bgwriter_lru_maxpages = 0
+listen_addresses = ''
+unix_socket_directories = '$SOCKDIR'
+CONF
+	"$BIN/pg_ctl" -D "$DATADIR" -l "$DATADIR/log" -w start >/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 <pg_install_prefix> <shared_buffers_size> [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 <arm> <size> <G> <op> <avg_ns> <count>"
+# 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 <prefix> <size> [delete_under_lock]}"
+SIZE="${2:?usage: bench_collide.sh <prefix> <size> [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" <<CONF
+shared_buffers = '$SIZE'
+jit = off
+autovacuum = off
+fsync = off
+bgwriter_lru_maxpages = 0
+listen_addresses = ''
+unix_socket_directories = '$SOCKDIR'
+CONF
+
+log "start (shared_buffers=$SIZE, chains='$CHAINS', rounds=$ROUNDS, total=$TOTAL, delete_under_lock=$DUL)"
+"$BIN/pg_ctl" -D "$DATADIR" -l "$LOG" -w start >/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 <pg_install_prefix> <shared_buffers_size>
+#
+# 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 <arm> <size> <op> <avg_ns> <count>".
+# Env: BUFTABLE_PROBE_ROUNDS (default 10).
+set -euo pipefail
+
+PREFIX="${1:?usage: bench_probe.sh <prefix> <size>}"
+SIZE="${2:?usage: bench_probe.sh <prefix> <size>}"
+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" <<CONF
+shared_buffers = '$SIZE'
+jit = off
+autovacuum = off
+fsync = off
+bgwriter_lru_maxpages = 0
+listen_addresses = ''
+unix_socket_directories = '$SOCKDIR'
+CONF
+
+log "start (shared_buffers=$SIZE, n=$N, rounds=$ROUNDS)"
+"$BIN/pg_ctl" -D "$DATADIR" -l "$LOG" -w start >/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 "==============================================================="
