Skip to content

perf(flow-php/etl): linear sort accumulation, working memory-to-exter…#2521

Closed
MrHDOLEK wants to merge 2 commits into
flow-php:1.xfrom
MrHDOLEK:perf/sort-linear-accumulation-and-fallback
Closed

perf(flow-php/etl): linear sort accumulation, working memory-to-exter…#2521
MrHDOLEK wants to merge 2 commits into
flow-php:1.xfrom
MrHDOLEK:perf/sort-linear-accumulation-and-fallback

Conversation

@MrHDOLEK

@MrHDOLEK MrHDOLEK commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Profiling against a 1M-row CSV showed that sortBy() with default settings is quadratic — and that the documented memory→external fallback does not exist:

  • MemorySort accumulated input with $mergedRows = $mergedRows->merge($batch); Rows::merge() re-copies every already-collected row on each call. File extractors emit
    one-row Rows batches, so sorting 1M rows meant ~1M merges and ~5·10¹¹ row-reference copies. Measured scaling: 25k → 3.5 s, 50k → 12.8 s, 100k → 66 s (~4–5× per doubling); 1M
    extrapolates to ~2 hours.
  • The default algorithm is named MEMORY_FALLBACK_EXTERNAL_SORT, but when MemorySort exceeded its limit it threw OutOfMemoryException that nothing caught — setting
    FLOW_SORT_MAX_MEMORY fatally crashed the pipeline instead of switching to external sort (verified empirically). With memory_limit=-1 the limit resolved to PHP_INT_MAX,
    so the external path effectively never ran.
  • The sort comparator resolved Row::valueOf() (a multi-level entry lookup) twice inside every one of the n·log n comparisons — 44% of sort wall time in cachegrind
    profiles.
  • Both sorters re-emitted sorted output in batches inherited from the input (1 row for file extractors), so everything downstream of sortBy() paid per-batch overhead once
    per row.

