Skip to content

Reuse appender row instance to eliminate per-row allocation#336

Open
skuirrels wants to merge 1 commit into
Giorgi:developfrom
skuirrels:perf/appender-row-allocation
Open

Reuse appender row instance to eliminate per-row allocation#336
skuirrels wants to merge 1 commit into
Giorgi:developfrom
skuirrels:perf/appender-row-allocation

Conversation

@skuirrels

Copy link
Copy Markdown
Contributor

What's Changed

DuckDBAppender.CreateRow() allocated a new DuckDBAppenderRow for every
appended row. In bulk-insert workloads that is one short-lived heap object per
row, adding sustained Gen0 GC pressure to the hottest loop in the library.

A row is always filled and EndRow()-ed before the next CreateRow() call, and
the table name, vector writers, data chunk and native appender are stable for
the lifetime of the appender. So a single DuckDBAppenderRow can be cached and
re-targeted at the next row via a new internal Reset(rowIndex) method instead
of being reallocated.

// DuckDBAppender.CreateRow()
if (currentRow is null)
{
    currentRow = new DuckDBAppenderRow(qualifiedTableName, vectorWriters, rowCount - 1, dataChunk, nativeAppender);
}
else
{
    currentRow.Reset(rowCount - 1); // rowIndex + columnIndex reset; other fields are stable
}

return currentRow;

Benchmark

Appending 1,000,000 rows × 4 columns (INTEGER, BIGINT, DOUBLE, BOOLEAN) into an
in-memory table. BenchmarkDotNet, .NET 10, Apple M4 Pro:

Metric Before After Change
Mean time 35.37 ms 25.96 ms −27%
Allocated 61.13 MB 92.84 KB −99.85%
Gen0 / 1000 ops 7000 0 eliminated

The 61 MB was almost entirely the per-row row object. Removing it also removes
the GC pressure (7000 → 0 Gen0 collections), which is what drives the wall-clock
speedup on top of the allocation win.

Behavior note

Because the row instance is now reused, a caller that holds onto a row reference
across a CreateRow() call will see it mutate. This matches the documented
create → append → EndRow() usage pattern (only one row is active at a time),
but it is a subtle change worth calling out.

Benchmarks project

Adds a small DuckDB.NET.Benchmarks project (BenchmarkDotNet) with the appender
benchmark above, so the improvement is reproducible and future regressions are
catchable. A couple of things I'd like your steer on:

  • It targets net10.0 only and disables assembly signing, unlike the other
    projects (multi-targeted + signed). Happy to align it if you'd prefer.
  • It uses BenchmarkDotNet's in-process toolchain because the repo's
    Directory.Build.props renames the output assembly (DuckDB.NET.Benchmarks)
    while the project file stays Benchmarks.csproj, which the default toolchain
    can't locate.

If you'd rather this PR not carry a new project at all, I can drop it and keep
just the DuckDB.NET.Data change.

Tests

The full existing suite (6,895 tests) still passes locally; the 66 appender
tests were run specifically against this change.

DuckDBAppender.CreateRow() allocated a new DuckDBAppenderRow for every
appended row. Since a row is always filled and EndRow()-ed before the next
CreateRow() call, and the table name, vector writers, data chunk and native
appender are stable for the appender's lifetime, a single row instance can be
cached and re-targeted at the next row index via a new Reset() method.

Benchmark (1,000,000 rows x 4 columns, in-memory table, .NET 10, M4 Pro):

  Before: 35.37 ms, 61.13 MB allocated, 7000 Gen0/1000 ops
  After:  25.96 ms,  0.09 MB allocated,    0 Gen0/1000 ops

~99.85% fewer managed allocations and ~27% faster from the removed GC
pressure. Adds a DuckDB.NET.Benchmarks project (BenchmarkDotNet, in-process
toolchain) covering the appender and mapped-appender paths.
@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.61%. Comparing base (6f5a68d) to head (1f7d0f5).

Additional details and impacted files
@@             Coverage Diff             @@
##           develop     #336      +/-   ##
===========================================
+ Coverage    87.58%   87.61%   +0.02%     
===========================================
  Files           77       77              
  Lines         3134     3140       +6     
  Branches       463      464       +1     
===========================================
+ Hits          2745     2751       +6     
  Misses         275      275              
  Partials       114      114              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@skuirrels

Copy link
Copy Markdown
Contributor Author

Just flagging that the macOS red X isn't from this change — it's the Coveralls upload step, which dies trying to brew install its reporter ("Refusing to load formula ... from untrusted tap"). Homebrew changed how it trusts taps and the coveralls action hasn't caught up, so it's failing on macOS for any PR right now, not just this one. The build and tests themselves pass fine on macOS, and Ubuntu/Windows are both green.

Happy to send a small CI fix for it separately if that's helpful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant