From 30bfe08c1c78e9eb177103cd545a3944fd24b6af Mon Sep 17 00:00:00 2001
From: Perfloop Agent <agent@perfloop.ai>
Date: Sat, 19 Sep 2026 01:01:30 +0200
Subject: [PATCH] Batch B-tree TIDs when building a bitmap

Pass each saved leaf-page span to tbm_add_tuples in one call. This lets the
existing within-call heap-block cache serve adjacent TIDs. Copy the TIDs
because BTScanPosItem does not store them as a contiguous array.

Keep the first TID on the scalar path. Use a separate non-inlined helper
only when another saved item is available. Allocate one bounded buffer per
primitive scan and reuse it for its remaining leaf-page spans.

Keep scan order, tuple counts, recheck behavior, and scalar-array progression
unchanged. Add regression coverage for duplicate keys in a scalar-array
scan and for a small span with both scalar and batched TIDs.
---
 src/backend/access/nbtree/nbtree.c      | 86 +++++++++++++++++++------
 src/test/regress/expected/bitmapops.out | 19 ++++++
 src/test/regress/sql/bitmapops.sql      |  9 +++
 3 files changed, 96 insertions(+), 18 deletions(-)

diff --git a/src/backend/access/nbtree/nbtree.c b/src/backend/access/nbtree/nbtree.c
index 0abdd7b49f..7196b38378 100644
--- a/src/backend/access/nbtree/nbtree.c
+++ b/src/backend/access/nbtree/nbtree.c
@@ -284,6 +284,58 @@ btgettuple(IndexScanDesc scan, ScanDirection dir)
 	return res;
 }
 
+/*
+ * _btgetbitmap_batch() -- add the rest of a primitive scan in batches
+ *
+ * The caller has found a saved leaf-page span with more than one item.
+ * Keeping the batch state in this helper leaves the common singleton path in
+ * btgetbitmap with the same local state as the original scalar loop.
+ */
+static pg_noinline int64
+_btgetbitmap_batch(IndexScanDesc scan, TIDBitmap *tbm)
+{
+	BTScanOpaque so = (BTScanOpaque) scan->opaque;
+	int64		ntids = 0;
+	ItemPointerData *heapTids;
+	int			firstItem;
+	int			lastItem;
+	int			nitems;
+
+	heapTids = palloc_array(ItemPointerData, MaxTIDsPerBTreePage);
+	firstItem = so->currPos.itemIndex;
+	lastItem = so->currPos.lastItem;
+	nitems = lastItem - firstItem + 1;
+
+	for (;;)
+	{
+		/*
+		 * The heap TIDs are not contiguous in BTScanPosItem, so copy them
+		 * into a contiguous array before adding them to the bitmap. Pass the
+		 * saved leaf-page span in one call so tbm_add_tuples can reuse its
+		 * current-block lookup cache within the span.
+		 */
+		for (int i = 0; i < nitems; i++)
+			heapTids[i] = so->currPos.items[firstItem + i].heapTid;
+		tbm_add_tuples(tbm, heapTids, nitems, false);
+		ntids += nitems;
+
+		/*
+		 * Mark the current page consumed before letting _bt_next move to the
+		 * next one.
+		 */
+		so->currPos.itemIndex = lastItem;
+		if (!_bt_next(scan, ForwardScanDirection))
+			break;
+
+		firstItem = so->currPos.itemIndex;
+		lastItem = so->currPos.lastItem;
+		nitems = lastItem - firstItem + 1;
+	}
+
+	pfree(heapTids);
+	return ntids;
+}
+
 /*
  * btgetbitmap() -- gets all matching tuples, and adds them to a bitmap
  */
