Hi,

While looking at hash index behaviour under concurrency I found that
single-row insert throughput stops scaling at a fairly low client count
and then goes backwards, where an equivalent btree keeps scaling.  The
cause is the metapage, and I'd like to float a fix before writing a
finished patch.

The problem is that _hash_doinsert() takes an exclusive buffer lock on
the metapage for every insert, solely to run:

     metap->hashm_ntuples += 1;
     do_expand = metap->hashm_ntuples >
        (double) metap->hashm_ffactor * (metap->hashm_maxbucket + 1);

and to register the metapage in the insert's WAL record.  Every inserter
into the index therefore serialises on that one buffer.  The v10
metapage cache (293e24e5078) removed the metapage read from the lookup
path, but the write on insert was left in place; Mithun Cy noted at the
time that inserts still touch the metapage every time [1], and it
doesn't seem to have been revisited.

I wanted to be sure, so I did some measurements on a c7i.8xlarge (32
vCPU, one NUMA node), data on tmpfs, synchronous_commit off,
shared_buffers 16GB.  pgbench, one single-row INSERT of a random int8
per transaction, index pre-loaded with 2M rows, 20s per point, three
runs averaged:

     clients     1      16      32      64
     btree     44k    514k    547k    597k
     hash      44k    364k    299k    273k

Hash peaks around 16 clients and declines from there.  Sampling
pg_stat_activity at 32 clients, ~68% of hash inserter samples are in the
buffer-content-lock wait; btree shows none.

Timing LockBuffer(EXCLUSIVE) per buffer with a uprobe, one buffer
accounts for 99.9% of the exclusive- lock wait time, and pg_buffercache
maps it to block 0 of the index, the metapage.

Bucket splits themselves are not the problem: measured against btree
page splits they cost the same (16-32us typical, ~8ms worst case for
both, dominated by relation extension).

So here's what I propose to do, don't touch the metapage on most
inserts.  Each backend accumulates its own insert count for a given
index and only takes the metapage lock once every N inserts, adding the
batch and testing the split threshold then. hashm_ntuples only drives
split timing, and VACUUM already recomputes it from scratch
(hashbulkdelete -> hashm_ntuples = num_index_tuples), so a slightly
stale counter costs at most a somewhat delayed split, never correctness.

With a proof-of-concept doing exactly this (batch size 64, attached),
the hash numbers become:

     clients     1      16      32      64
     hash      44k    533k    557k    604k

with that change hash now scales like btree (btree was 597k at 64).
Regression and isolation tests pass; a streaming standby with 16
concurrent inserters and splits replayed to the same hashm_ntuples and
maxbucket as the primary, and index probes on the standby found every
row.

I was chasing something else when I found this, so the attached patch is
a proof of concept, not a finished submission.  It makes the point and
the numbers are real, but it cuts corners I'd want the list's opinion on
before building the real thing:

   - Where should the pending per-backend count live?  The POC uses a
     fixed-size backend-local array keyed by relid, and simply loses the
     pending count when the backend exits (VACUUM fixes hashm_ntuples
     later).  A cleaner home might be the relcache entry, flushed on
     relcache invalidation and at transaction end, but that has its own
     edge cases (invalidation dropping the count, subxact abort).

   - How stale may the split trigger be?  With per-backend batching the
     index can run up to batch_size * nbackends tuples past the fill
     factor before anyone splits.  That is self-correcting (every
     inserter that crosses the threshold tries to split), but it does
     mean the index can briefly carry more overflow pages than today.

   - Is there any appetite for removing the global counter from the hot
     path entirely instead, and triggering a split from local information
     the inserter already has (e.g. having had to add or walk an overflow
     page)?  That would also make inserts that don't split read-only on
     the metapage.  It's a bigger change and I haven't prototyped it yet.

If the general direction is acceptable I'll turn this into a proper
patch with the pending-count storage settled.

best.

-greg

[1] 
https://postgr.es/m/cad__ouiajlg_3mdchgy193s5-hhtjo6b8e4jvupggsv_taz...@mail.gmail.com

From 3c7b694ae8bc97e704a6faea43309ecc43f7d80f Mon Sep 17 00:00:00 2001
From: Greg Burd <[email protected]>
Date: Thu, 22 Oct 2026 11:11:52 -0400
Subject: [PATCH] POC: batch the hash index metapage tuple counter per backend