Changes

  • Linear accumulation: MemorySort collects rows into a plain buffer and builds a single Rows at the end, preserving partition semantics (partitions kept only when all
    batches share one partition id, empty batches transparent, empty input yields no batches). The same accumulate-by-merge pattern is fixed in CollectingProcessor,
    DataFrame::fetch() and ExternalSort::extractSortedBuckets().
  • Working fallback: OutOfMemoryException now carries the rows collected before the limit was hit; SortingProcessor catches it, skips the already-buffered batch of the
    partially-consumed generator, and finishes the job with external sort (buffered rows + remaining stream). MEMORY_SORT keeps throwing, as before.
  • Precomputed sort keys: Rows::sortBy()/sortAscending()/sortDescending() extract entry values once per row and sort an index array. The one-stable-pass-per-reference
    structure is kept deliberately: the comparator treats mixed-type pairs (e.g. null vs string) as equal, which is not transitive, so collapsing to a single lexicographic
    pass would change observable ordering — proven with a randomized differential test against the previous implementation (200 trials × 5 reference combos, orders identical).
  • Cheaper k-way merge: RowsMinHeap extracts sort key values once at insert and caches them on BucketRow (@internal) instead of resolving valueOf() per reference
    per heap comparison — the same precomputed-key pattern the external grouping work (Storage for Aggregating Functions #1390) uses in its BucketsMinHeap.
  • Saner batching: external sort run size raised from a hardcoded 500 to 10,000 rows (100 spill files instead of 2,000 at 1M rows, consistent with the external grouping
    run-size policy), batchSize tracks the maximum input batch, and both sorters emit output batches of at least 1,000 rows.

SortingProcessor::externalSort() is intentionally left untouched to avoid conflicting with #1390, which rewrites that method.

Benchmarks

1M-row CSV (57 MB, 2 string columns), PHP 8.3 + opcache, fresh process per run:

Scenario 1.x this PR
sortBy(), default batching, 100k rows 66.1 s 1.3 s
sortBy(), default batching, 1M rows ~2 h (extrapolated) 26 s
sortBy(), batchSize(10000), 1M rows 56.1 s 23 s
sortBy(), 1M rows, FLOW_SORT_MAX_MEMORY=256M fatal error completes: 262 s, 432 MB peak, all 1,000,000 rows

Tests & verification

  • New: fallback completes under a 2 MB sort memory limit with the full range(0, 2499) asserted (catches dropped or duplicated rows from the generator-resume logic);
    MEMORY_SORT still throws. Both #[RunInSeparateProcess] — real-usage memory accounting is unreliable in a shared PHPUnit process after the allocator reuses freed pages.
  • Full core ETL suite green (2,829 tests); randomized old-vs-new ordering differential test: 1,000 comparisons, identical orders; mago lint/analyze clean.

Related historical issues (both closed): #1116 (wrong ordering when external sort memory was exceeded), #1142 (external sort cache memory consumption). Not addressed here on
purpose: the spill format still serializes full Row object graphs (~43× size amplification) — that belongs in a shared codec with #1390's BucketRunCache after #1390 lands.

Resolves: no open roadmap item — standalone performance/correctness fix from a profiling audit; happy to open an issue if preferred

Change Log


Added

Fixed

  • MEMORY_FALLBACK_EXTERNAL_SORT now actually falls back to external sort when the sort memory limit is exceeded, instead of crashing with an uncaught OutOfMemoryException

Changed

  • MemorySort, CollectingProcessor, DataFrame::fetch() and ExternalSort output accumulate rows into a plain buffer instead of re-merging Rows per batch, removing quadratic copying when sorting or collecting large datasets
  • Rows::sortBy()/sortAscending()/sortDescending() extract sort values once per row instead of resolving them inside every comparison; RowsMinHeap caches sort keys per inserted row
  • External sort run size raised from 500 to 10,000 rows and sorted output is emitted in batches of at least 1,000 rows instead of inheriting 1-row input batches

Removed

Deprecated

Security

@codecov

codecov Bot commented Jul 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.45455% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.38%. Comparing base (53065fd) to head (6d81580).
⚠️ Report is 3 commits behind head on 1.x.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@             Coverage Diff              @@
##                1.x    #2521      +/-   ##
============================================
+ Coverage     86.27%   86.38%   +0.10%     
+ Complexity    22924    22901      -23     
============================================
  Files          1739     1740       +1     
  Lines         70415    70395      -20     
============================================
+ Hits          60753    60811      +58     
+ Misses         9662     9584      -78     
Components Coverage Δ
etl 90.05% <95.45%> (+0.19%) ⬆️
cli 89.40% <ø> (ø)
lib-array-dot 81.44% <ø> (ø)
lib-azure-sdk 64.44% <ø> (ø)
lib-doctrine-dbal-bulk 93.61% <ø> (ø)
lib-filesystem 86.04% <ø> (ø)
lib-types 90.18% <ø> (ø)
lib-parquet 70.23% <ø> (+0.12%) ⬆️
lib-parquet-viewer 82.26% <ø> (ø)
lib-snappy 89.82% <ø> (+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% <ø> (ø)
adapter-doctrine 90.79% <ø> (ø)
adapter-google-sheet 99.18% <ø> (ø)
adapter-http 73.23% <ø> (+0.89%) ⬆️
adapter-json 88.71% <ø> (ø)
adapter-logger 50.00% <ø> (ø)
adapter-parquet 83.16% <ø> (+5.45%) ⬆️
adapter-text 74.13% <ø> (ø)
adapter-xml 83.40% <ø> (ø)
adapter-avro 0.00% <ø> (ø)
adapter-excel 94.21% <ø> (ø)
adapter-postgresql 91.06% <ø> (ø)
adapter-seal 93.87% <ø> (+8.45%) ⬆️
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 11:36
@MrHDOLEK MrHDOLEK requested a review from norberttech as a code owner July 11, 2026 11:36
@norberttech norberttech added the hackaton-07-2026 A first Flow Hackaton with Stloyd and MrHDOLEK 🎉 label Jul 11, 2026
@norberttech norberttech added this to the 0.42.0 milestone Jul 11, 2026
@github-actions github-actions Bot added size: L and removed size: M labels Jul 11, 2026
…nal fallback and precomputed sort keys

Sorting a 1M-row CSV with default settings took hours and a configured
sort memory limit crashed the pipeline instead of falling back to
external sort.

- Rows::mergeAll(iterable) merges batches without the quadratic cost of
  calling merge() in a loop, preserving merge() partition semantics
  (pinned by a test comparing both against 10 batch combinations).
  MemorySort, CollectingProcessor, DataFrame::fetch() and
  ExternalSort::extractSortedBuckets() no longer accumulate by
  re-copying every already-collected row per batch.
- MEMORY_FALLBACK_EXTERNAL_SORT never actually fell back:
  OutOfMemoryException was uncaught. MemorySort wraps its input in a
  memory-guard generator, mergeAll attaches the rows collected so far
  to the rethrown exception, and SortingProcessor resumes external sort
  from those rows plus the remaining generator. MEMORY_SORT keeps
  throwing.
- Rows::sortBy/sortAscending/sortDescending extract entry values once
  per row instead of resolving Row::valueOf inside every comparison.
  The one-stable-pass-per-reference structure is kept deliberately:
  the comparator treats mixed-type pairs as equal, which is not
  transitive, so a single lexicographic pass would change observable
  ordering (verified with a randomized differential test against the
  previous implementation).
- RowsMinHeap caches sort key values on BucketRow at insert instead of
  re-resolving them per heap comparison; ExternalSort run size raised
  from hardcoded 500 to 10_000 and both sorters emit output batches of
  at least 1000 rows instead of inheriting 1-row input batches.
- New tests: fallback completes under a 2MB sort memory limit with no
  dropped or duplicated rows, MEMORY_SORT still throws; both run in
  separate processes because real-usage memory accounting is unreliable
  after allocator page reuse.

Measured on the 1M-row benchmark dataset: sortBy with default batching
66s -> 1.3s at 100k rows and ~2h (extrapolated) -> 26s at 1M rows;
FLOW_SORT_MAX_MEMORY=256M now completes at 432MB peak instead of a
fatal error.
@MrHDOLEK MrHDOLEK force-pushed the perf/sort-linear-accumulation-and-fallback branch from bb66ba1 to aaa2c7b Compare July 11, 2026 14:07
…d sort value comparator

External sort no longer re-encodes the whole dataset through identity
merge passes: single-bucket chunks pass through unchanged and the final
merge level streams straight to the consumer instead of being spilled
and re-read. At 1M rows with default settings this cuts Floe
encode/decode passes from 3+3 to 1+1 per row.

- ExternalSort output batch size comes from the existing
  externalSortBatchSize config knob instead of a new const plus
  max-input-batch tracking; run size becomes a constructor parameter
- SortingProcessor fallback yields collected rows directly, dropping
  the FALLBACK_BATCH_SIZE re-chunking pass; MemorySort output batching
  uses the same config knob
- Rows and RowsMinHeap share one ValueComparator, removing the second
  subtly different comparison ladder for the same sort
- CollectingExtractor uses Rows::mergeAll, the last quadratic
  accumulate-by-merge loop in core; dead SortBucketsExtractor removed
@MrHDOLEK MrHDOLEK closed this Jul 11, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in Roadmap Jul 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

hackaton-07-2026 A first Flow Hackaton with Stloyd and MrHDOLEK 🎉 size: L

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants