diff --git a/.github/workflows/preview-release.yml b/.github/workflows/preview-release.yml
index 52002c0b..e2327f8b 100644
--- a/.github/workflows/preview-release.yml
+++ b/.github/workflows/preview-release.yml
@@ -11,10 +11,11 @@ permissions:
jobs:
publish-fork-package:
if: >-
- (github.event.release.prerelease == true &&
- startsWith(github.event.release.tag_name, 'v1.5.4-preview.')) ||
- (github.event.release.prerelease == false &&
- github.event.release.tag_name == 'v1.5.4')
+ startsWith(github.event.release.tag_name, 'v1.5.') &&
+ ((github.event.release.prerelease == true &&
+ contains(github.event.release.tag_name, '-preview.')) ||
+ (github.event.release.prerelease == false &&
+ !contains(github.event.release.tag_name, '-')))
runs-on: ubuntu-latest
environment: nuget-preview
@@ -31,10 +32,10 @@ jobs:
version="${{ github.event.release.tag_name }}"
version="${version#v}"
- if [[ "$version" =~ ^1\.5\.4-preview\.[0-9]+$ ]]; then
+ if [[ "$version" =~ ^1\.5\.[0-9]+-preview\.[0-9]+$ ]]; then
fork_preview=true
fork_release=false
- elif [[ "$version" == "1.5.4" ]]; then
+ elif [[ "$version" =~ ^1\.5\.[0-9]+$ ]]; then
fork_preview=false
fork_release=true
else
diff --git a/Directory.Build.props b/Directory.Build.props
index 90d83fac..603223b8 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -19,13 +19,15 @@
Giorgi Dalakishvili
Copyright © 2020 - $(Year) Giorgi Dalakishvili
- Skuirrels.
+ true
+ Skuirrels.
DuckDB.NET.$(MSBuildProjectName)
$(ForkPackagePrefix)DuckDB.NET.$(MSBuildProjectName)
DuckDB;ADO.NET;Database;Olap;Embedded
Logo.jpg
README.md
README-PREVIEW.md
+ README-FORK.md
MIT
true
@@ -40,7 +42,7 @@
-
+
https://github.com/skuirrels/DuckDB.NET
https://github.com/skuirrels/DuckDB.NET
@@ -74,13 +76,21 @@
True
+
+ True
+
+
-
+
+
-
+
+
diff --git a/Driver-Benchmark-Comparison-1.5.4-pre.md b/Driver-Benchmark-Comparison-1.5.4-pre.md
new file mode 100644
index 00000000..661306f8
--- /dev/null
+++ b/Driver-Benchmark-Comparison-1.5.4-pre.md
@@ -0,0 +1,191 @@
+# DuckDB driver benchmark comparison: consolidated fork 1.5.4
+
+## Current status
+
+**Updated 2026-07-21:** `feature/appender-scoped-writer` is included in the
+local `release/fork-1.5.4` branch at commit
+`2b04e5e970b1610537f362e1cff435e0f86ee712`. The complete five-lane comparison
+was rerun as `20260721T172000Z-appender-scoped-consolidated-rerun`.
+
+The accepted run completed 13 released DuckDB.NET methods, 14 EFCoreProvider
+methods, 18 consolidated-fork methods, 12 Java methods, and 12 Go methods (ten
+Go repetitions each). The consolidation lane includes both the compatible
+`CreateRow().EndRow()` path and the new allocation-free `AppendRowScoped` path.
+Stock .NET `develop` is not included.
+
+| Real-work benchmark | DuckDB.NET 1.5.3 | EFCoreProvider 1.13.0 | Consolidated fork 1.5.4 | Java JDBC 1.5.4 | Go 1.5.4 | Updated winner |
+| --- | ---: | ---: | ---: | ---: | ---: | --- |
+| Parameterized analytics, prepared | 2.618 ms | 2.609 ms* | **2.498 ms** | 2.522 ms | 2.640 ms | **Consolidated fork** |
+| Materialize 100,000 mixed rows | 9.931 ms | **9.928 ms*** | 10.180 ms | 54.588 ms | 28.105 ms | **EFCoreProvider** |
+| Reusable prepared insert | 54.762 us/row† | 53.697 us/row*† | **19.517 us/row** | 26.111 us/row | 29.275 us/row | **Consolidated fork** |
+| Idiomatic high-throughput insert | 259.7 ns/row | 276.0 ns/row `BulkInsert`* | **79.48 ns/row `AppendRowScoped`** | 174.25 ns/row | 183.46 ns/row | **Consolidated fork** |
+| TPC-H Q1, SF 0.1 | 13.9593 ms | 13.7994 ms* | **13.7020 ms** | 14.0699 ms | 14.2022 ms | **Consolidated fork** |
+| TPC-H Q6, SF 0.1 | 0.9579 ms | 0.9563 ms* | **0.9122 ms** | 0.9761 ms | 0.9914 ms | **Consolidated fork** |
+| TPC-H Q12, SF 0.1 | 7.8996 ms | **7.6711 ms*** | 7.7706 ms | 7.8124 ms | 7.8385 ms | **EFCoreProvider** |
+| TPC-H Q14, SF 0.1 | 1.5953 ms | 1.5309 ms* | **1.5235 ms** | 1.6955 ms | 1.5905 ms | **Consolidated fork** |
+
+`*` Except for its public `BulkInsert`, the EF provider lane executes the same
+low-level benchmark code through its transitive `DuckDB.NET.Data.Full` 1.5.3
+dependency. These cells do not measure EF LINQ translation or change tracking.
+
+`†` `DuckDBCommand.Prepare()` is a no-op in the released and EF dependency .NET
+lanes. It does not create a native prepared statement in those lanes.
+
+The raw winner changed materially: the consolidated fork now wins six of eight
+real-work rows. EFCoreProvider has the lowest point estimate for materialization
+and Q12, but those should be understood as DuckDB.NET 1.5.3/native-engine
+results rather than provider-specific wins. The 0.03% materialization gap
+between the released and EF lanes is effectively a tie.
+
+## Appender optimization result
+
+| Fork appender path | Latency | Throughput | Managed allocation |
+| --- | ---: | ---: | ---: |
+| Compatible `CreateRow().EndRow()` | 86.47 ns/row | 11.56 M rows/s | 64 B/row |
+| New `AppendRowScoped` | **79.48 ns/row** | **12.58 M rows/s** | **0 B/row** |
+| Java JDBC Appender | 174.25 ns/row | 5.74 M rows/s | Not comparable |
+| Go Appender | 183.46 ns/row | 5.45 M rows/s | Not comparable |
+
+The scoped writer is 8.1% faster than the compatible optimized path and removes
+the remaining managed row allocation. Against the other drivers, it has 54.4%
+lower latency than Java and 56.7% lower latency than Go. Put another way, this
+run measured 2.19x Java's and 2.31x Go's row throughput.
+
+The compatible path also benefits substantially from the branch: its 86.47
+ns/row is 65% below the consolidated fork's previous 247.7 ns/row point
+estimate. This cross-run delta is large, but the same-run scoped-versus-compatible
+comparison is the stronger evidence for the incremental scoped API benefit.
+
+## Prepared scalar microbenchmark
+
+This isolates command creation, parameter binding, execution, and one returned
+value. It is not a full application workload.
+
+| Operation | DuckDB.NET 1.5.3 | EFCoreProvider 1.13.0 | Consolidated fork 1.5.4 | Java JDBC 1.5.4 | Go 1.5.4 | Winner |
+| --- | ---: | ---: | ---: | ---: | ---: | --- |
+| Unprepared execution | 66.11 us | 64.18 us* | 62.04 us | 69.44 us | **55.03 us** | **Go** |
+| Prepared execution | 65.80 us† | 67.11 us*† | 24.32 us | 24.27 us | **17.68 us** | **Go** |
+| Create and prepare | 0.090 us† | 0.084 us*† | **33.03 us** | 36.50 us | 33.42 us | **Consolidated fork** |
+
+The approximately 84-90 ns setup results in the released and EF dependency
+lanes only allocate a command object and call the no-op `Prepare()`. They are
+not comparable with the fork, Java, and Go native preparation results.
+
+## Parameterized analytical query
+
+The workload filters and aggregates a deterministic 2,000,000-row table,
+groups and orders the result, and consumes every returned value.
+
+| Operation | DuckDB.NET 1.5.3 | EFCoreProvider 1.13.0 | Consolidated fork 1.5.4 | Java JDBC 1.5.4 | Go 1.5.4 | Winner |
+| --- | ---: | ---: | ---: | ---: | ---: | --- |
+| Unprepared | 2.618 ms | 2.594 ms* | **2.553 ms** | 2.685 ms | 2.721 ms | **Consolidated fork** |
+| Prepared/reused | 2.618 ms† | 2.609 ms*† | **2.498 ms** | 2.522 ms | 2.640 ms | **Consolidated fork** |
+
+The prepared fork/Java difference is only 1.0%, so this ordering is directional
+without alternating repeated runs.
+
+## Result materialization
+
+Each lane fully reads 100,000 ordered rows containing `BIGINT`, `DATE`,
+`TIMESTAMP`, `DOUBLE`, nullable `VARCHAR`, and `BOOLEAN`, while calculating a
+checksum.
+
+| Metric | DuckDB.NET 1.5.3 | EFCoreProvider 1.13.0 | Consolidated fork 1.5.4 | Java JDBC 1.5.4 | Go 1.5.4 | Winner |
+| --- | ---: | ---: | ---: | ---: | ---: | --- |
+| Latency | 9.931 ms | **9.928 ms*** | 10.180 ms | 54.588 ms | 28.105 ms | **EFCoreProvider** |
+| Throughput | 10.07 M rows/s | **10.07 M rows/s*** | 9.82 M rows/s | 1.83 M rows/s | 3.56 M rows/s | **EFCoreProvider** |
+
+The .NET results are within 2.5%. The meaningful result is the clear .NET lead
+over Go and Java in this typed materialization workload, not the tiny ordering
+among the closely related .NET lanes. This workload contains no MAP or LIST
+columns, so it does not exercise the fork's MAP/LIST materialization work.
+
+## Bulk ingestion
+
+Each invocation writes 10,000 precomputed mixed-type rows inside an explicit
+transaction and rolls the transaction back.
+
+| Metric | DuckDB.NET 1.5.3 | EFCoreProvider 1.13.0 | Consolidated fork 1.5.4 | Java JDBC 1.5.4 | Go 1.5.4 | Winner |
+| --- | ---: | ---: | ---: | ---: | ---: | --- |
+| Prepared insert latency | 54.762 us/row† | 53.697 us/row*† | **19.517 us/row** | 26.111 us/row | 29.275 us/row | **Consolidated fork** |
+| Prepared insert throughput | 18,261 rows/s† | 18,623 rows/s*† | **51,238 rows/s** | 38,298 rows/s | 34,159 rows/s | **Consolidated fork** |
+| Idiomatic Appender latency | 259.7 ns/row | 276.0 ns/row `BulkInsert`* | **79.48 ns/row scoped** | 174.25 ns/row | 183.46 ns/row | **Consolidated fork** |
+| Idiomatic Appender throughput | 3.85 M rows/s | 3.62 M rows/s `BulkInsert`* | **12.58 M rows/s scoped** | 5.74 M rows/s | 5.45 M rows/s | **Consolidated fork** |
+| Direct compatible Appender | 259.7 ns/row | 237.8 ns/row* | **86.47 ns/row** | 174.25 ns/row | 183.46 ns/row | **Consolidated fork** |
+
+EFCoreProvider's public `BulkInsert` is 16.1% slower than directly calling the
+Appender bundled in its DuckDB.NET 1.5.3 dependency (276.0 versus 237.8
+ns/row). The public API result is the appropriate idiomatic provider figure.
+
+## TPC-H analytical queries
+
+The suite generates scale factor 0.1 using DuckDB's `tpch` extension, then
+executes and fully consumes Q1, Q6, Q12, and Q14. Every connection uses one
+DuckDB thread.
+
+| Query | DuckDB.NET 1.5.3 | EFCoreProvider 1.13.0 | Consolidated fork 1.5.4 | Java JDBC 1.5.4 | Go 1.5.4 | Winner |
+| --- | ---: | ---: | ---: | ---: | ---: | --- |
+| Q1 | 13.9593 ms | 13.7994 ms* | **13.7020 ms** | 14.0699 ms | 14.2022 ms | **Consolidated fork** |
+| Q6 | 0.9579 ms | 0.9563 ms* | **0.9122 ms** | 0.9761 ms | 0.9914 ms | **Consolidated fork** |
+| Q12 | 7.8996 ms | **7.6711 ms*** | 7.7706 ms | 7.8124 ms | 7.8385 ms | **EFCoreProvider** |
+| Q14 | 1.5953 ms | 1.5309 ms* | **1.5235 ms** | 1.6955 ms | 1.5905 ms | **Consolidated fork** |
+
+These are primarily native-engine workloads. Most gaps are small enough to
+require repeated alternating runs before attributing them to a wrapper. Q12's
+raw EF result should likewise not be interpreted as an EF provider advantage.
+
+## Packages and environment
+
+- macOS 26.5.1, Apple M4 Pro, arm64;
+- .NET SDK 10.0.300;
+- Java 26.0.1 and `org.duckdb:duckdb_jdbc:1.5.4.0`;
+- Go 1.26.5 and `github.com/duckdb/duckdb-go/v2` v2.10504.0;
+- released `DuckDB.NET.Data.Full` 1.5.3;
+- `DuckDB.EFCoreProvider` 1.13.0, transitively using DuckDB.NET 1.5.3 and
+ Entity Framework Core 10.0.10;
+- consolidated DuckDB.NET fork 1.5.4 at release commit `2b04e5e`;
+- one DuckDB thread per connection.
+
+The released .NET and EF dependency lanes use native DuckDB 1.5.3. The
+consolidated fork, Java, and Go use native DuckDB 1.5.4. This is therefore a
+package-level comparison rather than a wrapper-only comparison against one
+identical native library.
+
+For reproducibility, the benchmark ran from harness commit `a73a0eb`. Its tree
+was verified byte-identical to isolated tree `b8a9257`, constructed from
+release commit `2b04e5e` plus the benchmark-only commits. Java JMH was rerun
+outside the filesystem sandbox because JMH requires a local coordinator socket;
+its benchmark configuration was unchanged.
+
+## Methodology and limitations
+
+- One in-memory database and connection are used per benchmark instance.
+- Data generation, extension installation, and input construction occur outside
+ measured methods.
+- All returned rows and columns are consumed.
+- All five lanes ran sequentially on the same machine under one accepted run
+ configuration; Java alone required the local-socket permission noted above.
+- Go values are arithmetic means of ten repetitions. BenchmarkDotNet and JMH
+ values are their reported arithmetic means.
+- Cross-runtime allocation counts are not compared because .NET, JVM, and Go
+ allocations have different representations and costs.
+- BenchmarkDotNet process-priority warnings and Java's `Unsafe` deprecation
+ warnings did not invalidate the completed measurements.
+- No failed benchmark remains in the accepted result set. The earlier partial
+ `20260721T160513Z-appender-scoped-consolidated` run is not used.
+
+## Raw reports
+
+- [Environment](BenchmarkDotNet.Artifacts/DriverComparison/20260721T172000Z-appender-scoped-consolidated-rerun/environment.txt)
+- [DuckDB.NET 1.5.3 reports](BenchmarkDotNet.Artifacts/DriverComparison/20260721T172000Z-appender-scoped-consolidated-rerun/dotnet-1.5.3/results/)
+- [EFCoreProvider 1.13.0 reports](BenchmarkDotNet.Artifacts/DriverComparison/20260721T172000Z-appender-scoped-consolidated-rerun/dotnet-efcore-1.13.0/results/)
+- [Consolidated fork reports](BenchmarkDotNet.Artifacts/DriverComparison/20260721T172000Z-appender-scoped-consolidated-rerun/dotnet-fork-1.5.4/results/)
+- [Java JMH JSON](BenchmarkDotNet.Artifacts/DriverComparison/20260721T172000Z-appender-scoped-consolidated-rerun/java/results.json)
+- [Go benchmark output](BenchmarkDotNet.Artifacts/DriverComparison/20260721T172000Z-appender-scoped-consolidated-rerun/go.txt)
+
+## Recommended follow-up
+
+Repeat the five lanes three to five times on an idle, fixed-power machine while
+alternating their order, then report medians and variation. If the goal is to
+isolate managed-wrapper performance, also run every lane against exactly the
+same native DuckDB build.
diff --git a/Driver-Benchmark-Comparison.md b/Driver-Benchmark-Comparison.md
new file mode 100644
index 00000000..ee0c8f62
--- /dev/null
+++ b/Driver-Benchmark-Comparison.md
@@ -0,0 +1,303 @@
+# DuckDB .NET, EF Core, Go, and Java benchmark comparison
+
+## Technical summary
+
+- **Tuned local DuckDB.NET 1.5.4 wins reusable prepared insertion** at
+ 19.931 us/row, 2.64x faster than both released DuckDB.NET 1.5.3 and the
+ ADO.NET driver bundled by `DuckDB.EFCoreProvider` 1.9.0.
+- **The EF provider's real public `DbContext.BulkInsert` path measures
+ 261.3 ns/row.** That is 4.8% slower than accessing its bundled Appender
+ directly.
+- **Java wins high-throughput Appender ingestion** at 168.3 ns/row, with Go
+ close behind at 172.7 ns/row.
+- **The three .NET lanes are effectively tied on mixed-type result
+ materialization** and materially faster than Go and Java in this run.
+
+## Key findings: side-by-side results
+
+Results are from run `20260716T211118Z` on an Apple M4 Pro. Lower latency is
+better; higher throughput is better. Values are harness-reported means, with
+the arithmetic mean of ten reported repetitions used for Go.
+
+| Real-work benchmark | DuckDB.NET 1.5.3 | EFCoreProvider 1.9.0 | Tuned .NET 1.5.4 | Java JDBC 1.5.4 | Go 1.5.4 | Winner |
+| --- | ---: | ---: | ---: | ---: | ---: | --- |
+| Parameterized analytical query, prepared | **2.417 ms** | 2.422 ms* | 2.428 ms | 2.431 ms | 2.526 ms | **DuckDB.NET 1.5.3** |
+| Materialize 100,000 mixed-type rows | **9.347 ms** | 9.497 ms* | 9.470 ms | 52.774 ms | 26.292 ms | **DuckDB.NET 1.5.3** |
+| Insert with reusable prepared statement | 52.573 us/row | 52.604 us/row* | **19.931 us/row** | 25.508 us/row | 28.906 us/row | **Tuned .NET 1.5.4** |
+| Idiomatic high-throughput insert | 245.3 ns/row Appender | 261.3 ns/row `BulkInsert` | 242.4 ns/row Appender | **168.3 ns/row Appender** | 172.7 ns/row Appender | **Java JDBC 1.5.4** |
+| TPC-H Q1, SF 0.1 | 13.1018 ms | **13.0752 ms*** | 13.1425 ms | 13.0767 ms | 13.3362 ms | **EF provider dependency path, effectively tied with Java** |
+| TPC-H Q6, SF 0.1 | **0.8619 ms** | 0.8660 ms* | 0.8778 ms | 0.8755 ms | 0.8850 ms | **DuckDB.NET 1.5.3** |
+| TPC-H Q12, SF 0.1 | 7.2306 ms | 7.2937 ms* | 7.2821 ms | **7.1969 ms** | 7.3073 ms | **Java JDBC 1.5.4** |
+| TPC-H Q14, SF 0.1 | 1.4774 ms | 1.4779 ms* | **1.4642 ms** | 1.5230 ms | 1.5207 ms | **Tuned .NET 1.5.4** |
+
+## Scope, packages, and metric definitions
+
+The suite compares five package-level lanes:
+
+- released `DuckDB.NET.Data.Full` 1.5.3;
+- `DuckDB.EFCoreProvider` 1.9.0;
+- the locally performance-tuned DuckDB.NET 1.5.4 source in this checkout;
+- DuckDB's core-team-maintained `org.duckdb:duckdb_jdbc` 1.5.4.0 driver;
+- `github.com/duckdb/duckdb-go/v2` v2.10504.0, using DuckDB 1.5.4.
+
+`DuckDB.EFCoreProvider` 1.9.0 has a transitive dependency on
+`DuckDB.NET.Data.Full` 1.5.3. The EF provider column therefore contains two
+clearly separated kinds of measurements:
+
+- **Public EF provider path:** `DbContext.BulkInsert`, which is the provider's
+ appender-backed bulk-ingestion API.
+- **Bundled ADO.NET dependency path:** the identical SQL, prepared-command,
+ materialization, and TPC-H workloads executed through the provider package's
+ bundled DuckDB.NET 1.5.3 dependency. These cells are marked with `*`.
+
+The report does not claim that the `*` cells measure EF LINQ translation or EF
+change tracking. They show the performance an application receives from the
+ADO.NET driver bundled by `DuckDB.EFCoreProvider` 1.9.0.
+
+## Parameterized analytical query
+
+This query filters and aggregates a deterministic 2,000,000-row orders table,
+groups by year and region, orders the result, and consumes every returned
+value.
+
+| Measured operation | DuckDB.NET 1.5.3 | EFCoreProvider 1.9.0 | Tuned .NET 1.5.4 | Java JDBC 1.5.4 | Go 1.5.4 | Winner |
+| --- | ---: | ---: | ---: | ---: | ---: | --- |
+| Unprepared | **2.377 ms** | 2.421 ms* | 2.501 ms | 2.487 ms | 2.565 ms | **DuckDB.NET 1.5.3** |
+| Prepared and reused | **2.417 ms** | 2.422 ms* | 2.428 ms | 2.431 ms | 2.526 ms | **DuckDB.NET 1.5.3** |
+| Prepared/unprepared ratio | 1.017x | 1.000x* | 0.971x | 0.977x | 0.985x | **No meaningful winner; scan and aggregation dominate** |
+
+All prepared results are within 4.5% of one another. Preparation materially
+helps the tuned local, Java, and Go scalar paths, but it is not the dominant
+cost once the query scans and aggregates two million rows.
+
+## Result materialization
+
+All providers fully read the same 100,000 ordered rows containing `BIGINT`,
+`DATE`, `TIMESTAMP`, `DOUBLE`, nullable `VARCHAR`, and `BOOLEAN`, while building
+a checksum so values cannot be skipped.
+
+| Measured operation | DuckDB.NET 1.5.3 | EFCoreProvider 1.9.0 | Tuned .NET 1.5.4 | Java JDBC 1.5.4 | Go 1.5.4 | Winner |
+| --- | ---: | ---: | ---: | ---: | ---: | --- |
+| Mean latency | **9.347 ms** | 9.497 ms* | 9.470 ms | 52.774 ms | 26.292 ms | **DuckDB.NET 1.5.3** |
+| Materialization throughput | **10.70 million rows/s** | 10.53 million rows/s* | 10.56 million rows/s | 1.89 million rows/s | 3.80 million rows/s | **DuckDB.NET 1.5.3** |
+
+The three .NET observations are within 1.6%, so they should be considered
+effectively tied. The meaningful result is that this typed ADO.NET
+materialization path is about 2.8x faster than Go and 5.6x faster than Java in
+this run.
+
+## Bulk ingestion
+
+Each invocation writes the same 10,000 precomputed mixed-type rows inside an
+explicit transaction and rolls that transaction back. Input creation and
+provider metadata warmup happen outside the measurement.
+
+### Equivalent ingestion paths
+
+| Measured operation | DuckDB.NET 1.5.3 | EFCoreProvider 1.9.0 | Tuned .NET 1.5.4 | Java JDBC 1.5.4 | Go 1.5.4 | Winner |
+| --- | ---: | ---: | ---: | ---: | ---: | --- |
+| Prepared insert latency | 52.573 us/row | 52.604 us/row* | **19.931 us/row** | 25.508 us/row | 28.906 us/row | **Tuned .NET 1.5.4** |
+| Prepared insert throughput | 19,021 rows/s | 19,010 rows/s* | **50,173 rows/s** | 39,204 rows/s | 34,595 rows/s | **Tuned .NET 1.5.4** |
+| Direct Appender latency | 245.3 ns/row | 249.4 ns/row* | 242.4 ns/row | **168.3 ns/row** | 172.7 ns/row | **Java JDBC 1.5.4** |
+| Direct Appender throughput | 4.08 million rows/s | 4.01 million rows/s* | 4.13 million rows/s | **5.94 million rows/s** | 5.79 million rows/s | **Java JDBC 1.5.4** |
+| Idiomatic bulk API latency | 245.3 ns/row Appender | 261.3 ns/row `BulkInsert` | 242.4 ns/row Appender | **168.3 ns/row Appender** | 172.7 ns/row Appender | **Java JDBC 1.5.4** |
+| Idiomatic bulk API throughput | 4.08 million rows/s | 3.83 million rows/s `BulkInsert` | 4.13 million rows/s | **5.94 million rows/s** | 5.79 million rows/s | **Java JDBC 1.5.4** |
+
+The tuned local prepared insert is 2.64x faster than both the released
+DuckDB.NET package and the ADO.NET dependency bundled with the EF provider. It
+is 1.28x faster than Java and 1.45x faster than Go.
+
+Java has the lowest Appender latency, with Go 2.6% behind it. The direct .NET
+Appender results are close to one another, confirming that the prepared-command
+tuning is not responsible for Appender performance.
+
+### EF provider `BulkInsert` overhead
+
+This isolates the extra public-provider layer by comparing two measurements
+from the same EF provider benchmark project and the same bundled DuckDB.NET
+1.5.3 dependency.
+
+| Measurement | Direct bundled Appender | Public EF `DbContext.BulkInsert` | Difference | Winner |
+| --- | ---: | ---: | ---: | --- |
+| Latency per row | **249.4 ns** | 261.3 ns | `BulkInsert` is 11.9 ns, or 4.8%, slower | **Direct bundled Appender** |
+| Throughput | **4.01 million rows/s** | 3.83 million rows/s | `BulkInsert` processes about 4.6% fewer rows/s | **Direct bundled Appender** |
+
+The EF provider's public bulk path adds only 4.8% latency over the direct
+Appender dependency path in this warmed 10,000-row scenario.
+
+## TPC-H analytical queries
+
+The suite uses DuckDB's official `tpch` extension to generate scale factor 0.1
+during setup, then executes and fully consumes Q1, Q6, Q12, and Q14. Every
+connection uses one DuckDB thread.
+
+| Query | DuckDB.NET 1.5.3 | EFCoreProvider 1.9.0 | Tuned .NET 1.5.4 | Java JDBC 1.5.4 | Go 1.5.4 | Winner |
+| --- | ---: | ---: | ---: | ---: | ---: | --- |
+| Q1 | 13.1018 ms | **13.0752 ms*** | 13.1425 ms | 13.0767 ms | 13.3362 ms | **EF provider dependency path, effectively tied with Java** |
+| Q6 | **0.8619 ms** | 0.8660 ms* | 0.8778 ms | 0.8755 ms | 0.8850 ms | **DuckDB.NET 1.5.3** |
+| Q12 | 7.2306 ms | 7.2937 ms* | 7.2821 ms | **7.1969 ms** | 7.3073 ms | **Java JDBC 1.5.4** |
+| Q14 | 1.4774 ms | 1.4779 ms* | **1.4642 ms** | 1.5230 ms | 1.5207 ms | **Tuned .NET 1.5.4** |
+
+The winners change by query and the gaps are small. Q1's first two observations
+differ by only 0.0015 ms. Treat these as essentially comparable engine-query
+results rather than evidence that one wrapper is universally faster.
+
+## Prepared scalar microbenchmark
+
+The three-`BIGINT` scalar query isolates statement and parameter-binding
+overhead. It is intentionally not presented as a complete application
+workload.
+
+| Measured operation | DuckDB.NET 1.5.3 | EFCoreProvider 1.9.0 | Tuned .NET 1.5.4 | Java JDBC 1.5.4 | Go 1.5.4 | Winner |
+| --- | ---: | ---: | ---: | ---: | ---: | --- |
+| Unprepared execution | 63.25 us | 63.03 us* | 64.74 us | 67.77 us | **54.04 us** | **Go 1.5.4** |
+| Prepared execution | 64.05 us | 62.23 us* | 21.47 us | 23.43 us | **17.71 us** | **Go 1.5.4** |
+| Speedup from preparing within provider | 0.99x | 1.01x* | 3.02x | 2.89x | **3.05x** | **Go 1.5.4** |
+| Create and prepare | 0.080 us, no-op | 0.079 us, no-op* | **32.396 us** | 36.005 us | 32.482 us | **Tuned .NET 1.5.4 among real prepare paths** |
+
+`DuckDBCommand.Prepare()` is a no-op in DuckDB.NET 1.5.3, including the copy
+bundled with the EF provider. Their setup timings are therefore not comparable
+with the three paths that perform real native preparation.
+
+## What this run establishes
+
+- **Tuned local DuckDB.NET 1.5.4 wins the reusable prepared-insert workload.**
+ It is 2.64x faster than the released .NET 1.5.3 path and the ADO.NET driver
+ bundled with `DuckDB.EFCoreProvider` 1.9.0.
+- **`DuckDB.EFCoreProvider` has a small bulk-layer cost.** Its public
+ `DbContext.BulkInsert` is 4.8% slower than using its bundled Appender
+ directly: 261.3 versus 249.4 ns/row.
+- **Java wins Appender ingestion, closely followed by Go.** Java measured
+ 168.3 ns/row and Go measured 172.7 ns/row.
+- **The .NET lanes lead mixed-type result materialization.** Their results are
+ within 1.6% of each other and materially ahead of Go and Java in this run.
+- **Scan-heavy analytics and TPC-H are broadly close.** Small winner changes
+ should not be interpreted as universal wrapper advantages.
+
+## Methodology and workload contract
+
+| Workload | Data and measured boundary |
+| --- | --- |
+| Scalar execution | Reused command/statement; bind one changing `BIGINT`; execute and read scalar |
+| Parameterized analytics | Filter, group, aggregate, and order 2,000,000 orders; fully read result |
+| Materialization | Read all six typed columns from 100,000 ordered rows and checksum values |
+| Prepared ingestion | Bind and execute 10,000 precomputed rows inside one transaction |
+| Appender ingestion | Append the same 10,000 rows inside one transaction |
+| EF bulk ingestion | Call `DbContext.BulkInsert` for the same 10,000 mapped rows inside one transaction |
+| TPC-H | Generate SF 0.1 in setup; execute and fully consume Q1, Q6, Q12, and Q14 |
+
+Common controls:
+
+- one in-memory database and one connection per benchmark instance;
+- `SET threads = 1` in every provider;
+- deterministic data generation before measurement;
+- setup, extension installation, TPC-H generation, and input construction are
+ outside measured methods;
+- all returned rows and columns are consumed;
+- ten measured results per workload after warmup;
+- benchmark setup verifies the expected package or native engine version.
+
+JDBC has no unprepared parameterized `Statement` API. Its unprepared lane
+creates, binds, executes, reads, and closes a `PreparedStatement` per operation,
+which is the closest public-API equivalent.
+
+The released .NET package and EF provider dependency use native DuckDB 1.5.3.
+The tuned local .NET, Java, and Go lanes use native DuckDB 1.5.4. This is a real
+package-to-package comparison, not a wrapper-only comparison against one
+identical native library.
+
+## Limitations and robustness
+
+- The machine was not otherwise idle, and the provider lanes ran sequentially
+ rather than in an alternating order.
+- The package comparison intentionally includes two native engine versions:
+ DuckDB 1.5.3 for released .NET and the EF provider dependency, and DuckDB
+ 1.5.4 for tuned local .NET, Java, and Go.
+- Large effects such as the 2.64x prepared-insert improvement are useful
+ signals. Single-digit percentage differences, including the EF `BulkInsert`
+ overhead, should be confirmed with alternating runs on an idle,
+ fixed-power machine.
+- Cross-runtime allocation counts are not comparable because .NET, JVM, and Go
+ allocations have different representations and costs.
+
+## Recommended next steps
+
+1. Repeat the complete five-lane run three to five times on an idle machine,
+ alternating provider order, and report the median plus variation.
+2. Add a separate EF-specific application benchmark for LINQ query translation
+ and materialization. Keep it separate from the exact-SQL table because it
+ measures a different abstraction boundary.
+3. Add an EF `SaveChanges` ingestion benchmark if that is a production usage
+ pattern. Compare it with `BulkInsert`, but do not present it as equivalent
+ to a direct Appender.
+4. Run a wrapper-only comparison against the same native DuckDB version if the
+ goal changes from real package performance to isolating managed-driver
+ overhead.
+
+## Further questions
+
+- Does the target EF application primarily use `BulkInsert`, `SaveChanges`, or
+ raw SQL through the provider?
+- Should the next report optimize for real package versions, as this one does,
+ or pin every lane to one native DuckDB engine for wrapper-only attribution?
+
+## Projects
+
+- `DuckDB.NET.1_5_3.Benchmarks` benchmarks released
+ `DuckDB.NET.Data.Full` 1.5.3.
+- `DuckDB.EFCoreProvider.1_9_0.Benchmarks` benchmarks
+ `DuckDB.EFCoreProvider` 1.9.0, including its public `BulkInsert` API.
+- `DuckDB.NET.Benchmarks` benchmarks the locally tuned DuckDB.NET 1.5.4
+ projects in this checkout.
+- `DuckDB.Java.Benchmarks` uses JMH against
+ `org.duckdb:duckdb_jdbc:1.5.4.0`.
+- `DuckDB.Go.Benchmarks` uses Go's `testing.B` against
+ `github.com/duckdb/duckdb-go/v2` v2.10504.0.
+
+## Raw reports for this run
+
+- [Benchmark environment](BenchmarkDotNet.Artifacts/DriverComparison/20260716T211118Z/environment.txt)
+- [DuckDB.NET 1.5.3 analytical query](BenchmarkDotNet.Artifacts/DriverComparison/20260716T211118Z/dotnet-1.5.3/results/DuckDB.NET.Benchmarks.AnalyticalQueryBenchmark-report-github.md)
+- [DuckDB.NET 1.5.3 materialization](BenchmarkDotNet.Artifacts/DriverComparison/20260716T211118Z/dotnet-1.5.3/results/DuckDB.NET.Benchmarks.ResultMaterializationBenchmark-report-github.md)
+- [DuckDB.NET 1.5.3 ingestion](BenchmarkDotNet.Artifacts/DriverComparison/20260716T211118Z/dotnet-1.5.3/results/DuckDB.NET.Benchmarks.BulkIngestionBenchmark-report-github.md)
+- [DuckDB.NET 1.5.3 TPC-H](BenchmarkDotNet.Artifacts/DriverComparison/20260716T211118Z/dotnet-1.5.3/results/DuckDB.NET.Benchmarks.TpchBenchmark-report-github.md)
+- [EF provider dependency analytical query](BenchmarkDotNet.Artifacts/DriverComparison/20260716T211118Z/dotnet-efcore-1.9.0/results/DuckDB.NET.Benchmarks.AnalyticalQueryBenchmark-report-github.md)
+- [EF provider dependency materialization](BenchmarkDotNet.Artifacts/DriverComparison/20260716T211118Z/dotnet-efcore-1.9.0/results/DuckDB.NET.Benchmarks.ResultMaterializationBenchmark-report-github.md)
+- [EF provider dependency ingestion](BenchmarkDotNet.Artifacts/DriverComparison/20260716T211118Z/dotnet-efcore-1.9.0/results/DuckDB.NET.Benchmarks.BulkIngestionBenchmark-report-github.md)
+- [EF provider public `BulkInsert`](BenchmarkDotNet.Artifacts/DriverComparison/20260716T211118Z/dotnet-efcore-1.9.0/results/DuckDB.NET.Benchmarks.EfCoreProviderBulkIngestionBenchmark-report-github.md)
+- [EF provider dependency TPC-H](BenchmarkDotNet.Artifacts/DriverComparison/20260716T211118Z/dotnet-efcore-1.9.0/results/DuckDB.NET.Benchmarks.TpchBenchmark-report-github.md)
+- [Tuned local DuckDB.NET 1.5.4 analytical query](BenchmarkDotNet.Artifacts/DriverComparison/20260716T211118Z/dotnet-local-1.5.4/results/DuckDB.NET.Benchmarks.AnalyticalQueryBenchmark-report-github.md)
+- [Tuned local DuckDB.NET 1.5.4 materialization](BenchmarkDotNet.Artifacts/DriverComparison/20260716T211118Z/dotnet-local-1.5.4/results/DuckDB.NET.Benchmarks.ResultMaterializationBenchmark-report-github.md)
+- [Tuned local DuckDB.NET 1.5.4 ingestion](BenchmarkDotNet.Artifacts/DriverComparison/20260716T211118Z/dotnet-local-1.5.4/results/DuckDB.NET.Benchmarks.BulkIngestionBenchmark-report-github.md)
+- [Tuned local DuckDB.NET 1.5.4 TPC-H](BenchmarkDotNet.Artifacts/DriverComparison/20260716T211118Z/dotnet-local-1.5.4/results/DuckDB.NET.Benchmarks.TpchBenchmark-report-github.md)
+- [Java JMH text](BenchmarkDotNet.Artifacts/DriverComparison/20260716T211118Z/java.txt)
+- [Java JMH JSON](BenchmarkDotNet.Artifacts/DriverComparison/20260716T211118Z/java/results.json)
+- [Go benchmark output](BenchmarkDotNet.Artifacts/DriverComparison/20260716T211118Z/go.txt)
+
+## Run
+
+From the repository root:
+
+```bash
+./run-driver-comparison.sh
+```
+
+Results are written beneath
+`BenchmarkDotNet.Artifacts/DriverComparison//`, separated into
+`dotnet-1.5.3`, `dotnet-efcore-1.9.0`, `dotnet-local-1.5.4`, Java, and Go
+outputs. Each .NET lane uses one launch with five warmup and ten measured
+iterations. Java uses one JMH fork with five warmup and ten measured 500 ms
+iterations plus the GC profiler. Go uses ten benchmark repetitions at 500 ms
+per workload.
+
+For repeated runs after successful Release builds:
+
+```bash
+SKIP_DOTNET_BUILD=1 SKIP_JAVA_BUILD=1 ./run-driver-comparison.sh
+```
+
+Run on an otherwise idle machine with fixed power and thermal settings, and
+compare several alternating provider orders for publishable claims. Elapsed
+time is the meaningful cross-language comparison. Allocation counts are useful
+within each runtime but should not be compared as if .NET, JVM, and Go
+allocations had equal cost.
diff --git a/DuckDB.EFCoreProvider.1_13_0.Benchmarks/DuckDB.EFCoreProvider.1_13_0.Benchmarks.csproj b/DuckDB.EFCoreProvider.1_13_0.Benchmarks/DuckDB.EFCoreProvider.1_13_0.Benchmarks.csproj
new file mode 100644
index 00000000..768c7ff9
--- /dev/null
+++ b/DuckDB.EFCoreProvider.1_13_0.Benchmarks/DuckDB.EFCoreProvider.1_13_0.Benchmarks.csproj
@@ -0,0 +1,28 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ true
+ DuckDB.EFCoreProvider.1_13_0.Benchmarks
+ DuckDB.NET.Benchmarks
+ $(DefineConstants);DUCKDB_NET_BASELINE_1_5_3;DUCKDB_EFCORE_PROVIDER_1_13_0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DuckDB.EFCoreProvider.1_13_0.Benchmarks/EfCoreProviderBulkIngestionBenchmark.cs b/DuckDB.EFCoreProvider.1_13_0.Benchmarks/EfCoreProviderBulkIngestionBenchmark.cs
new file mode 100644
index 00000000..bb99f5f6
--- /dev/null
+++ b/DuckDB.EFCoreProvider.1_13_0.Benchmarks/EfCoreProviderBulkIngestionBenchmark.cs
@@ -0,0 +1,106 @@
+using BenchmarkDotNet.Attributes;
+using DuckDB.EFCoreProvider.Extensions;
+using DuckDB.NET.Data;
+using Microsoft.EntityFrameworkCore;
+using System.Reflection;
+
+namespace DuckDB.NET.Benchmarks;
+
+///
+/// Measures DuckDB.EFCoreProvider's public appender-backed BulkInsert API over
+/// the same 10,000-row transaction used by the driver ingestion benchmarks.
+///
+[MemoryDiagnoser]
+public class EfCoreProviderBulkIngestionBenchmark
+{
+ private DuckDBConnection connection = null!;
+ private EfCoreProviderBenchmarkContext context = null!;
+ private EfCoreIngestRow[] rows = null!;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ VerifyProviderVersion();
+
+ connection = PreparedCommandWorkload.OpenVerifiedConnection();
+ RealisticWorkload.InitializeIngest(connection);
+
+ var options = new DbContextOptionsBuilder()
+ .UseDuckDB(connection, contextOwnsConnection: false)
+ .Options;
+
+ context = new EfCoreProviderBenchmarkContext(options);
+ rows = RealisticWorkload.CreateIngestRows()
+ .Select(static row => new EfCoreIngestRow
+ {
+ Id = row.Id,
+ EventTime = row.EventTime,
+ Amount = row.Amount,
+ Category = row.Category,
+ IsActive = row.IsActive,
+ })
+ .ToArray();
+
+ // Resolve and cache the provider's physical-column accessor plan before
+ // measurement, matching the warmed steady-state used by every harness.
+ context.BulkInsert(Array.Empty());
+ }
+
+ [GlobalCleanup]
+ public void Cleanup()
+ {
+ context.Dispose();
+ connection.Dispose();
+ }
+
+ [Benchmark(OperationsPerInvoke = RealisticWorkload.IngestRowCount)]
+ public int BulkInsertInTransaction()
+ {
+ using var transaction = context.Database.BeginTransaction();
+ var inserted = context.BulkInsert(rows);
+ transaction.Rollback();
+ return inserted;
+ }
+
+ private static void VerifyProviderVersion()
+ {
+ var version = typeof(DuckDBBulkExtensions).Assembly
+ .GetCustomAttribute()
+ ?.InformationalVersion;
+
+ if (version is null || !version.StartsWith("1.13.0", StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ $"The provider comparison requires DuckDB.EFCoreProvider 1.13.0, but loaded {version ?? "an unknown version"}.");
+ }
+ }
+}
+
+internal sealed class EfCoreProviderBenchmarkContext(
+ DbContextOptions options) : DbContext(options)
+{
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ var entity = modelBuilder.Entity();
+ entity.ToTable("benchmark_ingest");
+ entity.HasKey(row => row.Id);
+ entity.Property(row => row.Id).HasColumnName("id").ValueGeneratedNever();
+ entity.Property(row => row.EventTime).HasColumnName("event_time");
+ entity.Property(row => row.Amount).HasColumnName("amount");
+ entity.Property(row => row.Category).HasColumnName("category");
+ entity.Property(row => row.IsActive).HasColumnName("is_active");
+ }
+}
+
+internal sealed class EfCoreIngestRow
+{
+ public long Id { get; init; }
+
+ public DateTime EventTime { get; init; }
+
+ public double Amount { get; init; }
+
+ public string Category { get; init; } = string.Empty;
+
+ public bool IsActive { get; init; }
+}
diff --git a/DuckDB.Go.Benchmarks/go.mod b/DuckDB.Go.Benchmarks/go.mod
new file mode 100644
index 00000000..f2a3912d
--- /dev/null
+++ b/DuckDB.Go.Benchmarks/go.mod
@@ -0,0 +1,30 @@
+module github.com/Giorgi/DuckDB.NET/DuckDB.Go.Benchmarks
+
+go 1.24.0
+
+require github.com/duckdb/duckdb-go/v2 v2.10504.0
+
+require (
+ github.com/apache/arrow-go/v18 v18.5.1 // indirect
+ github.com/duckdb/duckdb-go-bindings v0.10504.0 // indirect
+ github.com/duckdb/duckdb-go-bindings/lib/darwin-amd64 v0.10504.0 // indirect
+ github.com/duckdb/duckdb-go-bindings/lib/darwin-arm64 v0.10504.0 // indirect
+ github.com/duckdb/duckdb-go-bindings/lib/linux-amd64 v0.10504.0 // indirect
+ github.com/duckdb/duckdb-go-bindings/lib/linux-arm64 v0.10504.0 // indirect
+ github.com/duckdb/duckdb-go-bindings/lib/windows-amd64 v0.10504.0 // indirect
+ github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
+ github.com/goccy/go-json v0.10.5 // indirect
+ github.com/google/flatbuffers v25.12.19+incompatible // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/klauspost/compress v1.18.3 // indirect
+ github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+ github.com/pierrec/lz4/v4 v4.1.25 // indirect
+ github.com/zeebo/xxh3 v1.1.0 // indirect
+ golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
+ golang.org/x/mod v0.32.0 // indirect
+ golang.org/x/sync v0.19.0 // indirect
+ golang.org/x/sys v0.40.0 // indirect
+ golang.org/x/telemetry v0.0.0-20260116145544-c6413dc483f5 // indirect
+ golang.org/x/tools v0.41.0 // indirect
+ golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
+)
diff --git a/DuckDB.Go.Benchmarks/go.sum b/DuckDB.Go.Benchmarks/go.sum
new file mode 100644
index 00000000..7149f85a
--- /dev/null
+++ b/DuckDB.Go.Benchmarks/go.sum
@@ -0,0 +1,72 @@
+github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
+github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+github.com/apache/arrow-go/v18 v18.5.1 h1:yaQ6zxMGgf9YCYw4/oaeOU3AULySDlAYDOcnr4LdHdI=
+github.com/apache/arrow-go/v18 v18.5.1/go.mod h1:OCCJsmdq8AsRm8FkBSSmYTwL/s4zHW9CqxeBxEytkNE=
+github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc=
+github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/duckdb/duckdb-go-bindings v0.10504.0 h1:XKOXNRetaJnMvMIDqi6etZOmh/nlEHM7saaC5UxU17I=
+github.com/duckdb/duckdb-go-bindings v0.10504.0/go.mod h1:M2eB9+zGq+O4opimtLL5nWwkp8vdJQJNbFW5HKdqe4U=
+github.com/duckdb/duckdb-go-bindings/lib/darwin-amd64 v0.10504.0 h1:hnWJ9SociR98hpypZPcgB+hTuWgw0hYsYnPFr8mBBEw=
+github.com/duckdb/duckdb-go-bindings/lib/darwin-amd64 v0.10504.0/go.mod h1:EnAvZh1kNJHp5yF+M1ZHNEvapnmt6anq1xXHVrAGqMo=
+github.com/duckdb/duckdb-go-bindings/lib/darwin-arm64 v0.10504.0 h1:q3JEdS5hU8ytvmcsgFEfGGQeJ5mS8l8QFYpfUlPHDRY=
+github.com/duckdb/duckdb-go-bindings/lib/darwin-arm64 v0.10504.0/go.mod h1:IGLSeEcFhNeZF16aVjQCULD7TsFZKG5G7SyKJAXKp5c=
+github.com/duckdb/duckdb-go-bindings/lib/linux-amd64 v0.10504.0 h1:2gABzYp2KnclSpE4qqinTZYhGpWSxmNHQKjs9WZ7zWk=
+github.com/duckdb/duckdb-go-bindings/lib/linux-amd64 v0.10504.0/go.mod h1:KAIynZ0GHCS7X5fRyuFnQMg/SZBPK/bS9OCOVojClxw=
+github.com/duckdb/duckdb-go-bindings/lib/linux-arm64 v0.10504.0 h1:iTFkIVt6Ohd/rTvNQomtbP/IIh90TFGSlOUqFEC68Xo=
+github.com/duckdb/duckdb-go-bindings/lib/linux-arm64 v0.10504.0/go.mod h1:81SGOYoEUs8qaAfSk1wRfM5oobrIJ5KI7AzYhK6/bvQ=
+github.com/duckdb/duckdb-go-bindings/lib/windows-amd64 v0.10504.0 h1:8O774uudYeexLa+uHekujkT3t3sDjQ7LSnlKIhRNWyc=
+github.com/duckdb/duckdb-go-bindings/lib/windows-amd64 v0.10504.0/go.mod h1:K25pJL26ARblGDeuAkrdblFvUen92+CwksLtPEHRqqQ=
+github.com/duckdb/duckdb-go/v2 v2.10504.0 h1:bnkcNQpz3EaJmMmfOhFRFJ3ELVDHW4u3PDhZ78ZTCMY=
+github.com/duckdb/duckdb-go/v2 v2.10504.0/go.mod h1:DQ8TxrUb0RGyTTFTwPNlpQ9FFx+ocJjoZGDkuOktYp8=
+github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
+github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
+github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs=
+github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
+github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
+github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
+github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
+github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
+github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
+github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
+github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
+github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
+github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
+github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
+github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
+golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
+golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
+golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
+golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
+golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/telemetry v0.0.0-20260116145544-c6413dc483f5 h1:i0p03B68+xC1kD2QUO8JzDTPXCzhN56OLJ+IhHY8U3A=
+golang.org/x/telemetry v0.0.0-20260116145544-c6413dc483f5/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
+golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
+golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
+golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
+golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
+gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
+gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/DuckDB.Go.Benchmarks/prepared_command_benchmark_test.go b/DuckDB.Go.Benchmarks/prepared_command_benchmark_test.go
new file mode 100644
index 00000000..644f5e3f
--- /dev/null
+++ b/DuckDB.Go.Benchmarks/prepared_command_benchmark_test.go
@@ -0,0 +1,120 @@
+package benchmarks
+
+import (
+ "database/sql"
+ "fmt"
+ "testing"
+
+ _ "github.com/duckdb/duckdb-go/v2"
+)
+
+const (
+ preparedCommandQuery = "SELECT $first::BIGINT + $second::BIGINT + $third::BIGINT"
+ expectedEngineVersion = "v1.5.4"
+)
+
+var resultSink int64
+
+func BenchmarkPreparedCommandExecuteUnprepared(b *testing.B) {
+ db := openVerifiedDatabase(b)
+ arguments := preparedCommandArguments()
+ var result int64
+
+ b.ReportAllocs()
+ b.ResetTimer()
+
+ for i := 0; i < b.N; i++ {
+ arguments[0] = sql.Named("first", int64(i))
+ if err := db.QueryRow(preparedCommandQuery, arguments...).Scan(&result); err != nil {
+ b.Fatal(err)
+ }
+ }
+
+ resultSink = result
+}
+
+func BenchmarkPreparedCommandExecutePrepared(b *testing.B) {
+ db := openVerifiedDatabase(b)
+ statement, err := db.Prepare(preparedCommandQuery)
+ if err != nil {
+ b.Fatal(err)
+ }
+ b.Cleanup(func() {
+ if err := statement.Close(); err != nil {
+ b.Errorf("close prepared statement: %v", err)
+ }
+ })
+
+ arguments := preparedCommandArguments()
+ var result int64
+
+ b.ReportAllocs()
+ b.ResetTimer()
+
+ for i := 0; i < b.N; i++ {
+ arguments[0] = sql.Named("first", int64(i))
+ if err := statement.QueryRow(arguments...).Scan(&result); err != nil {
+ b.Fatal(err)
+ }
+ }
+
+ resultSink = result
+}
+
+func BenchmarkPreparedCommandCreateAndPrepare(b *testing.B) {
+ db := openVerifiedDatabase(b)
+
+ b.ReportAllocs()
+ b.ResetTimer()
+
+ for i := 0; i < b.N; i++ {
+ statement, err := db.Prepare(preparedCommandQuery)
+ if err != nil {
+ b.Fatal(err)
+ }
+ if err := statement.Close(); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func openVerifiedDatabase(tb testing.TB) *sql.DB {
+ tb.Helper()
+
+ db, err := sql.Open("duckdb", "")
+ if err != nil {
+ tb.Fatal(err)
+ }
+ db.SetMaxOpenConns(1)
+ db.SetMaxIdleConns(1)
+ tb.Cleanup(func() {
+ if err := db.Close(); err != nil {
+ tb.Errorf("close database: %v", err)
+ }
+ })
+
+ var actualVersion string
+ if err := db.QueryRow("SELECT version()").Scan(&actualVersion); err != nil {
+ tb.Fatal(err)
+ }
+ if actualVersion != expectedEngineVersion {
+ tb.Fatal(fmt.Errorf(
+ "the driver comparison requires DuckDB %s, but loaded %s",
+ expectedEngineVersion,
+ actualVersion,
+ ))
+ }
+ if _, err := db.Exec("SET threads = 1"); err != nil {
+ tb.Fatal(err)
+ }
+
+ return db
+}
+
+func preparedCommandArguments() []any {
+ return []any{
+ sql.Named("first", int64(1)),
+ sql.Named("second", int64(2)),
+ sql.Named("third", int64(3)),
+ }
+}
diff --git a/DuckDB.Go.Benchmarks/realistic_benchmark_test.go b/DuckDB.Go.Benchmarks/realistic_benchmark_test.go
new file mode 100644
index 00000000..73cb652a
--- /dev/null
+++ b/DuckDB.Go.Benchmarks/realistic_benchmark_test.go
@@ -0,0 +1,465 @@
+package benchmarks
+
+import (
+ "context"
+ "database/sql"
+ "database/sql/driver"
+ "fmt"
+ "math"
+ "testing"
+ "time"
+
+ duckdb "github.com/duckdb/duckdb-go/v2"
+)
+
+const (
+ analyticsRowCount = 2_000_000
+ materializationRowCount = 100_000
+ ingestRowCount = 10_000
+ tpchScaleFactor = 0.1
+
+ analyticsQuery = `
+ SELECT
+ year(order_date) AS order_year,
+ region,
+ count(*) AS order_count,
+ sum(amount) AS revenue
+ FROM benchmark_orders
+ WHERE customer_id = $customerId
+ AND order_date >= $fromDate
+ AND order_date < $toDate
+ GROUP BY order_year, region
+ ORDER BY order_year, region`
+
+ materializationQuery = `
+ SELECT id, event_date, event_time, amount, customer_name, is_active
+ FROM benchmark_materialization
+ ORDER BY id`
+
+ ingestStatement = `
+ INSERT INTO benchmark_ingest
+ VALUES ($id, $eventTime, $amount, $category, $isActive)`
+)
+
+var realisticResultSink int64
+
+type ingestRow struct {
+ id int64
+ eventTime time.Time
+ amount float64
+ category string
+ isActive bool
+}
+
+func BenchmarkAnalyticalQueryExecuteUnprepared(b *testing.B) {
+ db := openVerifiedDatabase(b)
+ initializeAnalytics(b, db)
+
+ b.ReportAllocs()
+ b.ResetTimer()
+
+ var checksum int64
+ for i := 0; i < b.N; i++ {
+ rows, err := db.Query(
+ analyticsQuery,
+ analyticsArguments(i%100)...,
+ )
+ if err != nil {
+ b.Fatal(err)
+ }
+ checksum = consumeAnalytics(b, rows)
+ }
+
+ realisticResultSink = checksum
+}
+
+func BenchmarkAnalyticalQueryExecutePrepared(b *testing.B) {
+ db := openVerifiedDatabase(b)
+ initializeAnalytics(b, db)
+ statement, err := db.Prepare(analyticsQuery)
+ if err != nil {
+ b.Fatal(err)
+ }
+ b.Cleanup(func() {
+ if err := statement.Close(); err != nil {
+ b.Errorf("close analytical statement: %v", err)
+ }
+ })
+
+ b.ReportAllocs()
+ b.ResetTimer()
+
+ var checksum int64
+ for i := 0; i < b.N; i++ {
+ rows, err := statement.Query(analyticsArguments(i % 100)...)
+ if err != nil {
+ b.Fatal(err)
+ }
+ checksum = consumeAnalytics(b, rows)
+ }
+
+ realisticResultSink = checksum
+}
+
+func BenchmarkResultMaterializationReadOneHundredThousandMixedRows(b *testing.B) {
+ db := openVerifiedDatabase(b)
+ initializeMaterialization(b, db)
+
+ b.ReportAllocs()
+ b.ResetTimer()
+
+ var checksum int64
+ for i := 0; i < b.N; i++ {
+ rows, err := db.Query(materializationQuery)
+ if err != nil {
+ b.Fatal(err)
+ }
+ checksum = consumeMaterialization(b, rows)
+ }
+
+ realisticResultSink = checksum
+}
+
+func BenchmarkBulkIngestionInsertPreparedInTransaction(b *testing.B) {
+ db := openVerifiedDatabase(b)
+ initializeIngest(b, db)
+ rows := createIngestRows()
+ statement, err := db.Prepare(ingestStatement)
+ if err != nil {
+ b.Fatal(err)
+ }
+ b.Cleanup(func() {
+ if err := statement.Close(); err != nil {
+ b.Errorf("close ingest statement: %v", err)
+ }
+ })
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ started := time.Now()
+
+ for i := 0; i < b.N; i++ {
+ transaction, err := db.Begin()
+ if err != nil {
+ b.Fatal(err)
+ }
+ transactionStatement := transaction.Stmt(statement)
+
+ for _, row := range rows {
+ if _, err := transactionStatement.Exec(
+ sql.Named("id", row.id),
+ sql.Named("eventTime", row.eventTime),
+ sql.Named("amount", row.amount),
+ sql.Named("category", row.category),
+ sql.Named("isActive", row.isActive),
+ ); err != nil {
+ b.Fatal(err)
+ }
+ }
+
+ if err := transactionStatement.Close(); err != nil {
+ b.Fatal(err)
+ }
+ if err := transaction.Rollback(); err != nil {
+ b.Fatal(err)
+ }
+ }
+
+ reportRowsPerSecond(b, started, b.N*len(rows))
+}
+
+func BenchmarkBulkIngestionInsertWithAppenderInTransaction(b *testing.B) {
+ db := openVerifiedDatabase(b)
+ initializeIngest(b, db)
+ rows := createIngestRows()
+ connection, err := db.Conn(context.Background())
+ if err != nil {
+ b.Fatal(err)
+ }
+ b.Cleanup(func() {
+ if err := connection.Close(); err != nil {
+ b.Errorf("close appender connection: %v", err)
+ }
+ })
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ started := time.Now()
+
+ for i := 0; i < b.N; i++ {
+ transaction, err := connection.BeginTx(context.Background(), nil)
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ err = connection.Raw(func(rawConnection any) error {
+ driverConnection, ok := rawConnection.(driver.Conn)
+ if !ok {
+ return fmt.Errorf("DuckDB connection does not implement driver.Conn")
+ }
+
+ appender, err := duckdb.NewAppenderFromConn(driverConnection, "", "benchmark_ingest")
+ if err != nil {
+ return err
+ }
+
+ for _, row := range rows {
+ if err := appender.AppendRow(
+ row.id,
+ row.eventTime,
+ row.amount,
+ row.category,
+ row.isActive,
+ ); err != nil {
+ _ = appender.Close()
+ return err
+ }
+ }
+
+ return appender.Close()
+ })
+ if err != nil {
+ _ = transaction.Rollback()
+ b.Fatal(err)
+ }
+ if err := transaction.Rollback(); err != nil {
+ b.Fatal(err)
+ }
+ }
+
+ reportRowsPerSecond(b, started, b.N*len(rows))
+}
+
+func BenchmarkTpchQuery01(b *testing.B) {
+ benchmarkTpchQuery(b, 1)
+}
+
+func BenchmarkTpchQuery06(b *testing.B) {
+ benchmarkTpchQuery(b, 6)
+}
+
+func BenchmarkTpchQuery12(b *testing.B) {
+ benchmarkTpchQuery(b, 12)
+}
+
+func BenchmarkTpchQuery14(b *testing.B) {
+ benchmarkTpchQuery(b, 14)
+}
+
+func initializeAnalytics(tb testing.TB, db *sql.DB) {
+ tb.Helper()
+ mustExec(tb, db, fmt.Sprintf(`
+ CREATE TABLE benchmark_orders AS
+ SELECT
+ i::BIGINT AS order_id,
+ (i %% 10000)::INTEGER AS customer_id,
+ DATE '2020-01-01' + ((i %% 1461)::INTEGER) AS order_date,
+ CASE i %% 4
+ WHEN 0 THEN 'north'
+ WHEN 1 THEN 'south'
+ WHEN 2 THEN 'east'
+ ELSE 'west'
+ END::VARCHAR AS region,
+ ((i %% 100000)::DOUBLE / 100.0) AS amount
+ FROM range(%d) AS source(i)`, analyticsRowCount))
+}
+
+func initializeMaterialization(tb testing.TB, db *sql.DB) {
+ tb.Helper()
+ mustExec(tb, db, fmt.Sprintf(`
+ CREATE TABLE benchmark_materialization AS
+ SELECT
+ i::BIGINT AS id,
+ DATE '2020-01-01' + ((i %% 1461)::INTEGER) AS event_date,
+ TIMESTAMP '2020-01-01 00:00:00' + ((i %% 31536000) * INTERVAL 1 SECOND) AS event_time,
+ ((i %% 100000)::DOUBLE / 100.0) AS amount,
+ CASE
+ WHEN i %% 10 = 0 THEN NULL
+ ELSE ('customer-' || (i %% 10000)::VARCHAR)
+ END::VARCHAR AS customer_name,
+ (i %% 2 = 0) AS is_active
+ FROM range(%d) AS source(i)`, materializationRowCount))
+}
+
+func initializeIngest(tb testing.TB, db *sql.DB) {
+ tb.Helper()
+ mustExec(tb, db, `
+ CREATE TABLE benchmark_ingest(
+ id BIGINT,
+ event_time TIMESTAMP,
+ amount DOUBLE,
+ category VARCHAR,
+ is_active BOOLEAN
+ )`)
+}
+
+func initializeTpch(tb testing.TB, db *sql.DB) {
+ tb.Helper()
+ mustExec(tb, db, "INSTALL tpch")
+ mustExec(tb, db, "LOAD tpch")
+ mustExec(tb, db, fmt.Sprintf("CALL dbgen(sf = %g)", tpchScaleFactor))
+}
+
+func analyticsArguments(customerID int) []any {
+ return []any{
+ sql.Named("customerId", customerID),
+ sql.Named("fromDate", time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)),
+ sql.Named("toDate", time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)),
+ }
+}
+
+func consumeAnalytics(tb testing.TB, rows *sql.Rows) int64 {
+ tb.Helper()
+ defer closeRows(tb, rows)
+
+ rowCount := 0
+ checksum := int64(17)
+ for rows.Next() {
+ var year int32
+ var region string
+ var orderCount int64
+ var revenue float64
+ if err := rows.Scan(&year, ®ion, &orderCount, &revenue); err != nil {
+ tb.Fatal(err)
+ }
+ checksum = checksum*31 + int64(year)
+ checksum = checksum*31 + int64(len(region))
+ checksum = checksum*31 + orderCount
+ checksum = checksum*31 + int64(math.Float64bits(revenue))
+ rowCount++
+ }
+ if err := rows.Err(); err != nil {
+ tb.Fatal(err)
+ }
+ if rowCount == 0 {
+ tb.Fatal("the analytical workload returned no rows")
+ }
+ return checksum
+}
+
+func consumeMaterialization(tb testing.TB, rows *sql.Rows) int64 {
+ tb.Helper()
+ defer closeRows(tb, rows)
+
+ rowCount := 0
+ checksum := int64(17)
+ for rows.Next() {
+ var id int64
+ var eventDate time.Time
+ var eventTime time.Time
+ var amount float64
+ var customer sql.NullString
+ var isActive bool
+ if err := rows.Scan(&id, &eventDate, &eventTime, &amount, &customer, &isActive); err != nil {
+ tb.Fatal(err)
+ }
+ checksum += id + eventDate.Unix() + eventTime.UnixNano()
+ checksum += int64(math.Float64bits(amount))
+ if customer.Valid {
+ checksum += int64(len(customer.String))
+ }
+ if isActive {
+ checksum++
+ }
+ rowCount++
+ }
+ if err := rows.Err(); err != nil {
+ tb.Fatal(err)
+ }
+ if rowCount != materializationRowCount {
+ tb.Fatalf("expected %d materialized rows, but read %d", materializationRowCount, rowCount)
+ }
+ return checksum
+}
+
+func benchmarkTpchQuery(b *testing.B, queryNumber int) {
+ db := openVerifiedDatabase(b)
+ initializeTpch(b, db)
+ query := fmt.Sprintf("PRAGMA tpch(%d)", queryNumber)
+
+ b.ReportAllocs()
+ b.ResetTimer()
+
+ var checksum int64
+ for i := 0; i < b.N; i++ {
+ rows, err := db.Query(query)
+ if err != nil {
+ b.Fatal(err)
+ }
+ checksum = consumeGenericRows(b, rows)
+ }
+
+ realisticResultSink = checksum
+}
+
+func consumeGenericRows(tb testing.TB, rows *sql.Rows) int64 {
+ tb.Helper()
+ defer closeRows(tb, rows)
+
+ columns, err := rows.Columns()
+ if err != nil {
+ tb.Fatal(err)
+ }
+ values := make([]any, len(columns))
+ destinations := make([]any, len(columns))
+ for index := range values {
+ destinations[index] = &values[index]
+ }
+
+ rowCount := 0
+ checksum := int64(17)
+ for rows.Next() {
+ if err := rows.Scan(destinations...); err != nil {
+ tb.Fatal(err)
+ }
+ for _, value := range values {
+ checksum = checksum*31 + int64(len(fmt.Sprint(value)))
+ }
+ rowCount++
+ }
+ if err := rows.Err(); err != nil {
+ tb.Fatal(err)
+ }
+ if rowCount == 0 {
+ tb.Fatal("the TPC-H workload returned no rows")
+ }
+ return checksum
+}
+
+func createIngestRows() []ingestRow {
+ rows := make([]ingestRow, ingestRowCount)
+ start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+ categories := []string{"north", "south", "east", "west"}
+ for index := range rows {
+ rows[index] = ingestRow{
+ id: int64(index),
+ eventTime: start.Add(time.Duration(index) * time.Second),
+ amount: float64(index%100000) / 100.0,
+ category: categories[index%len(categories)],
+ isActive: index%2 == 0,
+ }
+ }
+ return rows
+}
+
+func mustExec(tb testing.TB, db *sql.DB, statement string) {
+ tb.Helper()
+ if _, err := db.Exec(statement); err != nil {
+ tb.Fatal(err)
+ }
+}
+
+func closeRows(tb testing.TB, rows *sql.Rows) {
+ tb.Helper()
+ if err := rows.Close(); err != nil {
+ tb.Fatal(err)
+ }
+}
+
+func reportRowsPerSecond(b *testing.B, started time.Time, rowCount int) {
+ elapsed := time.Since(started)
+ if elapsed > 0 {
+ b.ReportMetric(float64(rowCount)/elapsed.Seconds(), "rows/s")
+ b.ReportMetric(float64(elapsed.Nanoseconds())/float64(rowCount), "ns/row")
+ }
+}
diff --git a/DuckDB.Java.Benchmarks/.gitignore b/DuckDB.Java.Benchmarks/.gitignore
new file mode 100644
index 00000000..b83d2226
--- /dev/null
+++ b/DuckDB.Java.Benchmarks/.gitignore
@@ -0,0 +1 @@
+/target/
diff --git a/DuckDB.Java.Benchmarks/pom.xml b/DuckDB.Java.Benchmarks/pom.xml
new file mode 100644
index 00000000..af9660d6
--- /dev/null
+++ b/DuckDB.Java.Benchmarks/pom.xml
@@ -0,0 +1,83 @@
+
+
+ 4.0.0
+
+ org.duckdb.benchmarks
+ duckdb-java-benchmarks
+ 1.0.0-SNAPSHOT
+
+
+ UTF-8
+ 17
+ 1.5.4.0
+ 1.37
+
+
+
+
+ org.duckdb
+ duckdb_jdbc
+ ${duckdb.jdbc.version}
+
+
+ org.openjdk.jmh
+ jmh-core
+ ${jmh.version}
+
+
+
+
+ benchmarks
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.14.1
+
+
+
+ org.openjdk.jmh
+ jmh-generator-annprocess
+ ${jmh.version}
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.6.1
+
+
+ package
+
+ shade
+
+
+ false
+ ${project.build.directory}/benchmarks-all.jar
+
+
+ *:*
+
+ META-INF/*.SF
+ META-INF/*.DSA
+ META-INF/*.RSA
+
+
+
+
+
+
+ org.openjdk.jmh.Main
+
+
+
+
+
+
+
+
+
diff --git a/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/AnalyticalQueryBenchmark.java b/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/AnalyticalQueryBenchmark.java
new file mode 100644
index 00000000..509f0d04
--- /dev/null
+++ b/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/AnalyticalQueryBenchmark.java
@@ -0,0 +1,57 @@
+package org.duckdb.benchmarks;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.util.concurrent.TimeUnit;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.annotations.Warmup;
+
+@State(Scope.Thread)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Fork(1)
+@Warmup(iterations = 5, time = 500, timeUnit = TimeUnit.MILLISECONDS)
+@Measurement(iterations = 10, time = 500, timeUnit = TimeUnit.MILLISECONDS)
+public class AnalyticalQueryBenchmark {
+ private Connection connection;
+ private PreparedStatement preparedStatement;
+ private int nextCustomer;
+
+ @Setup(Level.Trial)
+ public void setup() throws SQLException {
+ connection = RealisticBenchmarkSupport.openConnection();
+ RealisticBenchmarkSupport.initializeAnalytics(connection);
+ preparedStatement = connection.prepareStatement(RealisticBenchmarkSupport.ANALYTICS_QUERY);
+ }
+
+ @TearDown(Level.Trial)
+ public void cleanup() throws SQLException {
+ preparedStatement.close();
+ connection.close();
+ }
+
+ @Benchmark
+ public long executeUnprepared() throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement(RealisticBenchmarkSupport.ANALYTICS_QUERY)) {
+ RealisticBenchmarkSupport.bindAnalytics(statement, nextCustomer++ % 100);
+ return RealisticBenchmarkSupport.consumeAnalytics(statement);
+ }
+ }
+
+ @Benchmark
+ public long executePrepared() throws SQLException {
+ RealisticBenchmarkSupport.bindAnalytics(preparedStatement, nextCustomer++ % 100);
+ return RealisticBenchmarkSupport.consumeAnalytics(preparedStatement);
+ }
+}
diff --git a/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/BenchmarkSupport.java b/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/BenchmarkSupport.java
new file mode 100644
index 00000000..e8016b66
--- /dev/null
+++ b/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/BenchmarkSupport.java
@@ -0,0 +1,55 @@
+package org.duckdb.benchmarks;
+
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+
+final class BenchmarkSupport {
+ static final String QUERY = "SELECT ?::BIGINT + ?::BIGINT + ?::BIGINT";
+
+ private static final String EXPECTED_ENGINE_VERSION = "v1.5.4";
+
+ private BenchmarkSupport() {
+ }
+
+ static Connection openVerifiedConnection() throws SQLException {
+ var connection = DriverManager.getConnection("jdbc:duckdb:");
+
+ try (Statement statement = connection.createStatement();
+ ResultSet result = statement.executeQuery("SELECT version()")) {
+ if (!result.next()) {
+ connection.close();
+ throw new SQLException("DuckDB did not return an engine version");
+ }
+
+ var actualVersion = result.getString(1);
+ if (!EXPECTED_ENGINE_VERSION.equals(actualVersion)) {
+ connection.close();
+ throw new SQLException(
+ "The driver comparison requires DuckDB " + EXPECTED_ENGINE_VERSION
+ + ", but loaded " + actualVersion);
+ }
+ }
+
+ return connection;
+ }
+
+ static void bindParameters(PreparedStatement statement, long first) throws SQLException {
+ statement.setLong(1, first);
+ statement.setLong(2, 2L);
+ statement.setLong(3, 3L);
+ }
+
+ static long executeScalar(PreparedStatement statement) throws SQLException {
+ try (ResultSet result = statement.executeQuery()) {
+ if (!result.next()) {
+ throw new SQLException("DuckDB did not return a scalar result");
+ }
+
+ return result.getLong(1);
+ }
+ }
+}
diff --git a/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/BulkIngestionBenchmark.java b/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/BulkIngestionBenchmark.java
new file mode 100644
index 00000000..afd27403
--- /dev/null
+++ b/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/BulkIngestionBenchmark.java
@@ -0,0 +1,85 @@
+package org.duckdb.benchmarks;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.util.concurrent.TimeUnit;
+import org.duckdb.DuckDBConnection;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OperationsPerInvocation;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.annotations.Warmup;
+
+@State(Scope.Thread)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Fork(1)
+@Warmup(iterations = 5, time = 500, timeUnit = TimeUnit.MILLISECONDS)
+@Measurement(iterations = 10, time = 500, timeUnit = TimeUnit.MILLISECONDS)
+public class BulkIngestionBenchmark {
+ private DuckDBConnection connection;
+ private PreparedStatement preparedStatement;
+ private RealisticBenchmarkSupport.IngestRow[] rows;
+
+ @Setup(Level.Trial)
+ public void setup() throws SQLException {
+ connection = (DuckDBConnection) RealisticBenchmarkSupport.openConnection();
+ RealisticBenchmarkSupport.initializeIngest(connection);
+ preparedStatement = connection.prepareStatement(RealisticBenchmarkSupport.INGEST_STATEMENT);
+ rows = RealisticBenchmarkSupport.createIngestRows();
+ }
+
+ @TearDown(Level.Trial)
+ public void cleanup() throws SQLException {
+ preparedStatement.close();
+ connection.close();
+ }
+
+ @Benchmark
+ @OperationsPerInvocation(RealisticBenchmarkSupport.INGEST_ROW_COUNT)
+ public int insertPreparedInTransaction() throws SQLException {
+ connection.setAutoCommit(false);
+ try {
+ for (var row : rows) {
+ RealisticBenchmarkSupport.bindIngest(preparedStatement, row);
+ preparedStatement.executeUpdate();
+ }
+ connection.rollback();
+ return rows.length;
+ } finally {
+ connection.setAutoCommit(true);
+ }
+ }
+
+ @Benchmark
+ @OperationsPerInvocation(RealisticBenchmarkSupport.INGEST_ROW_COUNT)
+ public int insertWithAppenderInTransaction() throws SQLException {
+ connection.setAutoCommit(false);
+ try {
+ try (var appender = connection.createAppender("benchmark_ingest")) {
+ for (var row : rows) {
+ appender.beginRow()
+ .append(row.id())
+ .append(row.eventTime())
+ .append(row.amount())
+ .append(row.category())
+ .append(row.isActive())
+ .endRow();
+ }
+ }
+ connection.rollback();
+ return rows.length;
+ } finally {
+ connection.setAutoCommit(true);
+ }
+ }
+}
diff --git a/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/PreparedCommandExecutionBenchmark.java b/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/PreparedCommandExecutionBenchmark.java
new file mode 100644
index 00000000..99b955e2
--- /dev/null
+++ b/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/PreparedCommandExecutionBenchmark.java
@@ -0,0 +1,56 @@
+package org.duckdb.benchmarks;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.util.concurrent.TimeUnit;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.annotations.Warmup;
+
+@State(Scope.Thread)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Fork(1)
+@Warmup(iterations = 5, time = 500, timeUnit = TimeUnit.MILLISECONDS)
+@Measurement(iterations = 10, time = 500, timeUnit = TimeUnit.MILLISECONDS)
+public class PreparedCommandExecutionBenchmark {
+ private Connection connection;
+ private PreparedStatement preparedStatement;
+ private long nextValue;
+
+ @Setup(Level.Trial)
+ public void setup() throws SQLException {
+ connection = BenchmarkSupport.openVerifiedConnection();
+ preparedStatement = connection.prepareStatement(BenchmarkSupport.QUERY);
+ }
+
+ @TearDown(Level.Trial)
+ public void cleanup() throws SQLException {
+ preparedStatement.close();
+ connection.close();
+ }
+
+ @Benchmark
+ public long executeUnprepared() throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement(BenchmarkSupport.QUERY)) {
+ BenchmarkSupport.bindParameters(statement, nextValue++);
+ return BenchmarkSupport.executeScalar(statement);
+ }
+ }
+
+ @Benchmark
+ public long executePrepared() throws SQLException {
+ BenchmarkSupport.bindParameters(preparedStatement, nextValue++);
+ return BenchmarkSupport.executeScalar(preparedStatement);
+ }
+}
diff --git a/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/PreparedCommandSetupBenchmark.java b/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/PreparedCommandSetupBenchmark.java
new file mode 100644
index 00000000..19c916c7
--- /dev/null
+++ b/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/PreparedCommandSetupBenchmark.java
@@ -0,0 +1,45 @@
+package org.duckdb.benchmarks;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.util.concurrent.TimeUnit;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.annotations.Warmup;
+
+@State(Scope.Thread)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Fork(1)
+@Warmup(iterations = 5, time = 500, timeUnit = TimeUnit.MILLISECONDS)
+@Measurement(iterations = 10, time = 500, timeUnit = TimeUnit.MILLISECONDS)
+public class PreparedCommandSetupBenchmark {
+ private Connection connection;
+
+ @Setup(Level.Trial)
+ public void setup() throws SQLException {
+ connection = BenchmarkSupport.openVerifiedConnection();
+ }
+
+ @TearDown(Level.Trial)
+ public void cleanup() throws SQLException {
+ connection.close();
+ }
+
+ @Benchmark
+ public void createAndPrepare() throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement(BenchmarkSupport.QUERY)) {
+ // Preparation and disposal are the measured operation.
+ }
+ }
+}
diff --git a/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/RealisticBenchmarkSupport.java b/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/RealisticBenchmarkSupport.java
new file mode 100644
index 00000000..cdcee6e0
--- /dev/null
+++ b/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/RealisticBenchmarkSupport.java
@@ -0,0 +1,219 @@
+package org.duckdb.benchmarks;
+
+import java.sql.Connection;
+import java.sql.Date;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.sql.Timestamp;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+final class RealisticBenchmarkSupport {
+ static final int ANALYTICS_ROW_COUNT = 2_000_000;
+ static final int MATERIALIZATION_ROW_COUNT = 100_000;
+ static final int INGEST_ROW_COUNT = 10_000;
+ static final double TPCH_SCALE_FACTOR = 0.1;
+
+ static final LocalDate ANALYTICS_FROM_DATE = LocalDate.of(2020, 1, 1);
+ static final LocalDate ANALYTICS_TO_DATE = LocalDate.of(2024, 1, 1);
+
+ static final String ANALYTICS_QUERY = """
+ SELECT
+ year(order_date) AS order_year,
+ region,
+ count(*) AS order_count,
+ sum(amount) AS revenue
+ FROM benchmark_orders
+ WHERE customer_id = ?
+ AND order_date >= ?
+ AND order_date < ?
+ GROUP BY order_year, region
+ ORDER BY order_year, region
+ """;
+
+ static final String MATERIALIZATION_QUERY = """
+ SELECT id, event_date, event_time, amount, customer_name, is_active
+ FROM benchmark_materialization
+ ORDER BY id
+ """;
+
+ static final String INGEST_STATEMENT = """
+ INSERT INTO benchmark_ingest
+ VALUES (?, ?, ?, ?, ?)
+ """;
+
+ private RealisticBenchmarkSupport() {
+ }
+
+ static Connection openConnection() throws SQLException {
+ var connection = BenchmarkSupport.openVerifiedConnection();
+ executeNonQuery(connection, "SET threads = 1");
+ return connection;
+ }
+
+ static void initializeAnalytics(Connection connection) throws SQLException {
+ executeNonQuery(connection, """
+ CREATE TABLE benchmark_orders AS
+ SELECT
+ i::BIGINT AS order_id,
+ (i %% 10000)::INTEGER AS customer_id,
+ DATE '2020-01-01' + ((i %% 1461)::INTEGER) AS order_date,
+ CASE i %% 4
+ WHEN 0 THEN 'north'
+ WHEN 1 THEN 'south'
+ WHEN 2 THEN 'east'
+ ELSE 'west'
+ END::VARCHAR AS region,
+ ((i %% 100000)::DOUBLE / 100.0) AS amount
+ FROM range(%d) AS source(i)
+ """.formatted(ANALYTICS_ROW_COUNT));
+ }
+
+ static void initializeMaterialization(Connection connection) throws SQLException {
+ executeNonQuery(connection, """
+ CREATE TABLE benchmark_materialization AS
+ SELECT
+ i::BIGINT AS id,
+ DATE '2020-01-01' + ((i %% 1461)::INTEGER) AS event_date,
+ TIMESTAMP '2020-01-01 00:00:00' + ((i %% 31536000) * INTERVAL 1 SECOND) AS event_time,
+ ((i %% 100000)::DOUBLE / 100.0) AS amount,
+ CASE
+ WHEN i %% 10 = 0 THEN NULL
+ ELSE ('customer-' || (i %% 10000)::VARCHAR)
+ END::VARCHAR AS customer_name,
+ (i %% 2 = 0) AS is_active
+ FROM range(%d) AS source(i)
+ """.formatted(MATERIALIZATION_ROW_COUNT));
+ }
+
+ static void initializeIngest(Connection connection) throws SQLException {
+ executeNonQuery(connection, """
+ CREATE TABLE benchmark_ingest(
+ id BIGINT,
+ event_time TIMESTAMP,
+ amount DOUBLE,
+ category VARCHAR,
+ is_active BOOLEAN
+ )
+ """);
+ }
+
+ static void initializeTpch(Connection connection) throws SQLException {
+ executeNonQuery(connection, "INSTALL tpch");
+ executeNonQuery(connection, "LOAD tpch");
+ executeNonQuery(connection, "CALL dbgen(sf = " + TPCH_SCALE_FACTOR + ")");
+ }
+
+ static void bindAnalytics(PreparedStatement statement, int customerId) throws SQLException {
+ statement.setInt(1, customerId);
+ statement.setDate(2, Date.valueOf(ANALYTICS_FROM_DATE));
+ statement.setDate(3, Date.valueOf(ANALYTICS_TO_DATE));
+ }
+
+ static long consumeAnalytics(PreparedStatement statement) throws SQLException {
+ try (ResultSet result = statement.executeQuery()) {
+ var rowCount = 0;
+ long checksum = 17;
+
+ while (result.next()) {
+ checksum = checksum * 31 + result.getInt(1);
+ checksum = checksum * 31 + result.getString(2).length();
+ checksum = checksum * 31 + result.getLong(3);
+ checksum = checksum * 31 + Double.doubleToLongBits(result.getDouble(4));
+ rowCount++;
+ }
+
+ if (rowCount == 0) {
+ throw new SQLException("The analytical workload returned no rows");
+ }
+
+ return checksum;
+ }
+ }
+
+ static long consumeMaterialization(Statement statement) throws SQLException {
+ try (ResultSet result = statement.executeQuery(MATERIALIZATION_QUERY)) {
+ var rowCount = 0;
+ long checksum = 17;
+
+ while (result.next()) {
+ var id = result.getLong(1);
+ var eventDate = result.getDate(2).toLocalDate();
+ var eventTime = result.getTimestamp(3).toLocalDateTime();
+ var amount = result.getDouble(4);
+ var customer = result.getString(5);
+ var isActive = result.getBoolean(6);
+
+ checksum += id + eventDate.toEpochDay() + eventTime.getNano();
+ checksum += Double.doubleToLongBits(amount);
+ checksum += (customer == null ? 0 : customer.length()) + (isActive ? 1 : 0);
+ rowCount++;
+ }
+
+ if (rowCount != MATERIALIZATION_ROW_COUNT) {
+ throw new SQLException(
+ "Expected " + MATERIALIZATION_ROW_COUNT + " materialized rows, but read " + rowCount);
+ }
+
+ return checksum;
+ }
+ }
+
+ static long consumeTpch(Statement statement, int queryNumber) throws SQLException {
+ try (ResultSet result = statement.executeQuery("PRAGMA tpch(" + queryNumber + ")")) {
+ var rowCount = 0;
+ long checksum = 17;
+ var columnCount = result.getMetaData().getColumnCount();
+
+ while (result.next()) {
+ for (var column = 1; column <= columnCount; column++) {
+ var value = result.getObject(column);
+ checksum = checksum * 31 + (value == null ? 0 : value.hashCode());
+ }
+ rowCount++;
+ }
+
+ if (rowCount == 0) {
+ throw new SQLException("The TPC-H workload returned no rows");
+ }
+
+ return checksum;
+ }
+ }
+
+ static IngestRow[] createIngestRows() {
+ var rows = new IngestRow[INGEST_ROW_COUNT];
+ var start = LocalDateTime.of(2024, 1, 1, 0, 0);
+ var categories = new String[] {"north", "south", "east", "west"};
+
+ for (var index = 0; index < rows.length; index++) {
+ rows[index] = new IngestRow(
+ index,
+ start.plusSeconds(index),
+ index % 100000 / 100.0,
+ categories[index % categories.length],
+ index % 2 == 0);
+ }
+
+ return rows;
+ }
+
+ static void bindIngest(PreparedStatement statement, IngestRow row) throws SQLException {
+ statement.setLong(1, row.id());
+ statement.setTimestamp(2, Timestamp.valueOf(row.eventTime()));
+ statement.setDouble(3, row.amount());
+ statement.setString(4, row.category());
+ statement.setBoolean(5, row.isActive());
+ }
+
+ static void executeNonQuery(Connection connection, String sql) throws SQLException {
+ try (Statement statement = connection.createStatement()) {
+ statement.execute(sql);
+ }
+ }
+
+ record IngestRow(long id, LocalDateTime eventTime, double amount, String category, boolean isActive) {
+ }
+}
diff --git a/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/ResultMaterializationBenchmark.java b/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/ResultMaterializationBenchmark.java
new file mode 100644
index 00000000..7dd1cf4e
--- /dev/null
+++ b/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/ResultMaterializationBenchmark.java
@@ -0,0 +1,47 @@
+package org.duckdb.benchmarks;
+
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.concurrent.TimeUnit;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.annotations.Warmup;
+
+@State(Scope.Thread)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Fork(1)
+@Warmup(iterations = 5, time = 500, timeUnit = TimeUnit.MILLISECONDS)
+@Measurement(iterations = 10, time = 500, timeUnit = TimeUnit.MILLISECONDS)
+public class ResultMaterializationBenchmark {
+ private Connection connection;
+ private Statement statement;
+
+ @Setup(Level.Trial)
+ public void setup() throws SQLException {
+ connection = RealisticBenchmarkSupport.openConnection();
+ RealisticBenchmarkSupport.initializeMaterialization(connection);
+ statement = connection.createStatement();
+ }
+
+ @TearDown(Level.Trial)
+ public void cleanup() throws SQLException {
+ statement.close();
+ connection.close();
+ }
+
+ @Benchmark
+ public long readOneHundredThousandMixedRows() throws SQLException {
+ return RealisticBenchmarkSupport.consumeMaterialization(statement);
+ }
+}
diff --git a/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/TpchBenchmark.java b/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/TpchBenchmark.java
new file mode 100644
index 00000000..c91f6138
--- /dev/null
+++ b/DuckDB.Java.Benchmarks/src/main/java/org/duckdb/benchmarks/TpchBenchmark.java
@@ -0,0 +1,62 @@
+package org.duckdb.benchmarks;
+
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.concurrent.TimeUnit;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.annotations.Warmup;
+
+@State(Scope.Thread)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Fork(1)
+@Warmup(iterations = 5, time = 500, timeUnit = TimeUnit.MILLISECONDS)
+@Measurement(iterations = 10, time = 500, timeUnit = TimeUnit.MILLISECONDS)
+public class TpchBenchmark {
+ private Connection connection;
+ private Statement statement;
+
+ @Setup(Level.Trial)
+ public void setup() throws SQLException {
+ connection = RealisticBenchmarkSupport.openConnection();
+ RealisticBenchmarkSupport.initializeTpch(connection);
+ statement = connection.createStatement();
+ }
+
+ @TearDown(Level.Trial)
+ public void cleanup() throws SQLException {
+ statement.close();
+ connection.close();
+ }
+
+ @Benchmark
+ public long query01() throws SQLException {
+ return RealisticBenchmarkSupport.consumeTpch(statement, 1);
+ }
+
+ @Benchmark
+ public long query06() throws SQLException {
+ return RealisticBenchmarkSupport.consumeTpch(statement, 6);
+ }
+
+ @Benchmark
+ public long query12() throws SQLException {
+ return RealisticBenchmarkSupport.consumeTpch(statement, 12);
+ }
+
+ @Benchmark
+ public long query14() throws SQLException {
+ return RealisticBenchmarkSupport.consumeTpch(statement, 14);
+ }
+}
diff --git a/DuckDB.NET.1_5_3.Benchmarks/DuckDB.NET.1_5_3.Benchmarks.csproj b/DuckDB.NET.1_5_3.Benchmarks/DuckDB.NET.1_5_3.Benchmarks.csproj
new file mode 100644
index 00000000..b9955fa5
--- /dev/null
+++ b/DuckDB.NET.1_5_3.Benchmarks/DuckDB.NET.1_5_3.Benchmarks.csproj
@@ -0,0 +1,28 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ true
+ DuckDB.NET.1_5_3.Benchmarks
+ DuckDB.NET.Benchmarks
+ $(DefineConstants);DUCKDB_NET_BASELINE_1_5_3
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DuckDB.NET.Benchmarks/ListAppenderBenchmark.cs b/DuckDB.NET.Benchmarks/ListAppenderBenchmark.cs
new file mode 100644
index 00000000..5b89a881
--- /dev/null
+++ b/DuckDB.NET.Benchmarks/ListAppenderBenchmark.cs
@@ -0,0 +1,96 @@
+using BenchmarkDotNet.Attributes;
+using DuckDB.NET.Data;
+using System.Collections.ObjectModel;
+
+namespace DuckDB.NET.Benchmarks;
+
+[MemoryDiagnoser]
+public class ListAppenderBenchmark
+{
+ private const int ItemCount = 32;
+
+ private DuckDBConnection connection = null!;
+ private int[] arrayValues = null!;
+ private List listValues = null!;
+ private ReadOnlyCollection readOnlyValues = null!;
+
+ [Params(1_000_000)]
+ public int RowCount { get; set; }
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ connection = new DuckDBConnection("DataSource=:memory:");
+ connection.Open();
+ arrayValues = Enumerable.Range(0, ItemCount).ToArray();
+ listValues = arrayValues.ToList();
+ readOnlyValues = Array.AsReadOnly(arrayValues);
+ }
+
+ [GlobalCleanup]
+ public void Cleanup()
+ {
+ connection.Dispose();
+ }
+
+ [IterationSetup]
+ public void IterationSetup()
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = """
+ DROP TABLE IF EXISTS list_from_array;
+ DROP TABLE IF EXISTS list_from_list;
+ DROP TABLE IF EXISTS list_from_read_only_collection;
+ DROP TABLE IF EXISTS array_from_array;
+ CREATE TABLE list_from_array (values INTEGER[]);
+ CREATE TABLE list_from_list (values INTEGER[]);
+ CREATE TABLE list_from_read_only_collection (values INTEGER[]);
+ CREATE TABLE array_from_array (values INTEGER[32]);
+ """;
+ command.ExecuteNonQuery();
+ }
+
+ [Benchmark(Baseline = true)]
+ public void AppendListFromArray()
+ {
+ using var appender = connection.CreateAppender("list_from_array");
+
+ for (var index = 0; index < RowCount; index++)
+ {
+ appender.AppendRow(arrayValues, static (row, values) => row.AppendValue(values));
+ }
+ }
+
+ [Benchmark]
+ public void AppendListFromList()
+ {
+ using var appender = connection.CreateAppender("list_from_list");
+
+ for (var index = 0; index < RowCount; index++)
+ {
+ appender.AppendRow(listValues, static (row, values) => row.AppendValue(values));
+ }
+ }
+
+ [Benchmark]
+ public void AppendListFromReadOnlyCollection()
+ {
+ using var appender = connection.CreateAppender("list_from_read_only_collection");
+
+ for (var index = 0; index < RowCount; index++)
+ {
+ appender.AppendRow(readOnlyValues, static (row, values) => row.AppendValue(values));
+ }
+ }
+
+ [Benchmark]
+ public void AppendArrayFromArray()
+ {
+ using var appender = connection.CreateAppender("array_from_array");
+
+ for (var index = 0; index < RowCount; index++)
+ {
+ appender.AppendRow(arrayValues, static (row, values) => row.AppendValue(values));
+ }
+ }
+}
diff --git a/DuckDB.NET.Benchmarks/PreparedCommandBenchmark.cs b/DuckDB.NET.Benchmarks/PreparedCommandBenchmark.cs
index 6f35a47a..5db4c0b3 100644
--- a/DuckDB.NET.Benchmarks/PreparedCommandBenchmark.cs
+++ b/DuckDB.NET.Benchmarks/PreparedCommandBenchmark.cs
@@ -8,9 +8,11 @@ public class PreparedCommandBenchmark
{
private DuckDBConnection connection = null!;
private DuckDBCommand unpreparedCommand = null!;
+ private DuckDBCommand boxedPreparedCommand = null!;
private DuckDBCommand preparedCommand = null!;
private DuckDBParameter unpreparedParameter = null!;
- private DuckDBParameter preparedParameter = null!;
+ private DuckDBParameter boxedPreparedParameter = null!;
+ private DuckDBParameter preparedParameter = null!;
private int nextValue;
[GlobalSetup]
@@ -20,7 +22,9 @@ public void Setup()
connection.Open();
unpreparedCommand = CreateCommand(out unpreparedParameter);
- preparedCommand = CreateCommand(out preparedParameter);
+ boxedPreparedCommand = CreateCommand(out boxedPreparedParameter);
+ boxedPreparedCommand.Prepare();
+ preparedCommand = CreateTypedCommand(out preparedParameter);
preparedCommand.Prepare();
}
@@ -28,6 +32,7 @@ public void Setup()
public void Cleanup()
{
preparedCommand.Dispose();
+ boxedPreparedCommand.Dispose();
unpreparedCommand.Dispose();
connection.Dispose();
}
@@ -39,13 +44,29 @@ public int ExecuteUnprepared()
return (int)unpreparedCommand.ExecuteScalar()!;
}
+ [Benchmark]
+ public int ExecutePreparedBoxed()
+ {
+ boxedPreparedParameter.Value = nextValue++;
+ return (int)boxedPreparedCommand.ExecuteScalar()!;
+ }
+
[Benchmark]
public int ExecutePrepared()
{
- preparedParameter.Value = nextValue++;
+ preparedParameter.TypedValue = nextValue++;
return (int)preparedCommand.ExecuteScalar()!;
}
+ // Preserves the former ExecuteScalar path as a direct same-process comparison.
+ [Benchmark]
+ public int ExecutePreparedViaReader()
+ {
+ preparedParameter.Value = nextValue++;
+ using var reader = preparedCommand.ExecuteReader();
+ return reader.Read() ? (int)reader.GetValue(0) : default;
+ }
+
private DuckDBCommand CreateCommand(out DuckDBParameter changingParameter)
{
var command = connection.CreateCommand();
@@ -56,6 +77,17 @@ private DuckDBCommand CreateCommand(out DuckDBParameter changingParameter)
command.Parameters.Add(new DuckDBParameter("third", 3));
return command;
}
+
+ private DuckDBCommand CreateTypedCommand(out DuckDBParameter changingParameter)
+ {
+ var command = connection.CreateCommand();
+ command.CommandText = "SELECT $first::INTEGER + $second::INTEGER + $third::INTEGER";
+ changingParameter = new DuckDBParameter("first", 1);
+ command.Parameters.Add(changingParameter);
+ command.Parameters.Add(new DuckDBParameter("second", 2));
+ command.Parameters.Add(new DuckDBParameter("third", 3));
+ return command;
+ }
}
[MemoryDiagnoser]
diff --git a/DuckDB.NET.Benchmarks/PreparedCommandWorkload.cs b/DuckDB.NET.Benchmarks/PreparedCommandWorkload.cs
new file mode 100644
index 00000000..69c6bb2a
--- /dev/null
+++ b/DuckDB.NET.Benchmarks/PreparedCommandWorkload.cs
@@ -0,0 +1,33 @@
+using DuckDB.NET.Data;
+
+namespace DuckDB.NET.Benchmarks;
+
+internal static class PreparedCommandWorkload
+{
+ public const string Query = "SELECT $first::BIGINT + $second::BIGINT + $third::BIGINT";
+
+#if DUCKDB_NET_BASELINE_1_5_3
+ private const string ExpectedEngineVersion = "v1.5.3";
+#else
+ private const string ExpectedEngineVersion = "v1.5.4";
+#endif
+
+ public static DuckDBConnection OpenVerifiedConnection()
+ {
+ var connection = new DuckDBConnection("DataSource=:memory:");
+ connection.Open();
+
+ using var versionCommand = connection.CreateCommand();
+ versionCommand.CommandText = "SELECT version()";
+ var actualVersion = versionCommand.ExecuteScalar() as string;
+
+ if (!string.Equals(actualVersion, ExpectedEngineVersion, StringComparison.Ordinal))
+ {
+ connection.Dispose();
+ throw new InvalidOperationException(
+ $"The driver comparison requires DuckDB {ExpectedEngineVersion}, but loaded {actualVersion ?? "an unknown version"}.");
+ }
+
+ return connection;
+ }
+}
diff --git a/DuckDB.NET.Benchmarks/Program.cs b/DuckDB.NET.Benchmarks/Program.cs
index 141f9d98..198112cd 100644
--- a/DuckDB.NET.Benchmarks/Program.cs
+++ b/DuckDB.NET.Benchmarks/Program.cs
@@ -8,13 +8,35 @@
// while the project file stays Benchmarks.csproj, so BenchmarkDotNet's default toolchain
// can't locate the csproj. Run in-process to avoid the separate build/spawn step.
var config = DefaultConfig.Instance
- .AddJob(Job.Default.WithToolchain(InProcessEmitToolchain.Instance));
+ .AddJob(
+ Job.Default
+ .WithId("DriverComparison")
+ .WithToolchain(InProcessEmitToolchain.Instance)
+ .WithLaunchCount(1)
+ .WithWarmupCount(5)
+ .WithIterationCount(10)
+ .WithInvocationCount(1)
+ .WithUnrollFactor(1));
+
+var benchmarkTypes = new List
+{
+#if !DUCKDB_NET_BASELINE_1_5_3
+ typeof(AppenderBenchmark),
+ typeof(ListAppenderBenchmark),
+ typeof(MappedAppenderBenchmark),
+#endif
+ typeof(PreparedCommandBenchmark),
+ typeof(PreparedCommandSetupBenchmark),
+ typeof(AnalyticalQueryBenchmark),
+ typeof(ResultMaterializationBenchmark),
+ typeof(BulkIngestionBenchmark),
+ typeof(TpchBenchmark),
+};
+
+#if DUCKDB_EFCORE_PROVIDER_1_13_0
+benchmarkTypes.Add(typeof(EfCoreProviderBulkIngestionBenchmark));
+#endif
BenchmarkSwitcher
- .FromTypes([
- typeof(AppenderBenchmark),
- typeof(MappedAppenderBenchmark),
- typeof(PreparedCommandBenchmark),
- typeof(PreparedCommandSetupBenchmark),
- ])
+ .FromTypes(benchmarkTypes.ToArray())
.Run(args, config);
diff --git a/DuckDB.NET.Benchmarks/RealisticBenchmarks.cs b/DuckDB.NET.Benchmarks/RealisticBenchmarks.cs
new file mode 100644
index 00000000..933b4ae3
--- /dev/null
+++ b/DuckDB.NET.Benchmarks/RealisticBenchmarks.cs
@@ -0,0 +1,221 @@
+using BenchmarkDotNet.Attributes;
+using DuckDB.NET.Data;
+
+namespace DuckDB.NET.Benchmarks;
+
+[MemoryDiagnoser]
+public class AnalyticalQueryBenchmark
+{
+ private DuckDBConnection connection = null!;
+ private DuckDBCommand unpreparedCommand = null!;
+ private DuckDBCommand preparedCommand = null!;
+ private DuckDBParameter unpreparedCustomer = null!;
+ private DuckDBParameter preparedCustomer = null!;
+ private int nextCustomer;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ connection = RealisticWorkload.OpenConnection();
+ RealisticWorkload.InitializeAnalytics(connection);
+
+ unpreparedCommand = RealisticWorkload.CreateAnalyticsCommand(connection, out unpreparedCustomer);
+ preparedCommand = RealisticWorkload.CreateAnalyticsCommand(connection, out preparedCustomer);
+ preparedCommand.Prepare();
+ }
+
+ [GlobalCleanup]
+ public void Cleanup()
+ {
+ preparedCommand.Dispose();
+ unpreparedCommand.Dispose();
+ connection.Dispose();
+ }
+
+ [Benchmark(Baseline = true)]
+ public long ExecuteUnprepared()
+ {
+ unpreparedCustomer.Value = nextCustomer++ % 100;
+ return RealisticWorkload.ConsumeAnalytics(unpreparedCommand);
+ }
+
+ [Benchmark]
+ public long ExecutePrepared()
+ {
+ preparedCustomer.Value = nextCustomer++ % 100;
+ return RealisticWorkload.ConsumeAnalytics(preparedCommand);
+ }
+}
+
+[MemoryDiagnoser]
+public class ResultMaterializationBenchmark
+{
+ private DuckDBConnection connection = null!;
+ private DuckDBCommand command = null!;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ connection = RealisticWorkload.OpenConnection();
+ RealisticWorkload.InitializeMaterialization(connection);
+ command = connection.CreateCommand();
+ command.CommandText = RealisticWorkload.MaterializationQuery;
+ }
+
+ [GlobalCleanup]
+ public void Cleanup()
+ {
+ command.Dispose();
+ connection.Dispose();
+ }
+
+ [Benchmark]
+ public long ReadOneHundredThousandMixedRows() => RealisticWorkload.ConsumeMaterialization(command);
+}
+
+[MemoryDiagnoser]
+public class BulkIngestionBenchmark
+{
+ private DuckDBConnection connection = null!;
+ private DuckDBCommand preparedInsert = null!;
+ private DuckDBParameter idParameter = null!;
+ private DuckDBParameter eventTimeParameter = null!;
+ private DuckDBParameter amountParameter = null!;
+ private DuckDBParameter categoryParameter = null!;
+ private DuckDBParameter isActiveParameter = null!;
+ private IngestRow[] rows = null!;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ connection = RealisticWorkload.OpenConnection();
+ RealisticWorkload.InitializeIngest(connection);
+ rows = RealisticWorkload.CreateIngestRows();
+
+ preparedInsert = connection.CreateCommand();
+ preparedInsert.CommandText = RealisticWorkload.IngestStatement;
+ idParameter = new DuckDBParameter("id", 0L);
+ eventTimeParameter = new DuckDBParameter("eventTime", rows[0].EventTime);
+ amountParameter = new DuckDBParameter("amount", 0.0);
+ categoryParameter = new DuckDBParameter("category", rows[0].Category);
+ isActiveParameter = new DuckDBParameter("isActive", false);
+ preparedInsert.Parameters.Add(idParameter);
+ preparedInsert.Parameters.Add(eventTimeParameter);
+ preparedInsert.Parameters.Add(amountParameter);
+ preparedInsert.Parameters.Add(categoryParameter);
+ preparedInsert.Parameters.Add(isActiveParameter);
+ preparedInsert.Prepare();
+ }
+
+ [GlobalCleanup]
+ public void Cleanup()
+ {
+ preparedInsert.Dispose();
+ connection.Dispose();
+ }
+
+ [Benchmark(Baseline = true, OperationsPerInvoke = RealisticWorkload.IngestRowCount)]
+ public int InsertPreparedInTransaction()
+ {
+ using var transaction = connection.BeginTransaction();
+ preparedInsert.Transaction = transaction;
+
+ foreach (var row in rows)
+ {
+ idParameter.Value = row.Id;
+ eventTimeParameter.Value = row.EventTime;
+ amountParameter.Value = row.Amount;
+ categoryParameter.Value = row.Category;
+ isActiveParameter.Value = row.IsActive;
+ preparedInsert.ExecuteNonQuery();
+ }
+
+ transaction.Rollback();
+ preparedInsert.Transaction = null;
+ return rows.Length;
+ }
+
+ [Benchmark(OperationsPerInvoke = RealisticWorkload.IngestRowCount)]
+ public int InsertWithAppenderInTransaction()
+ {
+ using var transaction = connection.BeginTransaction();
+
+ using (var appender = connection.CreateAppender("benchmark_ingest"))
+ {
+ foreach (var row in rows)
+ {
+ appender.CreateRow()
+ .AppendValue(row.Id)
+ .AppendValue(row.EventTime)
+ .AppendValue(row.Amount)
+ .AppendValue(row.Category)
+ .AppendValue(row.IsActive)
+ .EndRow();
+ }
+ }
+
+ transaction.Rollback();
+ return rows.Length;
+ }
+
+#if !DUCKDB_NET_BASELINE_1_5_3
+ [Benchmark(OperationsPerInvoke = RealisticWorkload.IngestRowCount)]
+ public int InsertWithScopedAppenderInTransaction()
+ {
+ using var transaction = connection.BeginTransaction();
+
+ using (var appender = connection.CreateAppender("benchmark_ingest"))
+ {
+ foreach (var row in rows)
+ {
+ appender.AppendRowScoped(row, static (ref DuckDBAppenderRowWriter writer, IngestRow value) =>
+ {
+ writer.AppendValue(value.Id);
+ writer.AppendValue(value.EventTime);
+ writer.AppendValue(value.Amount);
+ writer.AppendValue(value.Category);
+ writer.AppendValue(value.IsActive);
+ });
+ }
+ }
+
+ transaction.Rollback();
+ return rows.Length;
+ }
+#endif
+}
+
+[MemoryDiagnoser]
+public class TpchBenchmark
+{
+ private DuckDBConnection connection = null!;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ connection = RealisticWorkload.OpenConnection();
+ RealisticWorkload.InitializeTpch(connection);
+ }
+
+ [GlobalCleanup]
+ public void Cleanup() => connection.Dispose();
+
+ [Benchmark]
+ public long Query01() => Execute(1);
+
+ [Benchmark]
+ public long Query06() => Execute(6);
+
+ [Benchmark]
+ public long Query12() => Execute(12);
+
+ [Benchmark]
+ public long Query14() => Execute(14);
+
+ private long Execute(int queryNumber)
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = $"PRAGMA tpch({queryNumber})";
+ return RealisticWorkload.ConsumeTpch(command);
+ }
+}
diff --git a/DuckDB.NET.Benchmarks/RealisticWorkload.cs b/DuckDB.NET.Benchmarks/RealisticWorkload.cs
new file mode 100644
index 00000000..c9ed3d7d
--- /dev/null
+++ b/DuckDB.NET.Benchmarks/RealisticWorkload.cs
@@ -0,0 +1,226 @@
+using System.Data;
+using DuckDB.NET.Data;
+
+namespace DuckDB.NET.Benchmarks;
+
+internal static class RealisticWorkload
+{
+ public const int AnalyticsRowCount = 2_000_000;
+ public const int MaterializationRowCount = 100_000;
+ public const int IngestRowCount = 10_000;
+ public const double TpchScaleFactor = 0.1;
+
+ public const string AnalyticsQuery = """
+ SELECT
+ year(order_date) AS order_year,
+ region,
+ count(*) AS order_count,
+ sum(amount) AS revenue
+ FROM benchmark_orders
+ WHERE customer_id = $customerId
+ AND order_date >= $fromDate
+ AND order_date < $toDate
+ GROUP BY order_year, region
+ ORDER BY order_year, region
+ """;
+
+ public const string MaterializationQuery = """
+ SELECT id, event_date, event_time, amount, customer_name, is_active
+ FROM benchmark_materialization
+ ORDER BY id
+ """;
+
+ public const string IngestStatement = """
+ INSERT INTO benchmark_ingest
+ VALUES ($id, $eventTime, $amount, $category, $isActive)
+ """;
+
+ public static readonly DateOnly AnalyticsFromDate = new(2020, 1, 1);
+ public static readonly DateOnly AnalyticsToDate = new(2024, 1, 1);
+
+ public static DuckDBConnection OpenConnection()
+ {
+ var connection = PreparedCommandWorkload.OpenVerifiedConnection();
+ ExecuteNonQuery(connection, "SET threads = 1");
+ return connection;
+ }
+
+ public static void InitializeAnalytics(DuckDBConnection connection)
+ {
+ ExecuteNonQuery(connection, $$"""
+ CREATE TABLE benchmark_orders AS
+ SELECT
+ i::BIGINT AS order_id,
+ (i % 10000)::INTEGER AS customer_id,
+ DATE '2020-01-01' + ((i % 1461)::INTEGER) AS order_date,
+ CASE i % 4
+ WHEN 0 THEN 'north'
+ WHEN 1 THEN 'south'
+ WHEN 2 THEN 'east'
+ ELSE 'west'
+ END::VARCHAR AS region,
+ ((i % 100000)::DOUBLE / 100.0) AS amount
+ FROM range({{AnalyticsRowCount}}) AS source(i)
+ """);
+ }
+
+ public static void InitializeMaterialization(DuckDBConnection connection)
+ {
+ ExecuteNonQuery(connection, $$"""
+ CREATE TABLE benchmark_materialization AS
+ SELECT
+ i::BIGINT AS id,
+ DATE '2020-01-01' + ((i % 1461)::INTEGER) AS event_date,
+ TIMESTAMP '2020-01-01 00:00:00' + ((i % 31536000) * INTERVAL 1 SECOND) AS event_time,
+ ((i % 100000)::DOUBLE / 100.0) AS amount,
+ CASE
+ WHEN i % 10 = 0 THEN NULL
+ ELSE ('customer-' || (i % 10000)::VARCHAR)
+ END::VARCHAR AS customer_name,
+ (i % 2 = 0) AS is_active
+ FROM range({{MaterializationRowCount}}) AS source(i)
+ """);
+ }
+
+ public static void InitializeIngest(DuckDBConnection connection)
+ {
+ ExecuteNonQuery(connection, """
+ CREATE TABLE benchmark_ingest(
+ id BIGINT,
+ event_time TIMESTAMP,
+ amount DOUBLE,
+ category VARCHAR,
+ is_active BOOLEAN
+ )
+ """);
+ }
+
+ public static void InitializeTpch(DuckDBConnection connection)
+ {
+ ExecuteNonQuery(connection, "INSTALL tpch");
+ ExecuteNonQuery(connection, "LOAD tpch");
+ ExecuteNonQuery(connection, $"CALL dbgen(sf = {TpchScaleFactor.ToString(System.Globalization.CultureInfo.InvariantCulture)})");
+ }
+
+ public static DuckDBCommand CreateAnalyticsCommand(DuckDBConnection connection, out DuckDBParameter customerParameter)
+ {
+ var command = connection.CreateCommand();
+ command.CommandText = AnalyticsQuery;
+ customerParameter = new DuckDBParameter("customerId", 0);
+ command.Parameters.Add(customerParameter);
+ command.Parameters.Add(new DuckDBParameter("fromDate", AnalyticsFromDate));
+ command.Parameters.Add(new DuckDBParameter("toDate", AnalyticsToDate));
+ return command;
+ }
+
+ public static long ConsumeAnalytics(DuckDBCommand command)
+ {
+ using var reader = command.ExecuteReader();
+ var rowCount = 0;
+ long checksum = 17;
+
+ while (reader.Read())
+ {
+ checksum = unchecked(checksum * 31 + reader.GetInt32(0));
+ checksum = unchecked(checksum * 31 + reader.GetString(1).Length);
+ checksum = unchecked(checksum * 31 + reader.GetInt64(2));
+ checksum = unchecked(checksum * 31 + BitConverter.DoubleToInt64Bits(reader.GetDouble(3)));
+ rowCount++;
+ }
+
+ if (rowCount == 0)
+ {
+ throw new InvalidOperationException("The analytical workload returned no rows.");
+ }
+
+ return checksum;
+ }
+
+ public static long ConsumeMaterialization(DuckDBCommand command)
+ {
+ using var reader = command.ExecuteReader();
+ var rowCount = 0;
+ long checksum = 17;
+
+ while (reader.Read())
+ {
+ var id = reader.GetInt64(0);
+ var eventDate = reader.GetFieldValue(1);
+ var eventTime = reader.GetDateTime(2);
+ var amount = reader.GetDouble(3);
+ var customerLength = reader.IsDBNull(4) ? 0 : reader.GetString(4).Length;
+ var isActive = reader.GetBoolean(5);
+
+ checksum = unchecked(checksum + id + eventDate.DayNumber + eventTime.Ticks);
+ checksum = unchecked(checksum + BitConverter.DoubleToInt64Bits(amount));
+ checksum = unchecked(checksum + customerLength + (isActive ? 1 : 0));
+ rowCount++;
+ }
+
+ if (rowCount != MaterializationRowCount)
+ {
+ throw new InvalidOperationException(
+ $"Expected {MaterializationRowCount} materialized rows, but read {rowCount}.");
+ }
+
+ return checksum;
+ }
+
+ public static long ConsumeTpch(DuckDBCommand command)
+ {
+ using var reader = command.ExecuteReader();
+ var rowCount = 0;
+ long checksum = 17;
+
+ while (reader.Read())
+ {
+ for (var column = 0; column < reader.FieldCount; column++)
+ {
+ var value = reader.GetValue(column);
+ checksum = unchecked(checksum * 31 + (value is DBNull ? 0 : value.GetHashCode()));
+ }
+
+ rowCount++;
+ }
+
+ if (rowCount == 0)
+ {
+ throw new InvalidOperationException("The TPC-H workload returned no rows.");
+ }
+
+ return checksum;
+ }
+
+ public static IngestRow[] CreateIngestRows()
+ {
+ var rows = new IngestRow[IngestRowCount];
+ var start = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Unspecified);
+ var categories = new[] { "north", "south", "east", "west" };
+
+ for (var index = 0; index < rows.Length; index++)
+ {
+ rows[index] = new IngestRow(
+ index,
+ start.AddSeconds(index),
+ index % 100000 / 100.0,
+ categories[index % categories.Length],
+ index % 2 == 0);
+ }
+
+ return rows;
+ }
+
+ public static void ExecuteNonQuery(DuckDBConnection connection, string sql)
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = sql;
+ command.ExecuteNonQuery();
+ }
+}
+
+internal readonly record struct IngestRow(
+ long Id,
+ DateTime EventTime,
+ double Amount,
+ string Category,
+ bool IsActive);
diff --git a/DuckDB.NET.Bindings/Bindings.csproj b/DuckDB.NET.Bindings/Bindings.csproj
index 05288209..cb88a774 100644
--- a/DuckDB.NET.Bindings/Bindings.csproj
+++ b/DuckDB.NET.Bindings/Bindings.csproj
@@ -7,6 +7,8 @@
Unofficial preview build of the DuckDB native bindings from the skuirrels DuckDB.NET fork.
Unofficial preview bindings for the consolidated DuckDB.NET performance work, bundling DuckDB v1.5.4.
+ Unofficial stable build of the DuckDB native bindings from the skuirrels DuckDB.NET fork.
+ Stable fork bindings for the consolidated DuckDB.NET performance work, bundling DuckDB v1.5.4.
DuckDB.NET.Native
win-x64;win-arm64;linux-x64;linux-arm64;osx
https://github.com/duckdb/duckdb/releases/download/v1.5.4
diff --git a/DuckDB.NET.Data/Data.csproj b/DuckDB.NET.Data/Data.csproj
index 36a1442b..0154d74d 100644
--- a/DuckDB.NET.Data/Data.csproj
+++ b/DuckDB.NET.Data/Data.csproj
@@ -13,6 +13,8 @@ Fixes:
Unofficial preview build of the DuckDB ADO.NET provider from the skuirrels DuckDB.NET fork.
Unofficial preview of the consolidated DuckDB.NET performance work for DuckDB v1.5.4. Do not reference this package alongside the official DuckDB.NET packages.
+ Unofficial stable build of the DuckDB ADO.NET provider from the skuirrels DuckDB.NET fork.
+ Stable fork release of the consolidated DuckDB.NET performance work for DuckDB v1.5.4. Do not reference this package alongside the official DuckDB.NET packages.
True
..\keyPair.snk
true
@@ -40,11 +42,11 @@ Fixes:
-
+
-
+
diff --git a/DuckDB.NET.Data/DataChunk/Writer/ListVectorDataWriter.cs b/DuckDB.NET.Data/DataChunk/Writer/ListVectorDataWriter.cs
index c56654dc..a51269c0 100644
--- a/DuckDB.NET.Data/DataChunk/Writer/ListVectorDataWriter.cs
+++ b/DuckDB.NET.Data/DataChunk/Writer/ListVectorDataWriter.cs
@@ -1,13 +1,27 @@
using DuckDB.NET.Data.Common;
+using System.Reflection;
+using System.Runtime.CompilerServices;
namespace DuckDB.NET.Data.DataChunk.Writer;
internal sealed unsafe class ListVectorDataWriter : VectorDataWriterBase
{
+ private delegate void CollectionWriter(ListVectorDataWriter writer, ICollection collection, ulong startIndex);
+
+ private static readonly ConditionalWeakTable CollectionWriterCache = new();
+ private static readonly MethodInfo WriteArrayMethod =
+ typeof(ListVectorDataWriter).GetMethod(nameof(WriteArray), BindingFlags.Static | BindingFlags.NonPublic)!;
+ private static readonly MethodInfo WriteListMethod =
+ typeof(ListVectorDataWriter).GetMethod(nameof(WriteList), BindingFlags.Static | BindingFlags.NonPublic)!;
+ private static readonly MethodInfo WriteEnumerableMethod =
+ typeof(ListVectorDataWriter).GetMethod(nameof(WriteEnumerable), BindingFlags.Static | BindingFlags.NonPublic)!;
+
private ulong offset = 0;
private readonly ulong arraySize;
private readonly DuckDBLogicalType childType;
private readonly VectorDataWriterBase listItemWriter;
+ private Type? cachedCollectionType;
+ private CollectionWriterPlan? cachedCollectionWriterPlan;
private bool IsList => ColumnType == DuckDBType.List;
private ulong vectorReservedSize = DuckDBGlobalData.VectorSize;
@@ -27,59 +41,17 @@ internal override bool AppendCollection(ICollection value, ulong rowIndex)
ResizeVector(rowIndex % DuckDBGlobalData.VectorSize, count);
- _ = value switch
- {
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
-
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
-
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
-
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
-
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable items => WriteItems(items),
- IEnumerable