_hash_doinsert() exclusive-locks the metapage on every single insert, only
to do "hashm_ntuples += 1" and test the split threshold.  Under concurrent
single-row inserts this serialises all inserters on the metapage buffer and
is the dominant cost: on a 32-vCPU box, hash-index insert throughput peaks
at ~16 clients and then declines, while an equivalent btree keeps scaling.

This is a proof of concept, not a finished patch.  Each backend accumulates
its inserts for a given index locally and only takes the metapage lock once
per HASH_NTUPLES_BATCH inserts, adding the batch and testing for a split
then.  The metapage is registered in the insert's WAL record only on those
flushes, with the batch delta as block data; redo adds the delta when the
metapage block is present.

Known rough edges, discussed in the proposal thread:
  - pending counts are held in a fixed-size backend-local array and are lost
    at backend exit (VACUUM recomputes hashm_ntuples, so this only delays a
    split, never corrupts the index);
  - the split trigger therefore lags by up to HASH_NTUPLES_BATCH per backend.

Author: Greg Burd <[email protected]>
---
 src/backend/access/hash/hash_xlog.c  | 14 ++++-
 src/backend/access/hash/hashinsert.c | 77 +++++++++++++++++++++++-----
 2 files changed, 76 insertions(+), 15 deletions(-)

diff --git a/src/backend/access/hash/hash_xlog.c 
b/src/backend/access/hash/hash_xlog.c
index e9a2b9aa9a7..5d94def4079 100644
--- a/src/backend/access/hash/hash_xlog.c
+++ b/src/backend/access/hash/hash_xlog.c
@@ -123,8 +123,18 @@ hash_xlog_insert(XLogReaderState *record)
        if (BufferIsValid(buffer))
                UnlockReleaseBuffer(buffer);
 
-       if (XLogReadBufferForRedo(record, 1, &buffer) == BLK_NEEDS_REDO)
+       /* PROTOTYPE: the metapage is only registered when a batched count is 
flushed */
+       buffer = InvalidBuffer;
+       if (XLogRecHasBlockRef(record, 1) &&
+               XLogReadBufferForRedo(record, 1, &buffer) == BLK_NEEDS_REDO)
        {
+               Size            dlen;
+               char       *d = XLogRecGetBlockData(record, 1, &dlen);
+               uint32          delta = 1;
+
+               if (dlen == sizeof(uint32))
+                       memcpy(&delta, d, sizeof(uint32));
+
                /*
                 * Note: in normal operation, we'd update the metapage while 
still
                 * holding lock on the page we inserted into.  But during 
replay it's
@@ -133,7 +143,7 @@ hash_xlog_insert(XLogReaderState *record)
                 */
                page = BufferGetPage(buffer);
                metap = HashPageGetMeta(page);
-               metap->hashm_ntuples += 1;
+               metap->hashm_ntuples += delta;
 
                PageSetLSN(page, lsn);
                MarkBufferDirty(buffer);
diff --git a/src/backend/access/hash/hashinsert.c 
b/src/backend/access/hash/hashinsert.c
index 3395bbc13f8..c83a8356aeb 100644
--- a/src/backend/access/hash/hashinsert.c
+++ b/src/backend/access/hash/hashinsert.c
@@ -22,6 +22,37 @@
 #include "storage/predicate.h"
 #include "utils/rel.h"
 
+/*
+ * PROTOTYPE: batch the metapage tuple count.  Each backend accumulates up to
+ * HASH_NTUPLES_BATCH inserts per index locally and only then takes the
+ * metapage exclusive lock to add them and check whether to split.
+ *
+ * shortcut: fixed 8-slot backend-local table, counts lost at backend exit
+ * (VACUUM recomputes hashm_ntuples); upgrade to a proper per-relation cache
+ * entry with flush at end of xact if this goes anywhere.
+ */
+#define HASH_NTUPLES_BATCH 64
+#define HASH_PENDING_SLOTS 8
+static Oid     hash_pending_relid[HASH_PENDING_SLOTS];
+static uint32 hash_pending_count[HASH_PENDING_SLOTS];
+
+static uint32 *
+hash_pending_slot(Relation rel)
+{
+       for (int i = 0; i < HASH_PENDING_SLOTS; i++)
+       {
+               if (hash_pending_relid[i] == RelationGetRelid(rel))
+                       return &hash_pending_count[i];
+               if (hash_pending_relid[i] == InvalidOid)
+               {
+                       hash_pending_relid[i] = RelationGetRelid(rel);
+                       hash_pending_count[i] = 0;
+                       return &hash_pending_count[i];
+               }
+       }
+       return NULL;                            /* full: fall back to 
per-insert update */
+}
+
 static void _hash_vacuum_one_page(Relation rel, Relation hrel,
                                                                  Buffer 
metabuf, Buffer buf);
 
@@ -47,6 +78,9 @@ _hash_doinsert(Relation rel, IndexTuple itup, Relation 
heapRel, bool sorted)
        HashPageOpaque pageopaque;
        Size            itemsz;
        bool            do_expand;
+       bool            flush_count;
+       uint32     *pending;
+       uint32          delta = 1;
        uint32          hashkey;
        Bucket          bucket;
        OffsetNumber itup_off;
@@ -191,10 +225,18 @@ restart_insert:
        }
 
        /*
-        * Write-lock the metapage so we can increment the tuple count. After
-        * incrementing it, check to see if it's time for a split.
+        * Only every HASH_NTUPLES_BATCH'th insert by this backend write-locks 
the
+        * metapage to publish the tuple count and check for a split.
         */
