Re: Relation bulk write facility

Andres Freund <andres@anarazel.de>

From: Andres Freund <andres@anarazel.de>
To: Heikki Linnakangas <hlinnaka@iki.fi>
Cc: pgsql-hackers <pgsql-hackers@postgresql.org>
Date: 2023-11-19T00:04:16Z
Lists: pgsql-hackers

Commits

Same data as JSON: GET /api/v1/messages/:b64id/commits the thread's linked commits as JSON, with link sources. API reference →
  1. Relax fsyncing at end of a bulk load that was not WAL-logged

  2. Fix cross-version upgrade tests after f0827b443.

  3. Remove AIX support

  4. Fix compiler warning on typedef redeclaration

  5. Introduce a new smgr bulk loading facility.

Hi,

On 2023-11-17 11:37:21 +0100, Heikki Linnakangas wrote:
> The new facility makes it easier to optimize bulk loading, as the
> logic for buffering, WAL-logging, and syncing the relation only needs
> to be implemented once. It's also less error-prone: We have had a
> number of bugs in how a relation is fsync'd - or not - at the end of a
> bulk loading operation. By centralizing that logic to one place, we
> only need to write it correctly once.

One thing I'd like to use the centralized handling for is to track such
writes in pg_stat_io. I don't mean as part of the initial patch, just that
that's another reason I like the facility.


> The new facility is faster for small relations: Instead of of calling
> smgrimmedsync(), we register the fsync to happen at next checkpoint,
> which avoids the fsync latency. That can make a big difference if you
> are e.g. restoring a schema-only dump with lots of relations.

I think this would be a huge win for people running their application tests
against postgres.


> +	bulkw = bulkw_start_smgr(dst, forkNum, use_wal);
> +
>  	nblocks = smgrnblocks(src, forkNum);
>  
>  	for (blkno = 0; blkno < nblocks; blkno++)
>  	{
> +		Page		page;
> +
>  		/* If we got a cancel signal during the copy of the data, quit */
>  		CHECK_FOR_INTERRUPTS();
>  
> -		smgrread(src, forkNum, blkno, buf.data);
> +		page = bulkw_alloc_buf(bulkw);
> +		smgrread(src, forkNum, blkno, page);
>  
>  		if (!PageIsVerifiedExtended(page, blkno,
>  									PIV_LOG_WARNING | PIV_REPORT_STAT))
> @@ -511,30 +514,9 @@ RelationCopyStorage(SMgrRelation src, SMgrRelation dst,
>  		 * page this is, so we have to log the full page including any unused
>  		 * space.
>  		 */
> -		if (use_wal)
> -			log_newpage(&dst->smgr_rlocator.locator, forkNum, blkno, page, false);
> -
> -		PageSetChecksumInplace(page, blkno);
> -
> -		/*
> -		 * Now write the page.  We say skipFsync = true because there's no
> -		 * need for smgr to schedule an fsync for this write; we'll do it
> -		 * ourselves below.
> -		 */
> -		smgrextend(dst, forkNum, blkno, buf.data, true);
> +		bulkw_write(bulkw, blkno, page, false);

I wonder if bulkw_alloc_buf() is a good name - if you naively read this
change, it looks like it'll just leak memory. It also might be taken to be
valid until freed, which I don't think is the case?


> +/*-------------------------------------------------------------------------
> + *
> + * bulk_write.c
> + *	  Efficiently and reliably populate a new relation
> + *
> + * The assumption is that no other backends access the relation while we are
> + * loading it, so we can take some shortcuts.  Alternatively, you can use the
> + * buffer manager as usual, if performance is not critical, but you must not
> + * mix operations through the buffer manager and the bulk loading interface at
> + * the same time.

From "Alternatively" onward this is is somewhat confusing.


> + * We bypass the buffer manager to avoid the locking overhead, and call
> + * smgrextend() directly.  A downside is that the pages will need to be
> + * re-read into shared buffers on first use after the build finishes.  That's
> + * usually a good tradeoff for large relations, and for small relations, the
> + * overhead isn't very significant compared to creating the relation in the
> + * first place.

FWIW, I doubt the "isn't very significant" bit is really true.


> + * One tricky point is that because we bypass the buffer manager, we need to
> + * register the relation for fsyncing at the next checkpoint ourselves, and
> + * make sure that the relation is correctly fsync by us or the checkpointer
> + * even if a checkpoint happens concurrently.

"fsync'ed" or such? Otherwise this reads awkwardly for me.



> + *
> + *
> + * Portions Copyright (c) 1996-2023, PostgreSQL Global Development Group
> + * Portions Copyright (c) 1994, Regents of the University of California
> + *
> + *
> + * IDENTIFICATION
> + *	  src/backend/storage/smgr/bulk_write.c
> + *
> + *-------------------------------------------------------------------------
> + */
> +#include "postgres.h"
> +
> +#include "access/xloginsert.h"
> +#include "access/xlogrecord.h"
> +#include "storage/bufmgr.h"
> +#include "storage/bufpage.h"
> +#include "storage/bulk_write.h"
> +#include "storage/proc.h"
> +#include "storage/smgr.h"
> +#include "utils/rel.h"
> +
> +#define MAX_BUFFERED_PAGES XLR_MAX_BLOCK_ID
> +
> +typedef struct BulkWriteBuffer
> +{
> +	Page		page;
> +	BlockNumber blkno;
> +	bool		page_std;
> +	int16		order;
> +} BulkWriteBuffer;
> +

The name makes it sound like this struct itself contains a buffer - but it's
just pointing to one. *BufferRef or such maybe?

I was wondering how you dealt with the alignment of buffers given the struct
definition, which is what lead me to look at this...


> +/*
> + * Bulk writer state for one relation fork.
> + */
> +typedef struct BulkWriteState
> +{
> +	/* Information about the target relation we're writing */
> +	SMgrRelation smgr;

Isn't there a danger of this becoming a dangling pointer? At least until
https://postgr.es/m/CA%2BhUKGJ8NTvqLHz6dqbQnt2c8XCki4r2QvXjBQcXpVwxTY_pvA%40mail.gmail.com
is merged?


> +	ForkNumber	forknum;
> +	bool		use_wal;
> +
> +	/* We keep several pages buffered, and WAL-log them in batches */
> +	int			nbuffered;
> +	BulkWriteBuffer buffers[MAX_BUFFERED_PAGES];
> +
> +	/* Current size of the relation */
> +	BlockNumber pages_written;
> +
> +	/* The RedoRecPtr at the time that the bulk operation started */
> +	XLogRecPtr	start_RedoRecPtr;
> +
> +	Page		zeropage;		/* workspace for filling zeroes */

We really should just have one such page in shared memory somewhere... For WAL
writes as well.

But until then - why do you allocate the page? Seems like we could just use a
static global variable?


> +/*
> + * Write all buffered pages to disk.
> + */
> +static void
> +bulkw_flush(BulkWriteState *bulkw)
> +{
> +	int			nbuffered = bulkw->nbuffered;
> +	BulkWriteBuffer *buffers = bulkw->buffers;
> +
> +	if (nbuffered == 0)
> +		return;
> +
> +	if (nbuffered > 1)
> +	{
> +		int			o;
> +
> +		qsort(buffers, nbuffered, sizeof(BulkWriteBuffer), buffer_cmp);
> +
> +		/*
> +		 * Eliminate duplicates, keeping the last write of each block.
> +		 * (buffer_cmp uses 'order' as the last sort key)
> +		 */

Huh - which use cases would actually cause duplicate writes?


Greetings,

Andres Freund