Splink 5/DuckDB/Chunking performance notes #3263
Replies: 2 comments 2 replies
Full prediction and Parquet export experimentsWorkloadThese experiments ran Splink PR 3255 on an The input was 1,000,000,000 Parquet rows. Splink The prediction result did not fit in memory. Immediately after Export settings tested
For the tuned runs, the export pragmas were deliberately applied only after Results
The 64-thread run began much faster, but the advantage did not persist. At comparable elapsed time near 14 minutes it had written 170.7 GB, essentially the same as the 171.5 GB observed for 32 threads. Increasing export concurrency therefore improved startup throughput but not sustained throughput. Export also caused additional spill growth. In the 32-thread run, temporary storage increased by approximately 355.1 GB between the post-predict snapshot and cancellation. In the 64-thread run it increased by approximately 217.6 GB before cancellation. Machine-level evidenceA live snapshot during the 64-thread export showed that the instance hardware was not saturated:
This rules out raw NVMe bandwidth, memory exhaustion, and whole-machine CPU saturation as the main causes. The evidence instead points to limited parallelism while DuckDB reads and reconstructs the heavily spilled in-memory table and feeds compression and Parquet writing. Once that pipeline exposes work for fewer than 64 workers, raising the thread count cannot improve sustained export throughput. OutcomeAll three exports were canceled before completion because throughput degraded too far. No large Parquet output or DuckDB spill files were downloaded. Logs, machine metrics, cancellation metadata, storage snapshots, and DuckDB query profiles were preserved. The final 64-thread instance was terminated after its small evidence artifacts were uploaded and synced locally.
The timing is strongly consistent with that hypothesis, but the data cannot prove DuckDB scans all resident blocks before spilled blocks. The decisive alignment is that only 28.6% of the materialized table was resident after prediction, corresponding to about 2.87B rows; around three minutes into the 64-thread export, completed plus active-file output represented roughly 3.0B rows, exactly when throughput collapsed from 34–40 GB/min to the later 5.55 GB/min range. Yes, the data strongly supports that interpretation, with one caveat. Resident table: 597 GB, or 28.6% of total materialized storage. The most defensible conclusion is: Export is initially fast while predominantly consuming resident or cached blocks. Performance collapses once it becomes dominated by fetching and reconstructing spilled blocks. |
Known-good EC2 configuration for large Splink jobsThis is the best configuration we have tested for Splink 5 on an Recommended strategy
The largest chunk we have tested successfully on Treat one third as a measured starting point, not a universal limit: blocking rules and output width change memory use. Increase the chunk size only if Softwarerequires-python = ">=3.12"
dependencies = [
"duckdb==1.6.0.dev365",
"splink @ git+https://github.com/moj-analytical-services/splink.git@7269e36cb510dcdb350846d55ab3b3b4da7250ba",
]The pinned Splink commit is from PR 3255. The original one-third benchmark used an earlier commit from the same PR ( EC2 and storage
Equivalent setup: mdadm --create /dev/md0 \
--level=0 --raid-devices=3 --chunk=1024K --metadata=1.2 --force \
/dev/nvme1n1 /dev/nvme2n1 /dev/nvme3n1
mkfs.xfs -f -K -d su=1024k,sw=3 /dev/md0
mount -o noatime,nodiscard /dev/md0 /mnt/ssdDiscover the instance-store device names rather than assuming the three paths above. S3 transfersKeep the bucket in the same AWS region as the instance. Configure AWS CLI v2 to use the CRT transfer client and advertise the instance's available bandwidth: [default]
region = eu-central-1
s3 =
preferred_transfer_client = crt
target_bandwidth = 50Gb/sThen stage the dataset onto local NVMe before running Splink: aws s3 sync --no-progress \
s3://BUCKET/datasets/fake_people_for_splink_1bn/ \
/mnt/ssd/fake-people-project/dataset/fake_people_for_splink_1bn/Do not query the billion-row source remotely from S3 for this benchmark. DuckDB and Splinkfrom pathlib import Path
import duckdb
temp_dir = Path("/mnt/ssd/fake-people-project/duckdb_tmp")
temp_dir.mkdir(parents=True, exist_ok=True)
con = duckdb.connect(":memory:")
con.execute("SET threads = 192")
con.execute("SET preserve_insertion_order = false")
con.execute(f"SET temp_directory = '{temp_dir}'")Important details:
Parquet outputThe exact measured fast export used 192 writers, Zstandard compression, one-million-row groups, and a 512 MB file target: predictions.as_duckdbpyrelation().write_parquet(
output_directory,
compression="zstd",
per_thread_output=True,
row_group_size=1_000_000,
file_size_bytes="512MB",
overwrite=True,
)This wrote 1.115 billion rows in 25.175 seconds because the source chunk was not spill-bound. Reducing export concurrency to 32-64 threads only helped once a source table had already spilled; it is not the preferred configuration for a chunk that fits in memory. Operational guardrails
In short: PR 3255, ordinary in-memory DuckDB, direct local Parquet input, 192 threads, and the largest chunk that creates zero temporary files. On the tested one-billion-row workload, |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
In our testing for the forthcoming Splink 5 release against the current DuckDB 2.0 prereleases (
duckdb==1.6.0.dev365), we've been looking at performance on large ec2 instances with many threads.We've found that it's possible to get extremely good performance, but there are also some DuckDB settings that can cause performance to drop dramatically.
We'll eventually add a section to the docs about this, but in the meantime I'll write up some notes here.
The trigger that led us to investigate these performance issues was workloads with a large number of input nodes - in our case we created a fake dataset with 1bn rows.
The most important issue is running Splink from an on disk connection:
In machines with large CPU counts, in some situations, this causes the runtime to be dominated with period of close to 0% CPU ultilisation and slow disk writes.
The solution is simple - create an in-memory connection:
One reason for this is that large CPU counts interact very poorly with the
write_buffer_row_group_count=5the default setting, and setting write_buffer_row_group_count=1 is better. But this doesn't fully explain the performance gap: the in-memory write is still much faster. The following stats are to produce 100m prediction rows:predict_chunkwrite_buffer_row_group_count=5write_buffer_row_group_count=1The reason is that on-disk DuckDB tables are more expensive than simply writing a file. With an in-memory database DuckDB knows the data is not supposed to survive a crash or process exit. With on-disk databases, DuckDB has to maintain a consistent, recoverable database, and some of that bookkeeping becomes a bottleneck on machines with very large CPU counts. write_buffer_row_group_count=1 reduces the worst of this overhead, but an in-memory connection avoids it altogether.
On a run with looser blocking rules on the same 192-thread 48x instance:
Splink generated over 1.1 billion predictions in 97 seconds. Writing all of those predictions out afterwards as 47 GB of compressed Parquet took only another 25 seconds, giving about 123 seconds total for computation plus output.
Large output tables and spill to tmp
At still larger scales, however, we found another performance cliff: the prediction table itself can become too large to remain entirely in memory, even when using an in-memory DuckDB connection.
For example, a full run on the same
r8id.48xlargeproduced approximately 10.04 billion prediction rows. Immediately afterpredict()completed, DuckDB reported approximately:This had a very large effect on Parquet export performance. The 1.115bn-row result above, which fitted much more comfortably in memory, exported at around 1.87 GB/s. During the badly affected 10bn-row export, final Parquet files were growing at only around 1.5 GB/min (0.025 GB/s) — roughly 75× slower by output throughput — despite running on the same machine and writing to the same local NVMe RAID0 filesystem.
I also found that export settings matter considerably once the source table is heavily spilled. The initial export used 192 threads, one-million-row Parquet row groups and
PER_THREAD_OUTPUT, resulting in around 192 concurrent writers. Reducing the export to 32 DuckDB threads, disabling insertion-order preservation, and using smaller bounded row groups:increased completed-row export throughput by roughly 4×
Even after that improvement, the very large export remained around 8–9× slower per row than the 1.1bn-row export
All reactions