-       LockBuffer(metabuf, BUFFER_LOCK_EXCLUSIVE);
+       pending = hash_pending_slot(rel);
+       flush_count = (pending == NULL || ++(*pending) >= HASH_NTUPLES_BATCH);
+       if (flush_count && pending != NULL)
+       {
+               delta = *pending;
+               *pending = 0;
+       }
+       if (flush_count)
+               LockBuffer(metabuf, BUFFER_LOCK_EXCLUSIVE);
 
        /* Do the update.  No ereport(ERROR) until changes are logged */
        START_CRIT_SECTION();
@@ -203,15 +245,18 @@ restart_insert:
        itup_off = _hash_pgaddtup(rel, buf, itemsz, itup, sorted);
        MarkBufferDirty(buf);
 
-       /* metapage operations */
-       metap = HashPageGetMeta(metapage);
-       metap->hashm_ntuples += 1;
+       do_expand = false;
+       if (flush_count)
+       {
+               metap = HashPageGetMeta(metapage);
+               metap->hashm_ntuples += delta;
 
-       /* Make sure this stays in sync with _hash_expandtable() */
-       do_expand = metap->hashm_ntuples >
-               (double) metap->hashm_ffactor * (metap->hashm_maxbucket + 1);
+               /* Make sure this stays in sync with _hash_expandtable() */
+               do_expand = metap->hashm_ntuples >
+                       (double) metap->hashm_ffactor * (metap->hashm_maxbucket 
+ 1);
 
-       MarkBufferDirty(metabuf);
+               MarkBufferDirty(metabuf);
+       }
 
        /* XLOG stuff */
        if (RelationNeedsWAL(rel))
@@ -223,7 +268,11 @@ restart_insert:
                XLogBeginInsert();
                XLogRegisterData(&xlrec, SizeOfHashInsert);
 
-               XLogRegisterBuffer(1, metabuf, REGBUF_STANDARD);
+               if (flush_count)
+               {
+                       XLogRegisterBuffer(1, metabuf, REGBUF_STANDARD);
+                       XLogRegisterBufData(1, &delta, sizeof(delta));
+               }
 
                XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
                XLogRegisterBufData(0, itup, IndexTupleSize(itup));
@@ -234,12 +283,14 @@ restart_insert:
                recptr = XLogGetFakeLSN(rel);
 
        PageSetLSN(BufferGetPage(buf), recptr);
-       PageSetLSN(BufferGetPage(metabuf), recptr);
+       if (flush_count)
+               PageSetLSN(BufferGetPage(metabuf), recptr);
 
        END_CRIT_SECTION();
 
        /* drop lock on metapage, but keep pin */
-       LockBuffer(metabuf, BUFFER_LOCK_UNLOCK);
+       if (flush_count)
+               LockBuffer(metabuf, BUFFER_LOCK_UNLOCK);
 
        /*
         * Release the modified page and ensure to release the pin on primary
-- 
2.54.0

Reply via email to