Subject: Re: parallel data loading for pgbench -i

From: lakshmi <[email-masked]>
Date: Tue, 17 Feb 2026 11:41:13 +0530

Hi Mircea, Hayato,
I ran a few more tests on 19devel ,focusing on the partitioned case to
better understand the performance behavior.

For scale 500, the serial initialization on my system takes around 34.3
seconds. Using parallel initialization without partitions (-j 10) makes the
client-side data generation noticeably faster,But the overall runtime ends
up slightly higher because the vacuum phase becomes much longer.

However,when running with partitions(pgbench -i -s 500 --partitions=10 -j
10),the total runtime drops to about 21.9 seconds, and the vacuum cost is
much smaller.I also verified that the row counts are correct in all cases
,and regression tests still pass locally.

So it looks like the main benefit of parallel initialization shows up
clearly in the partitioned setup,which matches the expectations discussed
earlier.Just sharing these observations in case they are useful for the
ongoing review.
Thanks again for the work on this patch.

Best regards,
Lakshmi

On Wed, Feb 11, 2026 at 5:53 PM Hayato Kuroda (Fujitsu) <
[email-masked]> wrote:

> Dear Mircea,
>
> Thanks for the proposal. I also feel the initalization wastes time.
> Here are my initial comments.
>
> 01.
> I found that pgbench raises a FATAL in case of -j > --partitions, is there
> a
> specific reason?
> If needed, we may choose the softer way, which adjust nthreads up to the
> number
> of partitions. -c and -j do the similar one:
>
> ```
> if (nthreads > nclients && !is_init_mode)
> nthreads = nclients;
> ```
>
> 02.
> Also, why is -j accepted in case of non-partitions?
>
> 03.
> Can we port all validation to main()? I found initPopulateTableParallel()
> has
> such a part.
>
> 04.
> Copying seems to be divided into chunks per COPY_BATCH_SIZE. Is it really
> essential to parallelize the initialization? I feel it may optimize even
> serialized case thus can be discussed independently.
>
> 05.
> Per my understanding, each thread creates its tables, and all of them are
> attached to the parent table. Is it right? I think it needs more code
> changes, and I am not sure it is critical to make initialization faster.
>
> So I suggest using the incremental approach. The first patch only
> parallelizes
> the data load, and the second patch implements the CREATE TABLE and ALTER
> TABLE
> ATTACH PARTITION. You can benchmark three patterns, master, 0001, and
> 0001 + 0002, then compare the results. IIUC, this is the common approach to
> reduce the patch size and make them more reviewable.
>
> 06.
> Missing update for typedefs.list. WorkerTask and CopyTarget can be added
> there.
>
> 07.
> Since there is a report like [1], you can benchmark more cases.
>
> [1]:
> https://www.postgresql.org/message-id/CAEvyyTht69zjnosPjziW6dqNLqs-n6eKia2vof108zQp1QFX%3DQ%40mail.gmail.com
>
> Best regards,
> Hayato Kuroda
> FUJITSU LIMITED
>

---

From: "Hayato Kuroda (Fujitsu)" <[email-masked]>
Date: Fri, 20 Feb 2026 09:59:15 +0000

Dear Iakshmi,

Thanks for the measurement!

> For scale 500, the serial initialization on my system takes around 34.3 seconds.
> Using parallel initialization without partitions (-j 10) makes the client-side
> data generation noticeably faster,But the overall runtime ends up slightly
> higher because the vacuum phase becomes much longer.

To confirm, do you know the reason why the VACUUMing needs more time than serial case?

Best regards,
Hayato Kuroda
FUJITSU LIMITED

---

From: lakshmi <[email-masked]>
Date: Mon, 23 Feb 2026 17:42:49 +0530

On Fri, Feb 20, 2026 at 3:29 PM Hayato Kuroda (Fujitsu) <
[email-masked]> wrote:

> seconds.
> Dear Hayato,

Thank you for the question.

>From what I observed,in the non-partitioned parallel case the data
generation phase becomes much faster,but the VACUUM phase takes longer
compared to the serial run.

My current understanding is that this may be related to multiple workers
inserting into the same heap relation.That could potentially affect page
locality or increases the amount of freezing work required afterward.In
contrast,the partitioned case seems to benefit more clearly,likely because
each worker operates on a separate partition and COPY FREEZE reduces the
vacuum effort.

I have not yet done deeper internal analysis,so this is based on the
behavior I measured rather than detailed inspection.If needed,I can try to
collect additional statistics to better understand and difference.

please let me know if this reasoning aligns with your understanding.

Best regards
Lakshmi

---

From: lakshmi <[email-masked]>
Date: Wed, 18 Mar 2026 16:07:01 +0530

Hi Mircea, Hayato,

Thanks for the updated v2 patches.

I applied 0001 and 0002 on 19devel and ran some tests. The results look
consistent.

For scale 100, parallel loading speeds up data generation, but in the
non-partitioned case, the VACUUM phase becomes noticeably slower. In
contrast, the partitioned + parallel case performs best overall with much
lower vacuum cost.

For scale 500, I see the same pattern: non-partitioned parallel runs are
dominated by VACUUM time, while the partitioned setup shows a clear overall
speedup.

I also verified correctness, and row counts match expected values.

So overall, the benefit of parallel loading is much clearer in the
partitioned case.

I’ll try to look further into the VACUUM behavior.

Thanks again for the work on this.

Best regards,
Lakshmi

On Fri, Mar 13, 2026 at 11:59 PM Mircea Cadariu <[email-masked]>
wrote:

> Hi Lakshmi, Hayato,
>
>
> Thanks a lot for your input!
>
> I'm not sure why the VACUUM phase takes longer compared to the serial
> run. We can potentially get a clue with a profiler. I know there is an
> ongoing effort to introduce parallel heap vacuum [1] which I expect will
> help with this.
>
> The code comments you have provided me have been applied to the v2 patch
> attached. Below I provide answers to the questions.
>
> For non-partitioned tables, each worker loads a separate range of rows
> via its own connection in parallel.
>
> You're right that the COPY batching is an optimization that's
> independent. I wanted to see how fast I can get this patch, so I looked
> for bottlenecks in the new code with a profiler and this was one of
> them. I agree it makes sense to apply this for the serialised case
> separately.
>
> Yes, that's correct. Each worker creates its assigned partitions as
> standalone tables, loads data into them, and then the main thread
> attaches them all to the parent after loading completes. It's to avoid
> AccessExclusiveLock contention on the parent table during parallel
> loading and allow each worker to use COPY FREEZE on its standalone table.
>
> > the data load, and the second patch implements the CREATE TABLE and
> > ALTER TABLE
> Thanks for the recommendation, I extracted 0001 and 0002 as per your
> suggestion. I will see if I can split it more, as indeed it helps with
> the review.
>
> Results are similar with the previous runs.
>
> master
>
> pgbench -i -s 100 -j 10
> done in 20.95 s (drop tables 0.00 s, create tables 0.01 s, client-side
> generate 14.51 s, vacuum 0.27 s, primary keys 6.16 s).
>
> pgbench -i -s 100 -j 10 --partitions=10
> done in 29.73 s (drop tables 0.00 s, create tables 0.02 s, client-side
> generate 16.33 s, vacuum 8.72 s, primary keys 4.67 s).
>
>
> 0001
> pgbench -i -s 100 -j 10
> done in 18.75 s (drop tables 0.00 s, create tables 0.01 s, client-side
> generate 6.51 s, vacuum 5.73 s, primary keys 6.50 s).
>
> pgbench -i -s 100 -j 10 --partitions=10
> done in 29.33 s (drop tables 0.00 s, create tables 0.02 s, client-side
> generate 16.48 s, vacuum 7.59 s, primary keys 5.24 s).
>
> 0002
> pgbench -i -s 100 -j 10
> done in 18.12 s (drop tables 0.00 s, create tables 0.01 s, client-side
> generate 6.64 s, vacuum 5.81 s, primary keys 5.65 s).
>
> pgbench -i -s 100 -j 10 --partitions=10
> done in 14.38 s (drop tables 0.00 s, create tables 0.01 s, client-side
> generate 7.97 s, vacuum 1.55 s, primary keys 4.85 s).
>
>
> Looking forward to your feedback.
>
> [1]:
>
> https://www.postgresql.org/message-id/CAD21AoAEfCNv-GgaDheDJ%2Bs-p_Lv1H24AiJeNoPGCmZNSwL1YA%40mail.gmail.com
>
> --
> Thanks,
> Mircea Cadariu
>

---

From: Heikki Linnakangas <[email-masked]>
Date: Tue, 7 Apr 2026 12:00:28 +0300

> I’ll try to look further into the VACUUM behavior.

As discussed already, the slower VACUUM is surely because of the lack of 
COPY FREEZE option. That's unfortunate...

The way this patch uses the connections and workers is a little bonkers. 
The main thread uses the first connection to execute:

begin; TRUNCATE TABLE pgbench_accounts;

That connection is handed over to the first worker thread, and new 
connections are opened for the other workers. But thanks to the 
TRUNCATE, the open transaction on the first connection holds an 
AccessExclusiveLock, preventing the other workers from starting the COPY 
until the first worker has finished! I added some debugging prints to 
show this:

