Skip to content

perf(flow-php/etl-adapter-csv): reuse a single row buffer and append … - #2518

Merged
norberttech merged 2 commits into
flow-php:1.xfrom
MrHDOLEK:perf/csv-loader-reusable-row-buffer
Jul 11, 2026
Merged

perf(flow-php/etl-adapter-csv): reuse a single row buffer and append …#2518
norberttech merged 2 commits into
flow-php:1.xfrom
MrHDOLEK:perf/csv-loader-reusable-row-buffer

Conversation

@MrHDOLEK

@MrHDOLEK MrHDOLEK commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Summary

CSVLoader::writeCSV() opened and closed a php://temp stream for every written row and resolved the destination stream through FilesystemStreams::writeTo() inside the
per-row loop
. Writing 1M rows meant:

  • 1,000,000 × fopen('php://temp') / fputcsv / stream_get_contents / fclose (~4M stream-API calls),
  • 1,000,000 × writeTo() — each re-running path validation, pathinfo() and a preg_match_all on loop-invariant arguments (visible in profiles as Path->uri() × 300k per 100k
    rows),
  • 1,000,000 × DestinationStream::append() — one fwrite per row.

The result: writing a CSV was ~80% more expensive than reading the same file. Found while profiling the full pipeline against a 1M-row dataset (Xdebug/cachegrind — the loader
machinery accounted for ~22% of self-time on the write path).

Changes

  • keep one lazily-opened php://temp buffer per loader instance, truncated and rewound between uses, released in the existing closure() hook (Loader\Closure); lazy re-open
    via is_resource() also keeps the loader safe to serialize,
  • collect the whole normalized batch in the buffer and append it to the destination stream once per Rows batch instead of once per row,
  • hoist the loop-invariant writeTo() call out of the row loop,
  • add a regression test asserting exact file bytes for rows of decreasing length across multiple batches (guards the ftruncate() between buffer reuses).

No public API changes. Output is byte-identical (verified with cmp on a 59.8 MB / 1M-row result file).

Benchmarks

1M-row CSV (57 MB, 2 string columns), PHP 8.3 + opcache, medians of 3 runs per config, each run in a fresh process:

Scenario 1.x this PR Δ
to_csv(), batchSize(1000) 10.39 s 6.20 s −40%
to_csv(), batchSize(10000) 10.84 s 6.67 s −39%
to_csv(), default batch (1 row) 13.88 s 13.44 s −3%

Pure write overhead (write minus read of the same file, batchSize(1000)): 4.68 s → 0.49 s (~10×) — writing now costs roughly the same as reading (+9%, previously +82%). After
the change, CSVLoader/writeTo/append no longer appear among the top functions in the cachegrind profile of the write path.

Peak memory is unchanged (16 MB at batchSize(1000); +2 MB at batchSize(10000) for the batch buffer).

The small gain at the default batch size is expected: with 1-row batches every per-batch cost is a per-row cost (load(), telemetry, writeTo, append still run once per row) —
that is a pipeline-level batching concern, independent of this loader fix.

Resolves: no related roadmap item — standalone performance fix, happy to open an issue if preferred

Change Log


Added

Fixed

Changed

  • CSVLoader reuses a single php://temp row buffer per loader instance (released in the closure() hook) instead of opening and closing a temporary stream for every written row
  • CSVLoader appends the whole normalized batch to the destination stream once per Rows batch instead of once per row, and resolves the destination stream once per batch instead of inside the per-row loop

Removed

Deprecated

Security

…per batch in CSVLoader

CSVLoader::writeCSV opened and closed a php://temp stream for every
written row and resolved the destination stream through
FilesystemStreams::writeTo() inside the per-row loop. At 1M rows that
meant ~4M stream-API calls and 1M redundant path validations, making
CSV writing more expensive than reading.

- keep one lazily-opened php://temp buffer per loader instance,
  truncated and rewound between uses, released in the existing
  closure() hook
- collect the whole normalized batch in the buffer and append it to
  the destination stream once per Rows batch instead of once per row
- hoist the loop-invariant writeTo() call out of the row loop
- add a regression test asserting exact file bytes for rows of
  decreasing length across multiple batches (guards the ftruncate
  between buffer reuses)

