Re: Recovery does not honor io_combine_limit, causing IOPS saturation

From: Mats Kindahl <mats(dot)kindahl(at)gmail(dot)com>
To: Andrey Borodin <x4mmm(at)yandex-team(dot)ru>, Markos Fountoulakis <markos(at)planetscale(dot)com>
Cc: PostgreSQL Hackers <pgsql-hackers(at)lists(dot)postgresql(dot)org>
Subject: Re: Recovery does not honor io_combine_limit, causing IOPS saturation
Date: 2026-11-09 18:51:05
Message-ID: ce8d5dc0-2730-4b02-9fba-c090bddadb2d@gmail.com
Views: Whole Thread | Raw Message | Download mbox | Resend email
Thread:
Lists: pgsql-hackers

Hi Markos, Andrey,

I did some benchmarks and it was somewhat a disappointment, but I'll
summarize it here. There are some improvements, but not as much as I
expected. I leave it to you to decide if it is worth merging. If you
have suggestions for other measurements, I'm happy to do them.

Summary
--------------

Run on 2026-11-08 in `eu-north-1`, on a fresh `m6i.xlarge` with a fresh
50 GiB gp3 volume (3,000 IOPS, 125 MiB/s, XFS), the baseline commit
(unpatched) and the patched commit (read-ahead cache), built as release
(`-O3`, no assertions). 99 timed runs and 4 untimed `strace` count runs.
Cold unless stated otherwise.

Workload: `pgbench -i` bulk load, then `kill -9`. The WAL was generated
with the baseline binary and both binaries recover the same pristine
crashed copy.

Sizes: pgbench scales 25, 100 and 300, i.e. 0.30, 1.19 and 3.57 GiB of
WAL to replay.

Setup: one `m6i.xlarge`, `gp3` volume with 3,000 IOPS and 125 MiB/s, XFS,
crash recovery of 3.57 GiB of WAL (pgbench bulk load), 3 repetitions per
setting, cold page cache unless stated.

Different settings of `io_combine_limit` (cold cache)

| Setting           | Dev reads | pread64 | Redo s | CPU s |
| ----------------- | --------- | ------- | ------ | ----- |
| Baseline          | 59,921    | 468,121 | 53.5   | 5.82  |
| Patched, 16 pages | 58,779    | 29,408  | 53.2   | 5.76  |
| Patched, 32 pages | 44,168    | 14,784  | 50.1   | 5.46  |
| Patched, 64 pages | 44,156    | 7,472   | 49.4   | 5.18  |
| Baseline, ra=256  | 45,067    | n/c     | 50.3   | 5.42  |

Baseline = unpatched build, ra=256 = `read_ahead_kb=256` (default 128).
Dev reads = device read I/Os; CPU = startup process user+system. n/c =
not counted (expected to equal the baseline's, since readahead does not
change the calls).

* Device reads fall 25% at 32 pages. Redo time falls 5.8% (32 pages) and
  7.1% (64 pages) and startup CPU 5.3% and 10.1%, compared with the
  patch at its default of 16 pages.
* The unpatched baseline with read_ahead_kb=256 has 45,067 device reads
  (-24.8%), redo -6.0% and startup CPU -6.8%. The patch with 32 pages is
  within 2.0% of that on device reads and 0.3% on redo time. So on this
  setup (gp3, XFS, cold cache) `read_ahead_kb=256` gives the same read
  reduction without a code change.
* The patch still helps CPU at large limits: with 64 pages, startup CPU
  is -4.4% compared with the baseline at read_ahead_kb=256 (95% CI of
  the difference [-0.31, -0.16] s). This is probably the saved syscalls.
* Device reads stop falling at 256 KiB: 64 pages gives the same 44,156
  reads as 32 pages.
* Time until ready is 66.2 s in every setting. Recovery is limited by
  the volume's 125 MiB/s throughput.
* Average IOPS (reads + writes per second) was 1,008 to 1,253 in all
  settings, well under the volume's 3,000 limit, and neither the
  volume's nor the instance's IOPS limit was exceeded. So fewer reads
  did not speed recovery up here.

Sorry about the noise,
Mats Kindahl

On 9/14/26 20:44, Andrey Borodin wrote:
> Hi Mats, Markos,
>
> Mats, in the original gp3 setup, did you also measure fewer device-level
> reads and shorter recovery time with the normal readahead settings?
>
> I'd also be interested in replay performance with WAL and relation pages
> already cached. Fewer syscalls could help even when the kernel already
> combines disk reads, but does that saving outweigh the extra per-page
> memcpy in this implementation?
>
> Looking at v1, I think there is a cache-validity issue with streaming:
>
>> + if (readSource == XLOG_FROM_STREAM)
>> + wanted = Min(wanted, readLen);
>> ...
>> + wanted = Max(wanted, XLOG_BLCKSZ);
> readLen is the valid prefix of the requested page, not the amount of
> available WAL from that page onwards. It is at most XLOG_BLCKSZ, so
> these bounds also leave streaming reads at one page regardless of
> io_combine_limit.
>
> More importantly, when only part of the page has arrived, pread() still
> reads the whole page and readAheadLen records the full result. Once
> more WAL arrives, XLogPageRead() can be called again for the same page
> with a larger reqLen. The new cache then hits and returns the old copy,
> including bytes that were not valid when it was filled.
>
> Should the cache track how many bytes were valid at the time of the
> read, and refill when reqLen exceeds that prefix? The available WAL
> distance from targetPagePtr to flushedUpto could separately bound the
> combined read size.
>
> Thanks!
>
>
> Best regards, Andrey Borodin.

In response to

Browse pgsql-hackers by date

  From Date Subject
Next Message Masahiko Sawada 2026-11-09 19:02:57 Re: Add a hook for handling logical decoding messages on subscribers.
Previous Message Robert Haas 2026-11-09 18:39:37 Re: Bypassing cursors in postgres_fdw to enable parallel plans