$ pgbench -s500 -i -j10 postgres
dropping old tables...
creating tables...
generating data (client-side)...
loading pgbench_accounts with 10 threads...
0.00: thread 0: sending COPY command, use_freeze: 1
0.00: thread 1: sending COPY command, use_freeze: 0
0.00: thread 2: sending COPY command, use_freeze: 0
0.00: thread 0: COPY started for rows between 0 and 5000000
0.00: thread 6: sending COPY command, use_freeze: 0
0.00: thread 3: sending COPY command, use_freeze: 0
0.00: thread 9: sending COPY command, use_freeze: 0
0.00: thread 4: sending COPY command, use_freeze: 0
0.00: thread 5: sending COPY command, use_freeze: 0
0.00: thread 7: sending COPY command, use_freeze: 0
0.00: thread 8: sending COPY command, use_freeze: 0
6.19: thread 0: COPY done!
6.27: thread 9: COPY started for rows between 45000000 and 50000000
6.27: thread 1: COPY started for rows between 5000000 and 10000000
6.27: thread 5: COPY started for rows between 25000000 and 30000000
6.27: thread 2: COPY started for rows between 10000000 and 15000000
6.27: thread 6: COPY started for rows between 30000000 and 35000000
6.27: thread 3: COPY started for rows between 15000000 and 20000000
6.27: thread 8: COPY started for rows between 40000000 and 45000000
6.27: thread 4: COPY started for rows between 20000000 and 25000000
6.27: thread 7: COPY started for rows between 35000000 and 40000000
19.19: thread 1: COPY done!
19.21: thread 9: COPY done!
19.26: thread 6: COPY done!
19.27: thread 7: COPY done!
19.28: thread 3: COPY done!
19.28: thread 5: COPY done!
19.28: thread 4: COPY done!
19.29: thread 8: COPY done!
19.36: thread 2: COPY done!
vacuuming...
creating primary keys...
done in 71.58 s (drop tables 0.07 s, create tables 0.01 s, client-side 
generate 19.41 s, vacuum 26.50 s, primary keys 25.59 s).

The straightforward fix is to commit the TRUNCATE transaction, and not 
use FREEZE on any of the COPY commands.

This all makes more sense in the partitioned case. Perhaps we should 
parallelize only when partitioned are used, and use only one thread per 
partition.

- Heikki

---

From: Mircea Cadariu <[email-masked]>
Date: Fri, 10 Apr 2026 19:37:09 +0100

Hi,

On 07/04/2026 10:00, Heikki Linnakangas wrote:
> parallelize only when partitioned are used, and use only one thread 
> per partition.
>
Thanks for having a look. I attached v3 that parallelizes only the 
partitioned case, one thread per partition. Results:

patch:

pgbench -i -s 100 --partitions 10

done in 12.63 s (drop tables 0.05 s, create tables 0.01 s, client-side 
generate 5.98 s, vacuum 1.63 s, primary keys 4.96 s).

master:

pgbench -i -s 100 --partitions 10

done in 29.29 s (drop tables 0.00 s, create tables 0.02 s, client-side 
generate 16.31 s, vacuum 7.78 s, primary keys 5.18 s).

-- 
Thanks,
Mircea Cadariu
From dd4f3e2d7dbae6b008157f4928287056fd0a82b9 Mon Sep 17 00:00:00 2001
From: Mircea Cadariu <[email-masked]>
Date: Wed, 8 Apr 2026 15:35:31 +0100
Subject: [PATCH] pgbench: parallelize account loading for range-partitioned
 tables

When initializing with range partitioning, spawn one worker thread per
partition to load pgbench_accounts in parallel.  Each worker opens its
own connection, truncates its partition within a transaction, and loads
its rows using COPY FREEZE, which avoids a separate freeze pass during
the subsequent vacuum step.

Non-partitioned and hash-partitioned tables are unaffected and continue
to use serial loading.
---
 src/bin/pgbench/pgbench.c                    | 120 ++++++++++++++++++-
 src/bin/pgbench/t/001_pgbench_with_server.pl |  18 +++
 2 files changed, 134 insertions(+), 4 deletions(-)

diff --git a/src/bin/pgbench/pgbench.c b/src/bin/pgbench/pgbench.c
index 1dae918cc0..f537d46393 100644
--- a/src/bin/pgbench/pgbench.c
+++ b/src/bin/pgbench/pgbench.c
@@ -5143,6 +5143,106 @@ initPopulateTable(PGconn *con, const char *table, int64 base,
 	termPQExpBuffer(&sql);
 }
 
+static void
+initPopulatePartition(PGconn *con, int partno)
+{
+	int64		total_rows = (int64) naccounts * scale;
+	int64		part_size = (total_rows + partitions - 1) / partitions;
+	int64		start_row = (int64) (partno - 1) * part_size;
+	int64		end_row = (partno == partitions) ? total_rows : (int64) partno * part_size;
+	char		table_name[NAMEDATALEN];
+	char		truncate_stmt[256];
+	char		copy_stmt[256];
+	int			n;
+	PGresult   *res;
+	PQExpBufferData sql;
+	int64		row;
+
+	snprintf(table_name, sizeof(table_name), "pgbench_accounts_%d", partno);
+	snprintf(truncate_stmt, sizeof(truncate_stmt), "truncate %s", table_name);
+
+	if (PQserverVersion(con) >= 140000)
+		n = pg_snprintf(copy_stmt, sizeof(copy_stmt),
+						"copy %s from stdin with (freeze on)", table_name);
+	else
+		n = pg_snprintf(copy_stmt, sizeof(copy_stmt),
+						"copy %s from stdin", table_name);
+
+	if (n >= sizeof(copy_stmt))
+		pg_fatal("invalid buffer size: must be at least %d characters long", n);
+
+	executeStatement(con, truncate_stmt);
+
+	res = PQexec(con, copy_stmt);
+	if (PQresultStatus(res) != PGRES_COPY_IN)
+		pg_fatal("could not start COPY for partition %d: %s",
+				 partno, PQerrorMessage(con));
+	PQclear(res);
+
+	initPQExpBuffer(&sql);
+
+	for (row = start_row; row < end_row; row++)
+	{
+		initAccount(&sql, row);
+		if (PQputCopyData(con, sql.data, sql.len) <= 0)
+			pg_fatal("PQputCopyData failed for partition %d", partno);
+	}
+
+	if (PQputCopyEnd(con, NULL) <= 0)
+		pg_fatal("PQputCopyEnd failed for partition %d", partno);
+
+	res = PQgetResult(con);
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("COPY failed for partition %d: %s", partno, PQerrorMessage(con));
+	PQclear(res);
+
+	termPQExpBuffer(&sql);
+}
+
+static THREAD_FUNC_RETURN_TYPE THREAD_FUNC_CC
+initPartitionWorkerThread(void *arg)
+{
+	int			partno = *(int *) arg;
+	PGconn	   *con = doConnect();
+
+	if (con == NULL)
+		pg_fatal("could not create connection for partition worker %d", partno);
+
+	executeStatement(con, "begin");
+	initPopulatePartition(con, partno);
+	executeStatement(con, "commit");
+
+	PQfinish(con);
+	THREAD_FUNC_RETURN;
+}
+
+static void
+initLoadAccountsParallel(void)
+{
+	THREAD_T   *threads;
+	int		   *partno;
+	int			i;
+
+	fprintf(stderr, "loading pgbench_accounts with %d threads...\n", partitions);
+
+	threads = pg_malloc_array(THREAD_T, partitions);
+	partno = pg_malloc_array(int, partitions);
+
+	for (i = 0; i < partitions; i++)
+	{
+		partno[i] = i + 1;
+		errno = THREAD_CREATE(&threads[i], initPartitionWorkerThread, &partno[i]);
+		if (errno != 0)
+			pg_fatal("could not create thread for partition %d: %m", i + 1);
+	}
+
+	for (i = 0; i < partitions; i++)
+		THREAD_JOIN(threads[i]);
+
+	free(threads);
+	free(partno);
+}
+
 /*
  * Fill the standard tables with some data generated and sent from the client.
  *
@@ -5155,8 +5255,11 @@ initGenerateDataClientSide(PGconn *con)
 	fprintf(stderr, "generating data (client-side)...\n");
 
 	/*
-	 * we do all of this in one transaction to enable the backend's
-	 * data-loading optimizations
+	 * For the non-partitioned and hash-partitioned cases, do everything in
+	 * one transaction to enable the backend's data-loading optimizations. For
+	 * range-partitioned tables, branches and tellers are loaded in one
+	 * transaction, then accounts are loaded in parallel with one thread per
+	 * partition, each in its own transaction.
 	 */
 	executeStatement(con, "begin");
 
@@ -5169,9 +5272,18 @@ initGenerateDataClientSide(PGconn *con)
 	 */
 	initPopulateTable(con, "pgbench_branches", nbranches, initBranch);
 	initPopulateTable(con, "pgbench_tellers", ntellers, initTeller);
-	initPopulateTable(con, "pgbench_accounts", naccounts, initAccount);
 
-	executeStatement(con, "commit");
+	if (partition_method == PART_RANGE)
+	{
+		executeStatement(con, "commit");
+		initLoadAccountsParallel();
+	}
+	else
+	{
+		/* hash partitioning and non-partitioned tables use serial loading */
+		initPopulateTable(con, "pgbench_accounts", naccounts, initAccount);
+		executeStatement(con, "commit");
+	}
 }
 
 /*
diff --git a/src/bin/pgbench/t/001_pgbench_with_server.pl b/src/bin/pgbench/t/001_pgbench_with_server.pl
index b7685ea5d2..b59c181c2a 100644
--- a/src/bin/pgbench/t/001_pgbench_with_server.pl
+++ b/src/bin/pgbench/t/001_pgbench_with_server.pl
@@ -164,6 +164,24 @@ $node->pgbench(
 # Check data state, after server-side data generation.
 check_data_state($node, 'server-side');
 
+# Test parallel initialization with range partitions (client-side generation).
+# One thread per partition is spawned automatically.
+$node->pgbench(
+	'-i -s 1 --partitions=4 --partition-method=range',
+	0,
+	[qr{^$}],
+	[
+		qr{creating tables},
+		qr{creating 4 partitions},
+		qr{loading pgbench_accounts with 4 threads},
+		qr{vacuuming},
+		qr{creating primary keys},
+		qr{done in \d+\.\d\d s }
+	],
+	'pgbench parallel initialization with range partitions');
+
+check_data_state($node, 'parallel-range-partitions');
+
 # Run all builtin scripts, for a few transactions each
 $node->pgbench(
 	'--transactions=5 -Dfoo=bla --client=2 --protocol=simple --builtin=t'
-- 
2.39.5 (Apple Git-154)

---

From: lakshmi <[email-masked]>
Date: Mon, 13 Apr 2026 11:44:18 +0530

Hi Mircea, Heikki,

I tested the v3 patch on 19devel with larger scale factors.

The behavior looks much better now compared to the earlier versions. For
scale 100 and 500, I see clear improvements in overall runtime, and for
scale 2000, the total time is around 97s on my system.

The loading phase now runs concurrently across workers, and I don’t see the
earlier serialization behavior anymore.

The VACUUM phase also remains relatively small (~6s for scale 2000), which
suggests that the previous overhead has been addressed.

I also verified correctness, and the row counts match the expected values.

Overall, the partitioned parallel approach looks solid and scales well in
my tests.

Best regards,
Lakshmi

---

From: "Hayato Kuroda (Fujitsu)" <[email-masked]>
Date: Mon, 13 Apr 2026 07:23:39 +0000

Dear Mircea,

Thanks for updating the patch. Now each worker looks like not to create each
child tables, just run TRUNCATE and COPY. But I'm unclear why the TRUNCATE is
needed here. Isn't they truncated in initGenerateDataClientSide()->initTruncateTables()
before launching threads?
Also, the current API is questionable. E.g., we cannot work in series if --partition is
specified. And I'm afraid OOM failure may be more likely to happen if the table has
many partitions.
Is it possible that we can have -p again for the initialization? We can require
partitions >= nthreads or partitions % nthreads == 0 at that time.

Best regards,
Hayato Kuroda
FUJITSU LIMITED

---

From: lakshmi <[email-masked]>
Date: Mon, 13 Apr 2026 17:21:06 +0530

Hi Hayato,

Thanks for your feedback.

I tried a few runs with different partition counts. From what I saw,
performance doesn’t always improve with more partitions—in fact, higher
partition counts increase VACUUM time and slow things down.

I also agree that having control over the number of workers (like using -j)
would help balance this better.

Regarding TRUNCATE, I noticed it’s already done earlier, so it might be
worth checking if the extra TRUNCATE is needed.

I didn’t see memory issues in my tests, but I understand it could become a
concern with many partitions.

Thanks again for the suggestions.

Best regards,
Lakshmi

On Mon, Apr 13, 2026 at 12:53 PM Hayato Kuroda (Fujitsu) <
[email-masked]> wrote:

> needed here. Isn't they truncated in
> initGenerateDataClientSide()->initTruncateTables()

---

From: Mircea Cadariu <[email-masked]>
Date: Fri, 8 May 2026 19:11:46 +0100

Hi Lakshmi and Hayato,

Thanks a lot for your feedback.

Attached for your consideration is v4, in which I address your remarks.

-- 
Thanks,
Mircea Cadariu
From 6099f30cf78b0ed8608670ff07b8a71b8cf0d47c Mon Sep 17 00:00:00 2001
From: Mircea Cadariu <[email-masked]>
Date: Sun, 3 May 2026 16:42:20 +0100
Subject: [PATCH v4] pgbench: parallelize account loading for range-partitioned
 tables

In init mode with range partitioning, -j > 1 loads pgbench_accounts
in parallel.  Each worker creates its assigned partitions as
standalone tables, populates them with COPY FREEZE, and the main
connection attaches them afterwards.
---
 doc/src/sgml/ref/pgbench.sgml                |   9 +
 src/bin/pgbench/pgbench.c                    | 258 +++++++++++++++++--
 src/bin/pgbench/t/001_pgbench_with_server.pl |  29 +++
 3 files changed, 269 insertions(+), 27 deletions(-)

diff --git a/doc/src/sgml/ref/pgbench.sgml b/doc/src/sgml/ref/pgbench.sgml
index 2e401d1ceb..3594b731cc 100644
--- a/doc/src/sgml/ref/pgbench.sgml
+++ b/doc/src/sgml/ref/pgbench.sgml
@@ -382,6 +382,11 @@ pgbench <optional> <replaceable>options</replaceable> </optional> <replaceable>d
         the scaled number of accounts.
         Default is <literal>0</literal>, meaning no partitioning.
        </para>
+       <para>
+        With <option>-j</option> greater than 1 and
+        <option>--partition-method=range</option>, partitions are
+        loaded in parallel.
+       </para>
       </listitem>
      </varlistentry>
 
@@ -502,6 +507,10 @@ pgbench <optional> <replaceable>options</replaceable> </optional> <replaceable>d
         Clients are distributed as evenly as possible among available threads.
         Default is 1.
        </para>
+       <para>
+        In initialization mode (<option>-i</option>), <option>-j</option>
+        sets the number of threads used to load partitions in parallel.
+       </para>
       </listitem>
      </varlistentry>
 
diff --git a/src/bin/pgbench/pgbench.c b/src/bin/pgbench/pgbench.c
index 1dae918cc0..aa21b653ce 100644
--- a/src/bin/pgbench/pgbench.c
+++ b/src/bin/pgbench/pgbench.c
@@ -4817,6 +4817,34 @@ initDropTables(PGconn *con)
 					 "pgbench_tellers");
 }
 
+static void
+appendAccountsRangeForValues(PQExpBufferData *query, int p)
+{
+	int64		part_size = (naccounts * (int64) scale + partitions - 1) / partitions;
+
+	appendPQExpBufferStr(query, " for values from (");
+	if (p == 1)
+		appendPQExpBufferStr(query, "minvalue");
+	else
+		appendPQExpBuffer(query, INT64_FORMAT, (p - 1) * part_size + 1);
+	appendPQExpBufferStr(query, ") to (");
+	if (p < partitions)
+		appendPQExpBuffer(query, INT64_FORMAT, p * part_size + 1);
+	else
+		appendPQExpBufferStr(query, "maxvalue");
+	appendPQExpBufferChar(query, ')');
+}
+
+static void
+getAccountsPartitionRows(int p, int64 *start_row, int64 *end_row)
+{
+	int64		total_rows = (int64) naccounts * scale;
+	int64		part_size = (total_rows + partitions - 1) / partitions;
+
+	*start_row = (int64) (p - 1) * part_size;
+	*end_row = (p == partitions) ? total_rows : (int64) p * part_size;
+}
+
 /*
  * Create "pgbench_accounts" partitions if needed.
  *
@@ -4839,33 +4867,17 @@ createPartitions(PGconn *con)
 	{
 		if (partition_method == PART_RANGE)
 		{
-			int64		part_size = (naccounts * (int64) scale + partitions - 1) / partitions;
-
-			printfPQExpBuffer(&query,
-							  "create%s table pgbench_accounts_%d\n"
-							  "  partition of pgbench_accounts\n"
-							  "  for values from (",
-							  unlogged_tables ? " unlogged" : "", p);
-
 			/*
 			 * For RANGE, we use open-ended partitions at the beginning and
 			 * end to allow any valid value for the primary key.  Although the
 			 * actual minimum and maximum values can be derived from the
 			 * scale, it is more generic and the performance is better.
 			 */
