Hi Koshi,
On 02/10/2026 11:46, Koshi Shibagaki (Fujitsu) wrote:
I did some additional testing of v4 and found three issues when parallel
initialization is combined with range partitioning:
Thanks!
One possible approach would be to use a scheme similar to v3: create the parent
and child partitions in the t step, then have each worker TRUNCATE its assigned
partition and run COPY ... WITH (FREEZE) on it in the same transaction during
the g step.
Indeed, I went ahead and updated the patch following this approach. See
attached v5.
1. --tablespace is not applied to the standalone child partitions,
so they are created in the database's default tablespace.
2. Running -I G after -I t fails because the t step does not create the child
partitions.
3. Re-running -I g fails because createStandalonePartitions() attempts to
create child tables that already exist.
I reproduced issues 2 and 3 with the following commands, respectively:
```
pgbench -i -I t -j 2 --partitions=4 --partition-method=range postgres
pgbench -i -I G -j 2 --partitions=4 --partition-method=range postgres
```
```
pgbench -i -I t -j 2 --partitions=4 --partition-method=range postgres
pgbench -i -I g -j 2 --partitions=4 --partition-method=range postgres
pgbench -i -I g -j 2 --partitions=4 --partition-method=range postgres
I've tried the above commands with v5 and confirm they are now working.
--
Thanks,
Mircea Cadariu
From f5967c86add83554fdab405bc20c6c6bdd554a29 Mon Sep 17 00:00:00 2001
From: Mircea Cadariu <[email protected]>
Date: Sun, 3 May 2026 16:42:20 +0100
Subject: [PATCH v5] pgbench: parallelize account loading for range-partitioned
tables
In init mode with range partitioning, -j > 1 loads pgbench_accounts
in parallel, using at most one thread per partition. The partitions
are created as usual by the table creation step; each worker then
truncates its assigned partitions and populates them with COPY FREEZE
in its own transaction.
---
doc/src/sgml/ref/pgbench.sgml | 10 +
src/bin/pgbench/pgbench.c | 196 ++++++++++++++++++-
src/bin/pgbench/t/001_pgbench_with_server.pl | 30 +++
src/tools/pgindent/typedefs.list | 1 +
4 files changed, 227 insertions(+), 10 deletions(-)
diff --git a/doc/src/sgml/ref/pgbench.sgml b/doc/src/sgml/ref/pgbench.sgml
index 9688527c95..40ee510fd4 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,11 @@ 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,
+ at most one per partition.
+ </para>
</listitem>
</varlistentry>
diff --git a/src/bin/pgbench/pgbench.c b/src/bin/pgbench/pgbench.c
index 181f0d892e..3f3faa3901 100644
--- a/src/bin/pgbench/pgbench.c
+++ b/src/bin/pgbench/pgbench.c
@@ -852,6 +852,13 @@ static bool socket_has_input(socket_set *sa, int fd, int
idx);
/* callback used to build rows for COPY during data loading */
typedef void (*initRowMethod) (PQExpBufferData *sql, int64 curr);
+/* per-thread state for loading pgbench_accounts partitions in parallel */
+typedef struct PartitionWorker
+{
+ int thread_id;
+ bool use_freeze;
+} PartitionWorker;
+
/* callback functions for our flex lexer */
static const PsqlScanCallbacks pgbench_callbacks = {
NULL, /* don't need
get_variable functionality */
@@ -882,6 +889,28 @@ get_table_relkind(PGconn *con, const char *table)
return relkind;
}
+static bool
+accounts_referenced_by_fkey(PGconn *con)
+{
+ PGresult *res;
+ bool has_referencing_fkey;
+ const char *sql =
+ "SELECT 1 FROM pg_catalog.pg_constraint "
+ "WHERE contype = 'f' AND confrelid =
'pgbench_accounts'::pg_catalog.regclass";
+
+ res = PQexec(con, sql);
+ if (PQresultStatus(res) != PGRES_TUPLES_OK)
+ {
+ pg_log_error("query failed: %s", PQerrorMessage(con));
+ pg_log_error_detail("Query was: %s", sql);
+ exit(1);
+ }
+ has_referencing_fkey = PQntuples(res) > 0;
+ PQclear(res);
+
+ return has_referencing_fkey;
+}
+
static inline pg_time_usec_t
pg_time_now(void)
{
@@ -4829,6 +4858,16 @@ initDropTables(PGconn *con)
"pgbench_tellers");
}
+/*
+ * Number of rows in each range partition of "pgbench_accounts". With many
+ * partitions, the last ones may receive fewer rows, or none.
+ */
+static int64
+rangePartitionSize(void)
+{
+ return (naccounts * (int64) scale + partitions - 1) / partitions;
+}
+
/*
* Create "pgbench_accounts" partitions if needed.
*
@@ -4851,7 +4890,7 @@ createPartitions(PGconn *con)
{
if (partition_method == PART_RANGE)
{
- int64 part_size = (naccounts * (int64) scale
+ partitions - 1) / partitions;
+ int64 part_size = rangePartitionSize();
printfPQExpBuffer(&query,
"create%s table
pgbench_accounts_%d\n"
@@ -5154,20 +5193,140 @@ initPopulateTable(PGconn *con, const char *table,
int64 base,
termPQExpBuffer(&sql);
}
+static void
+initPopulatePartition(PGconn *con, int partno, bool use_freeze)
+{
+ /* Rows belonging to this partition, matching createPartitions() */
+ int64 part_size = rangePartitionSize();
+ int64 start_row = (partno - 1) * part_size;
+ int64 end_row = Min(partno * part_size, naccounts * (int64)
scale);
+ PGresult *res;
+ PQExpBufferData sql;
+ int64 rowno;
+
+ initPQExpBuffer(&sql);
+
+ /*
+ * COPY FREEZE requires the table to have been created or truncated in
the
+ * current transaction, so truncate the partition first.
+ */
+ if (use_freeze)
+ {
+ printfPQExpBuffer(&sql, "truncate table pgbench_accounts_%d",
partno);
+ executeStatement(con, sql.data);
+ }
+
+ printfPQExpBuffer(&sql, "copy pgbench_accounts_%d from stdin%s",
+ partno, use_freeze ? " with (freeze
on)" : "");
+
+ res = PQexec(con, sql.data);
+ if (PQresultStatus(res) != PGRES_COPY_IN)
+ pg_fatal("unexpected copy in result: %s", PQerrorMessage(con));
+ PQclear(res);
+
+ for (rowno = start_row; rowno < end_row; rowno++)
+ {
+ initAccount(&sql, rowno);
+ if (PQputCopyData(con, sql.data, sql.len) <= 0)
+ pg_fatal("PQputCopyData failed for partition %d",
partno);
+
+ /*
+ * The cancel handler can only cancel the main connection,
which is
+ * idle while the workers load, so abort the COPY from here.
+ */
+ if (CancelRequested)
+ break;
+ }
+
+ if (PQputCopyEnd(con, CancelRequested ? "canceled by user" : 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)
+{
+ PartitionWorker *worker = (PartitionWorker *) arg;
+ PGconn *con = doConnect();
+ int partno;
+
+ if (con == NULL)
+ pg_fatal("could not create connection for partition worker %d",
+ worker->thread_id);
+
+ executeStatement(con, "begin");
+
+ /* Each thread loads every nthreads-th partition, starting from its own
*/
+ for (partno = worker->thread_id + 1; partno <= partitions;
+ partno += nthreads)
+ initPopulatePartition(con, partno, worker->use_freeze);
+
+ executeStatement(con, "commit");
+
+ PQfinish(con);
+ THREAD_FUNC_RETURN;
+}
+
+static void
+initLoadAccountsParallel(bool use_freeze)
+{
+ THREAD_T *threads;
+ PartitionWorker *workers;
+ int i;
+
+ fprintf(stderr, "loading pgbench_accounts with %d threads...\n",
nthreads);
+
+ threads = pg_malloc_array(THREAD_T, nthreads);
+ workers = pg_malloc_array(PartitionWorker, nthreads);
+
+ for (i = 0; i < nthreads; i++)
+ {
+ workers[i].thread_id = i;
+ workers[i].use_freeze = use_freeze;
+
+ errno = THREAD_CREATE(&threads[i], initPartitionWorkerThread,
+ &workers[i]);
+ if (errno != 0)
+ pg_fatal("could not create thread: %m");
+ }
+
+ for (i = 0; i < nthreads; i++)
+ THREAD_JOIN(threads[i]);
+
+ pg_free(threads);
+ pg_free(workers);
+}
+
/*
* Fill the standard tables with some data generated and sent from the client.
*
* The filler column is NULL in pgbench_branches and pgbench_tellers, and is
* a blank-padded string in pgbench_accounts.
+ *
+ * tables_created tells whether the tables were created by this run's
+ * initialization steps.
*/
static void
-initGenerateDataClientSide(PGconn *con)
+initGenerateDataClientSide(PGconn *con, bool tables_created)
{
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. Each worker truncates its partitions again so it can
use
+ * COPY FREEZE, unless a foreign key references pgbench_accounts, which
+ * prevents truncating a single partition. Parallel loading is only
done
+ * when the tables were created in this run, so that the partitions are
+ * known to match --partitions and --scale.
*/
executeStatement(con, "begin");
@@ -5180,9 +5339,20 @@ 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 && tables_created)
+ {
+ bool use_freeze = PQserverVersion(con) >= 140000 &&
+ !accounts_referenced_by_fkey(con);
+
+ executeStatement(con, "commit");
+ initLoadAccountsParallel(use_freeze);
+ }
+ else
+ {
+ initPopulateTable(con, "pgbench_accounts", naccounts,
initAccount);
+ executeStatement(con, "commit");
+ }
}
/*
@@ -5340,6 +5510,7 @@ runInitSteps(const char *initialize_steps)
const char *step;
double run_time = 0.0;
bool first = true;
+ bool tables_created = false;
initPQExpBuffer(&stats);
@@ -5363,10 +5534,11 @@ runInitSteps(const char *initialize_steps)
case 't':
op = "create tables";
initCreateTables(con);
+ tables_created = true;
break;
case 'g':
op = "client-side generate";
- initGenerateDataClientSide(con);
+ initGenerateDataClientSide(con, tables_created);
break;
case 'G':
op = "server-side generate";
@@ -6953,7 +7125,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))
{
@@ -7183,9 +7354,10 @@ main(int argc, char **argv)
/*
* Don't need more threads than there are clients. (This is not merely
an
* optimization; throttle_delay is calculated incorrectly below if some
- * threads have no clients assigned to them.)
+ * threads have no clients assigned to them.) In init mode, -j instead
+ * sets the number of threads loading partitions, capped further below.
*/
- if (nthreads > nclients)
+ if (nthreads > nclients && !is_init_mode)
nthreads = nclients;
/*
@@ -7230,6 +7402,10 @@ main(int argc, char **argv)
if (partitions > 0 && partition_method == PART_NONE)
partition_method = PART_RANGE;
+ /* Cap the threads to partition count */
+ if (partitions > 0 && nthreads > partitions)
+ nthreads = partitions;
+
if (initialize_steps == NULL)
initialize_steps = pg_strdup(DEFAULT_INIT_STEPS);
diff --git a/src/bin/pgbench/t/001_pgbench_with_server.pl
b/src/bin/pgbench/t/001_pgbench_with_server.pl
index b669ad9d92..362c9199fa 100644
--- a/src/bin/pgbench/t/001_pgbench_with_server.pl
+++ b/src/bin/pgbench/t/001_pgbench_with_server.pl
@@ -37,6 +37,17 @@ sub check_data_state
is($sql_result, '0', "$type: pgbench_history has no data");
}
+# Check that all rows of pgbench_accounts were loaded exactly once.
+sub check_accounts_count
+{
+ local $Test::Builder::Level = $Test::Builder::Level + 1;
+ my ($node, $type) = @_;
+
+ my $sql_result = $node->safe_psql('postgres',
+ 'SELECT count(*), count(DISTINCT aid) FROM pgbench_accounts;');
+ is($sql_result, '100000|100000', "$type: pgbench_accounts fully
loaded");
+}
+
# start a pgbench specific server
my $node = PostgreSQL::Test::Cluster->new('main');
# Set to untranslated messages, to be able to compare program output with
@@ -164,6 +175,25 @@ $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.
+$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{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');
+check_accounts_count($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'
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 3bed561f7f..54922c5b2c 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2210,6 +2210,7 @@ PartitionSchemeData
PartitionSpec
PartitionStrategy
PartitionTupleRouting
+PartitionWorker
PartitionedRelPruneInfo
PartitionedRelPruningData
PartitionwiseAggregateType
--
2.39.5 (Apple Git-154)