@@ -292,7 +344,6 @@ btgetbitmap(IndexScanDesc scan, TIDBitmap *tbm)
 {
 	BTScanOpaque so = (BTScanOpaque) scan->opaque;
 	int64		ntids = 0;
-	ItemPointer heapTid;
 
 	Assert(scan->heapRelation == NULL);
 
@@ -302,28 +353,27 @@ btgetbitmap(IndexScanDesc scan, TIDBitmap *tbm)
 		/* Fetch the first page & tuple */
 		if (_bt_first(scan, ForwardScanDirection))
 		{
-			/* Save tuple ID, and continue scanning */
-			heapTid = &scan->xs_heaptid;
-			tbm_add_tuples(tbm, heapTid, 1, false);
-			ntids++;
-
 			for (;;)
 			{
-				/*
-				 * Advance to next tuple within page.  This is the same as the
-				 * easy case in _bt_next().
-				 */
-				if (++so->currPos.itemIndex > so->currPos.lastItem)
+				/* Keep the current tuple on the original scalar path. */
+				tbm_add_tuples(tbm, &scan->xs_heaptid, 1, false);
+				ntids++;
+
+				++so->currPos.itemIndex;
+				if (so->currPos.itemIndex <= so->currPos.lastItem)
 				{
-					/* let _bt_next do the heavy lifting */
-					if (!_bt_next(scan, ForwardScanDirection))
-						break;
+					/*
+					 * A later item in the current span is available, so the
+					 * helper can consume the rest of this primitive scan in
+					 * batches.
+					 */
+					ntids += _btgetbitmap_batch(scan, tbm);
+					break;
 				}
 
-				/* Save tuple ID, and continue scanning */
-				heapTid = &so->currPos.items[so->currPos.itemIndex].heapTid;
-				tbm_add_tuples(tbm, heapTid, 1, false);
-				ntids++;
+				/* let _bt_next do the heavy lifting */
+				if (!_bt_next(scan, ForwardScanDirection))
+					break;
 			}
 		}
 		/* Now see if we need another primitive index scan */
diff --git a/src/test/regress/expected/bitmapops.out b/src/test/regress/expected/bitmapops.out
index 64068e0469..948d8e3d81 100644
--- a/src/test/regress/expected/bitmapops.out
+++ b/src/test/regress/expected/bitmapops.out
@@ -44,5 +44,24 @@ SELECT count(*) FROM bmscantest WHERE a = 1 OR b = 1;
   2485
 (1 row)
 
+-- Test a scalar-array B-tree bitmap scan, including duplicate index keys.
+SELECT count(*) FROM bmscantest WHERE a IN (1, 2, 3);
+ count 
+-------
+  3963
+(1 row)
+
+-- Two matches on one leaf page must include the scalar and batched TIDs.
+CREATE TABLE bmscan_small (a int);
+INSERT INTO bmscan_small VALUES (1), (2), (3);
+CREATE INDEX bmscan_small_idx ON bmscan_small(a);
+SELECT a FROM bmscan_small WHERE a BETWEEN 1 AND 2 ORDER BY a;
+ a 
+---
+ 1
+ 2
+(2 rows)
+
+DROP TABLE bmscan_small;
 -- clean up
 DROP TABLE bmscantest;
diff --git a/src/test/regress/sql/bitmapops.sql b/src/test/regress/sql/bitmapops.sql
index 1b175f6ff9..a56b7ea871 100644
--- a/src/test/regress/sql/bitmapops.sql
+++ b/src/test/regress/sql/bitmapops.sql
@@ -42,6 +42,15 @@ SELECT count(*) FROM bmscantest WHERE a = 1 AND b = 1;
 -- Test bitmap-or.
 SELECT count(*) FROM bmscantest WHERE a = 1 OR b = 1;
 
+-- Test a scalar-array B-tree bitmap scan, including duplicate index keys.
+SELECT count(*) FROM bmscantest WHERE a IN (1, 2, 3);
+
+-- Two matches on one leaf page must include the scalar and batched TIDs.
+CREATE TABLE bmscan_small (a int);
+INSERT INTO bmscan_small VALUES (1), (2), (3);
+CREATE INDEX bmscan_small_idx ON bmscan_small(a);
+SELECT a FROM bmscan_small WHERE a BETWEEN 1 AND 2 ORDER BY a;
+DROP TABLE bmscan_small;
 
 -- clean up
 DROP TABLE bmscantest;

base-commit: 0c5d6269614e107d1d2d669f82f63f7e232b30c9
-- 
2.50.0