-			if (p == 1)
-				appendPQExpBufferStr(&query, "minvalue");
-			else
-				appendPQExpBuffer(&query, INT64_FORMAT, (p - 1) * part_size + 1);
-
-			appendPQExpBufferStr(&query, ") to (");
-
-			if (p < partitions)
-				appendPQExpBuffer(&query, INT64_FORMAT, p * part_size + 1);
-			else
-				appendPQExpBufferStr(&query, "maxvalue");
-
-			appendPQExpBufferChar(&query, ')');
+			printfPQExpBuffer(&query,
+							  "create%s table pgbench_accounts_%d\n"
+							  "  partition of pgbench_accounts",
+							  unlogged_tables ? " unlogged" : "", p);
+			appendAccountsRangeForValues(&query, p);
 		}
 		else if (partition_method == PART_HASH)
 			printfPQExpBuffer(&query,
@@ -4889,6 +4901,62 @@ createPartitions(PGconn *con)
 	termPQExpBuffer(&query);
 }
 
+static void
+createStandalonePartitions(PGconn *con, int part_start, int part_end)
+{
+	PQExpBufferData query;
+	const char *aid_type = (scale >= SCALE_32BIT_THRESHOLD) ? "bigint" : "int";
+
+	Assert(partitions > 0);
+	Assert(partition_method == PART_RANGE);
+
+	initPQExpBuffer(&query);
+
+	for (int p = part_start; p <= part_end; p++)
+	{
+		printfPQExpBuffer(&query,
+						  "create%s table pgbench_accounts_%d\n"
+						  "  (aid %s not null,\n"
+						  "   bid int,\n"
+						  "   abalance int,\n"
+						  "   filler character(84))\n"
+						  "  with (fillfactor=%d)",
+						  unlogged_tables ? " unlogged" : "", p,
+						  aid_type, fillfactor);
+
+		executeStatement(con, query.data);
+	}
+
+	termPQExpBuffer(&query);
+}
+
+static void
+attachStandalonePartitions(PGconn *con)
+{
+	PQExpBufferData query;
+
+	Assert(partitions > 0);
+	Assert(partition_method == PART_RANGE);
+
+	initPQExpBuffer(&query);
+
+	executeStatement(con, "begin");
+
+	for (int p = 1; p <= partitions; p++)
+	{
+		printfPQExpBuffer(&query,
+						  "alter table pgbench_accounts\n"
+						  "  attach partition pgbench_accounts_%d",
+						  p);
+		appendAccountsRangeForValues(&query, p);
+		executeStatement(con, query.data);
+	}
+
+	executeStatement(con, "commit");
+
+	termPQExpBuffer(&query);
+}
+
 /*
  * Create pgbench's standard tables
  */