Measured on the 1M-row benchmark dataset: 11.1s -> 7.4s (-34%) with
batchSize(1000), byte-identical output (cmp on 59.8MB result).
…le-row-buffer

# Conflicts:
#	src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Integration/CSVTest.php
@codecov

codecov Bot commented Jul 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.33333% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.97%. Comparing base (b671d90) to head (b656b6e).

Additional details and impacted files
@@             Coverage Diff              @@
##                1.x    #2518      +/-   ##
============================================
  Coverage     85.96%   85.97%              
- Complexity        0    22261   +22261     
============================================
  Files          1663     1663              
  Lines         68406    68416      +10     
============================================
+ Hits          58807    58818      +11     
+ Misses         9599     9598       -1     
Components Coverage Δ
etl 88.55% <ø> (ø)
cli 89.40% <ø> (ø)
lib-array-dot 81.44% <ø> (ø)
lib-azure-sdk 64.44% <ø> (ø)
lib-doctrine-dbal-bulk 93.61% <ø> (ø)
lib-filesystem 85.97% <ø> (ø)
lib-types 90.16% <ø> (ø)
lib-parquet 70.10% <ø> (ø)
lib-parquet-viewer 82.26% <ø> (ø)
lib-snappy 89.38% <ø> (+0.44%) ⬆️
lib-dremel 0.00% <ø> (ø)
lib-postgresql 88.62% <ø> (ø)
lib-telemetry 86.58% <ø> (ø)
bridge-filesystem-async-aws 92.74% <ø> (ø)
bridge-filesystem-azure 90.45% <ø> (ø)
bridge-monolog-http 96.82% <ø> (ø)
bridge-monolog-telemetry 94.79% <ø> (ø)
bridge-openapi-specification 92.07% <ø> (ø)
symfony-http-foundation 78.57% <ø> (ø)
bridge-psr18-telemetry 100.00% <ø> (ø)
bridge-psr3-telemetry 98.95% <ø> (ø)
bridge-psr7-telemetry 100.00% <ø> (ø)
bridge-telemetry-otlp 90.41% <ø> (ø)
bridge-symfony-http-foundation-telemetry 92.85% <ø> (ø)
bridge-symfony-filesystem-bundle 90.66% <ø> (ø)
bridge-symfony-filesystem-cache 98.18% <ø> (ø)
bridge-symfony-postgresql-bundle 93.39% <ø> (ø)
bridge-symfony-postgresql-cache 94.41% <ø> (ø)
bridge-symfony-postgresql-messenger 98.80% <ø> (ø)
bridge-symfony-postgresql-session 93.65% <ø> (ø)
bridge-symfony-telemetry-bundle 90.23% <ø> (ø)
adapter-chartjs 84.05% <ø> (ø)
adapter-csv 91.66% <93.33%> (+0.23%) ⬆️
adapter-doctrine 90.79% <ø> (ø)
adapter-google-sheet 99.18% <ø> (ø)
adapter-http 72.34% <ø> (ø)
adapter-json 88.71% <ø> (ø)
adapter-logger 50.00% <ø> (ø)
adapter-parquet 77.70% <ø> (ø)
adapter-text 74.13% <ø> (ø)
adapter-xml 83.40% <ø> (ø)
adapter-avro 0.00% <ø> (ø)
adapter-excel 94.21% <ø> (ø)
adapter-postgresql 91.06% <ø> (ø)
adapter-seal 85.42% <ø> (ø)
bridge-phpunit-postgresql 75.30% <ø> (ø)
bridge-phpunit-telemetry 87.32% <ø> (ø)
bridge-phpstan-types 0.00% <ø> (ø)
bridge-postgresql-valinor 100.00% <ø> (ø)
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@MrHDOLEK
MrHDOLEK marked this pull request as ready for review July 11, 2026 10:08
@MrHDOLEK
MrHDOLEK requested a review from norberttech as a code owner July 11, 2026 10:08
@norberttech
norberttech merged commit 612872c into flow-php:1.x Jul 11, 2026
41 of 42 checks passed
@norberttech norberttech added this to the 0.42.0 milestone Jul 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants