Perf: Specialize ExecIndexOnlyScan() like ExecSeqScan()

From: Matthias Dötsch <matze(at)mdoetsch(dot)de>
To: pgsql-hackers(at)lists(dot)postgresql(dot)org
Cc: amitlan(at)postgresql(dot)org, dgrowleyml(at)gmail(dot)com
Subject: Perf: Specialize ExecIndexOnlyScan() like ExecSeqScan()
Date: 2026-10-14 10:36:49
Message-ID: 3684e94b-3077-4430-94d9-e5411aa5f110@mdoetsch.de
Views: Whole Thread | Raw Message | Download mbox | Resend email
Thread:
Lists: pgsql-hackers

Hey,

this is my first patch for Postgres, so please let me know if I got
something about the contribution policies wrong.

Our main workload does a lot of index-only scans, and in perf profiles of it
a good part of the time showed up under ExecScan(). Digging into the
code I noticed that IndexScan, IndexOnlyScan and BitmapHeapScan all
still go through the generic out-of-line ExecScan(). So each tuple pays
for an indirect call to the access method and for the runtime EPQ /
qual / projection checks. SeqScan got rid of that with fb9f95502 (and
83a53572a6f made sure SeqNext actually gets inlined).

The attached patch does the same for index-only scans. There are now
ExecIndexOnlyScan, ...WithQuals, ...WithProject, ...WithQualProject and
...EPQ, the variant is picked in ExecInitIndexOnlyScan(), and
IndexOnlyNext() is pg_always_inline. The EPQ variant just calls
ExecScan() like ExecSeqScanEPQ does.

One thing that cost me bit more time: the runtime key setup (which may call
ExecReScan()) has to happen before the pg_assume() calls. If it comes
after, gcc can't know that pstate->qual is still non-NULL after the
call, keeps the !qual && !projInfo branch of ExecScanExtended() and you
end up with a second inlined copy of IndexOnlyNext() in each variant.
I added a comment for that.

Test setup: table with id int primary key and two more int columns, 5M
rows, vacuumed so it's all-visible. enable_seqscan and enable_bitmapscan
off, jit off, no parallel. gcc 12, -O2, i7-13700H.

                                                 instructions latency
  SELECT count(*) FROM t
    WHERE id BETWEEN 1 AND 1000000                    -5.2%    -5 to -11%
  same, plus AND id % 3 = 0                           -4.8%    -3 to -5%
  SELECT id + 1 FROM t
    WHERE id BETWEEN 1 AND 1000000 OFFSET 2000000     -6.3%    -4 to -10%

Instructions are from callgrind (single user mode), so those are kinda
deterministic. Latency is median pgbench latency over 5 interleaved
rounds, server pinned to one P-core. It's a range because I ran
everything a second time with -falign-functions=64 and the wall clock
results moved by several percent just from the the layout change. So I
wouldn't put too much weight on the exact latency numbers, but it was
faster in all cases.

tbh the code gets bigger: the per-tuple function is 576-858 bytes depending
on the variant, and the IOS functions together go from ~0.6kB to
~3.5kB.

Tests: regress and isolation pass with cassert, and CI on my github
fork is green on all platforms. There is no existing test that runs an
index-only scan under EPQ though. I checked that path manually with a
correlated subquery in an UPDATE that has to wait for a concurrent
update. I can turn that into an isolation test if people think it's
worth it.

any thoughts?

Best,
Matthias

Attachment Content-Type Size
v2-0001-Specialize-ExecIndexOnlyScan-like-ExecSeqScan.patch text/x-patch 7.8 KB

Browse pgsql-hackers by date

  From Date Subject
Next Message Ashutosh Sharma 2026-10-14 10:47:28 Re: Skip prefetch for block references that follow a FPW or WILL_INIT of the same block
Previous Message Nikhil Sontakke 2026-10-14 10:31:49 Re: Publication DDL can race with a concurrent UPDATE