@@ -4981,7 +5049,17 @@ initCreateTables(PGconn *con)
 	termPQExpBuffer(&query);
 
 	if (partition_method != PART_NONE)
+	{
+		/*
+		 * In the parallel range-partitioned case, partitions are created by
+		 * the worker threads (so each one can use COPY FREEZE in its own
+		 * transaction) and attached afterwards.
+		 */
+		if (partition_method == PART_RANGE && nthreads > 1)
+			return;
+
 		createPartitions(con);
+	}
 }
 
 /*
@@ -5143,6 +5221,121 @@ initPopulateTable(PGconn *con, const char *table, int64 base,
 	termPQExpBuffer(&sql);
 }
 
+static void
+initPopulatePartition(PGconn *con, int partno)
+{
+	int64		start_row;
+	int64		end_row;
+	char		copy_stmt[256];
+	PGresult   *res;
+	PQExpBufferData sql;
+	int64		row;
+
+	getAccountsPartitionRows(partno, &start_row, &end_row);
+
+	snprintf(copy_stmt, sizeof(copy_stmt),
+			 PQserverVersion(con) >= 140000 ?
+			 "copy pgbench_accounts_%d from stdin with (freeze on)" :
+			 "copy pgbench_accounts_%d from stdin",
+			 partno);
+
+	res = PQexec(con, copy_stmt);
+	if (PQresultStatus(res) != PGRES_COPY_IN)
+		pg_fatal("could not start COPY for partition %d: %s",
+				 partno, PQerrorMessage(con));
+	PQclear(res);
+
+	initPQExpBuffer(&sql);
+
+	for (row = start_row; row < end_row; row++)
+	{
+		initAccount(&sql, row);
+		if (PQputCopyData(con, sql.data, sql.len) <= 0)
+			pg_fatal("PQputCopyData failed for partition %d", partno);
+	}
+
+	if (PQputCopyEnd(con, NULL) <= 0)
+		pg_fatal("PQputCopyEnd failed for partition %d", partno);
+
+	res = PQgetResult(con);
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("COPY failed for partition %d: %s", partno, PQerrorMessage(con));
+	PQclear(res);
+
+	termPQExpBuffer(&sql);
+}
+
+typedef struct PartitionWorkerData
+{
+	int			thread_id;
+	int			part_start;
+	int			part_end;
+}			PartitionWorkerData;
+
+static THREAD_FUNC_RETURN_TYPE THREAD_FUNC_CC
+initPartitionWorkerThread(void *arg)
+{
+	PartitionWorkerData *data = (PartitionWorkerData *) arg;
+	PGconn	   *con = doConnect();
+	int			p;
+
+	if (con == NULL)
+		pg_fatal("could not create connection for partition worker (parts %d-%d)",
+				 data->part_start, data->part_end);
+
+	executeStatement(con, "begin");
+	createStandalonePartitions(con, data->part_start, data->part_end);
+	for (p = data->part_start; p <= data->part_end; p++)
+	{
+		pg_time_usec_t start = pg_time_now();
+
+		initPopulatePartition(con, p);
+		fprintf(stderr, "partition %d loaded by thread %d (in %.2f s)\n",
+				p, data->thread_id,
+				PG_TIME_GET_DOUBLE(pg_time_now() - start));
+	}
+	executeStatement(con, "commit");
+
+	PQfinish(con);
+	THREAD_FUNC_RETURN;
+}
+
+static void
+initLoadAccountsParallel(void)
+{
+	THREAD_T   *threads;
+	PartitionWorkerData *data;
+	int			parts_per_worker = partitions / nthreads;
+	int			extra_parts = partitions % nthreads;
+	int			next_part = 1;
+	int			i;
+
+	fprintf(stderr, "creating %d partitions...\n", partitions);
+	fprintf(stderr, "loading pgbench_accounts with %d threads...\n", nthreads);
+
+	threads = pg_malloc_array(THREAD_T, nthreads);
+	data = pg_malloc_array(PartitionWorkerData, nthreads);
+
+	for (i = 0; i < nthreads; i++)
+	{
+		data[i].thread_id = i;
+		data[i].part_start = next_part;
+		data[i].part_end = next_part + parts_per_worker - 1 +
+			(i < extra_parts ? 1 : 0);
+		next_part = data[i].part_end + 1;
+
+		errno = THREAD_CREATE(&threads[i], initPartitionWorkerThread, &data[i]);
+		if (errno != 0)
+			pg_fatal("could not create thread for worker %d: %m", i);
+	}
+
+	for (i = 0; i < nthreads; i++)
+		THREAD_JOIN(threads[i]);
+
+	free(threads);
+	free(data);
+}
+
 /*
  * Fill the standard tables with some data generated and sent from the client.
  *
@@ -5155,8 +5348,11 @@ initGenerateDataClientSide(PGconn *con)
 	fprintf(stderr, "generating data (client-side)...\n");
 
 	/*
-	 * we do all of this in one transaction to enable the backend's
-	 * data-loading optimizations
+	 * For serial loading, do everything in one transaction to enable the
+	 * backend's data-loading optimizations.  For parallel loading
+	 * (range-partitioned, -j > 1), load branches and tellers in one
+	 * transaction, then load accounts in parallel with each worker in its own
+	 * transaction.
 	 */
 	executeStatement(con, "begin");
 
@@ -5169,9 +5365,18 @@ initGenerateDataClientSide(PGconn *con)
 	 */
 	initPopulateTable(con, "pgbench_branches", nbranches, initBranch);
 	initPopulateTable(con, "pgbench_tellers", ntellers, initTeller);
-	initPopulateTable(con, "pgbench_accounts", naccounts, initAccount);
 
-	executeStatement(con, "commit");
+	if (partition_method == PART_RANGE && nthreads > 1)
+	{
+		executeStatement(con, "commit");
+		initLoadAccountsParallel();
+		attachStandalonePartitions(con);
+	}
+	else
+	{
+		initPopulateTable(con, "pgbench_accounts", naccounts, initAccount);
+		executeStatement(con, "commit");
+	}
 }
 
 /*
@@ -6944,7 +7149,6 @@ main(int argc, char **argv)
 				initialization_option_set = true;
 				break;
 			case 'j':			/* jobs */
-				benchmarking_option_set = true;
 				if (!option_parse_int(optarg, "-j/--jobs", 1, INT_MAX,
 									  &nthreads))
 				{
@@ -7176,7 +7380,7 @@ main(int argc, char **argv)
 	 * optimization; throttle_delay is calculated incorrectly below if some
 	 * threads have no clients assigned to them.)
 	 */
-	if (nthreads > nclients)
+	if (nthreads > nclients && !is_init_mode)
 		nthreads = nclients;
 
 	/*
diff --git a/src/bin/pgbench/t/001_pgbench_with_server.pl b/src/bin/pgbench/t/001_pgbench_with_server.pl
index b7685ea5d2..29ee28d616 100644
--- a/src/bin/pgbench/t/001_pgbench_with_server.pl
+++ b/src/bin/pgbench/t/001_pgbench_with_server.pl
@@ -164,6 +164,35 @@ $node->pgbench(
 # Check data state, after server-side data generation.
 check_data_state($node, 'server-side');
 
+# Test parallel initialization with range partitions (client-side generation).
+# Use -j to control the number of worker threads; partitions must be >= -j.
+$node->pgbench(
+	'-i -j 2 -s 1 --partitions=4 --partition-method=range',
+	0,
+	[qr{^$}],
+	[
+		qr{creating tables},
+		qr{creating 4 partitions},
+		qr{loading pgbench_accounts with 2 threads},
+		qr{partition \d loaded by thread \d \(in \d+\.\d\d s\)},
+		qr{vacuuming},
+		qr{creating primary keys},
+		qr{done in \d+\.\d\d s }
+	],
+	'pgbench parallel initialization with range partitions');
+
+check_data_state($node, 'parallel-range-partitions');
+
+# Uneven distribution: 5 partitions across 2 threads (3 + 2).
+$node->pgbench(
+	'-i -j 2 -s 1 --partitions=5 --partition-method=range',
+	0,
+	[qr{^$}],
+	[ qr{loading pgbench_accounts with 2 threads}, qr{done in \d+\.\d\d s } ],
+	'pgbench parallel init with uneven partition distribution');
+
+check_data_state($node, 'parallel-range-uneven');
+
 # Run all builtin scripts, for a few transactions each
 $node->pgbench(
 	'--transactions=5 -Dfoo=bla --client=2 --protocol=simple --builtin=t'
-- 
2.39.5 (Apple Git-154)

---

From: lakshmi <[email-masked]>
Date: Sat, 9 May 2026 13:32:44 +0530

Hi Mircea, Hayato,

I tested the v4 patch on 19devel with a few different thread/partition
combinations.

The updated API looks much better now. I verified that:

   - parallel loading works correctly with -j
   - uneven partition distribution (for example 5 partitions with 2
   threads) also works fine
   - serial mode with -j 1 works again as expected

The workers appear to run concurrently, and VACUUM time remains relatively
small in my tests.

Overall, the new approach looks much cleaner and more flexible compared to
the earlier versions.

Thanks again for the update.

Best regards,
Lakshmi