diff --git a/src/core/etl/src/Flow/ETL/DSL/functions.php b/src/core/etl/src/Flow/ETL/DSL/functions.php index 8f6c46630c..9ad482fe86 100644 --- a/src/core/etl/src/Flow/ETL/DSL/functions.php +++ b/src/core/etl/src/Flow/ETL/DSL/functions.php @@ -434,7 +434,7 @@ function filesystem_cache( #[DocumentationDSL(module: Module::CORE, type: DSLType::EXTRACTOR)] function batched_by(Extractor $extractor, string|Reference $column, ?int $min_size = null): BatchByExtractor { - // @mago-ignore analysis:invalid-operand + // @mago-ignore analysis:invalid-operand,impossible-condition,redundant-comparison,redundant-logical-operation if ($min_size !== null && $min_size <= 0) { throw new InvalidArgumentException('Minimum batch size must be greater than 0, given: ' . $min_size); } diff --git a/src/core/etl/src/Flow/ETL/Extractor/BatchByExtractor.php b/src/core/etl/src/Flow/ETL/Extractor/BatchByExtractor.php index 0cc3e2dbba..24603ce53b 100644 --- a/src/core/etl/src/Flow/ETL/Extractor/BatchByExtractor.php +++ b/src/core/etl/src/Flow/ETL/Extractor/BatchByExtractor.php @@ -25,7 +25,7 @@ public function __construct( private Reference $column, private ?int $minSize = null, ) { - // @mago-ignore analysis:invalid-operand + // @mago-ignore analysis:invalid-operand,impossible-condition,redundant-comparison,redundant-logical-operation if ($this->minSize !== null && $this->minSize <= 0) { throw new InvalidArgumentException('Minimum batch size must be greater than 0, given: ' . $this->minSize); } diff --git a/src/core/etl/src/Flow/ETL/Processor/BatchingByProcessor.php b/src/core/etl/src/Flow/ETL/Processor/BatchingByProcessor.php index bbf67f2304..7bfba9e57e 100644 --- a/src/core/etl/src/Flow/ETL/Processor/BatchingByProcessor.php +++ b/src/core/etl/src/Flow/ETL/Processor/BatchingByProcessor.php @@ -33,7 +33,7 @@ public function __construct( private Reference $column, private ?int $minSize = null, ) { - // @mago-ignore analysis:invalid-operand + // @mago-ignore analysis:invalid-operand,impossible-condition,redundant-comparison,redundant-logical-operation if ($this->minSize !== null && $this->minSize <= 0) { throw new InvalidArgumentException('Minimum batch size must be greater than 0, given: ' . $this->minSize); } diff --git a/src/core/etl/src/Flow/ETL/Processor/BatchingProcessor.php b/src/core/etl/src/Flow/ETL/Processor/BatchingProcessor.php index d5fb60ad88..40673b5862 100644 --- a/src/core/etl/src/Flow/ETL/Processor/BatchingProcessor.php +++ b/src/core/etl/src/Flow/ETL/Processor/BatchingProcessor.php @@ -29,7 +29,7 @@ public function __construct( private int $size, ) { - // @mago-ignore analysis:invalid-operand + // @mago-ignore analysis:invalid-operand,impossible-condition,redundant-comparison if ($this->size <= 0) { throw new InvalidArgumentException('Batch size must be greater than 0, given: ' . $this->size); } diff --git a/src/core/etl/src/Flow/ETL/Transformation/BatchBy.php b/src/core/etl/src/Flow/ETL/Transformation/BatchBy.php index 114ca2704e..d27bd9ae4e 100644 --- a/src/core/etl/src/Flow/ETL/Transformation/BatchBy.php +++ b/src/core/etl/src/Flow/ETL/Transformation/BatchBy.php @@ -20,7 +20,7 @@ public function __construct( private string|Reference $column, private ?int $minSize = null, ) { - // @mago-ignore analysis:invalid-operand + // @mago-ignore analysis:invalid-operand,impossible-condition,redundant-comparison,redundant-logical-operation if ($this->minSize !== null && $this->minSize <= 0) { throw new InvalidArgumentException('Minimum batch size must be greater than 0, given: ' . $this->minSize); } diff --git a/src/core/etl/src/Flow/Floe/Decoding/JsonDecoder.php b/src/core/etl/src/Flow/Floe/Decoding/JsonDecoder.php index 695d23faac..73582d409d 100644 --- a/src/core/etl/src/Flow/Floe/Decoding/JsonDecoder.php +++ b/src/core/etl/src/Flow/Floe/Decoding/JsonDecoder.php @@ -26,9 +26,7 @@ public function __construct() $create = Closure::bind( static function (string $value, bool $isObject) use ($reflection): Json { $json = $reflection->newInstanceWithoutConstructor(); - // @mago-ignore analysis:invalid-property-write $json->value = $value; - // @mago-ignore analysis:invalid-property-write $json->isObject = $isObject; return $json; diff --git a/src/core/etl/src/Flow/Floe/Decoding/UuidDecoder.php b/src/core/etl/src/Flow/Floe/Decoding/UuidDecoder.php index 99a2478bb1..2d0d24caca 100644 --- a/src/core/etl/src/Flow/Floe/Decoding/UuidDecoder.php +++ b/src/core/etl/src/Flow/Floe/Decoding/UuidDecoder.php @@ -25,7 +25,6 @@ public function __construct() $create = Closure::bind( static function (string $value) use ($reflection): Uuid { $uuid = $reflection->newInstanceWithoutConstructor(); - // @mago-ignore analysis:invalid-property-write $uuid->value = $value; return $uuid; diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Client/Types/PostgreSqlVersion.php b/src/lib/postgresql/src/Flow/PostgreSql/Client/Types/PostgreSqlVersion.php index 0b1de1f30c..c159f5ecb3 100644 --- a/src/lib/postgresql/src/Flow/PostgreSql/Client/Types/PostgreSqlVersion.php +++ b/src/lib/postgresql/src/Flow/PostgreSql/Client/Types/PostgreSqlVersion.php @@ -35,6 +35,7 @@ public function supportsJsonb(): bool public function supportsJsonPath(): bool { + // @mago-ignore analysis:redundant-comparison return $this->value >= self::V12->value; // Added in PostgreSQL 12 } diff --git a/web/landing/assets/images/blog/performance-optimizations/after.png b/web/landing/assets/images/blog/performance-optimizations/after.png new file mode 100644 index 0000000000..66baefddd5 Binary files /dev/null and b/web/landing/assets/images/blog/performance-optimizations/after.png differ diff --git a/web/landing/assets/images/blog/performance-optimizations/before.png b/web/landing/assets/images/blog/performance-optimizations/before.png new file mode 100644 index 0000000000..5e47b944a5 Binary files /dev/null and b/web/landing/assets/images/blog/performance-optimizations/before.png differ diff --git a/web/landing/assets/images/blog/performance-optimizations/placeholder.svg b/web/landing/assets/images/blog/performance-optimizations/placeholder.svg new file mode 100644 index 0000000000..f83ff2f343 --- /dev/null +++ b/web/landing/assets/images/blog/performance-optimizations/placeholder.svg @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/landing/assets/styles/app.css b/web/landing/assets/styles/app.css index d17bc1c0b8..2847b581b2 100644 --- a/web/landing/assets/styles/app.css +++ b/web/landing/assets/styles/app.css @@ -139,6 +139,10 @@ code { @apply font-semibold text-xl text-slate-900 mt-7 mb-2 dark:text-white; } +#blog-post h4 { + @apply font-semibold text-lg text-slate-900 mt-6 mb-2 dark:text-white; +} + #blog-post p, #blog-post ul, #blog-post ol { @@ -176,6 +180,21 @@ code { dark:border-blue-100 dark:text-white/75; } +#blog-post .callout-info { + @apply border-l-2 border-blue-300 bg-blue-300/[0.04] rounded-r-lg px-4 py-3 my-5 text-slate-700 + dark:border-blue-100 dark:bg-blue-100/[0.08] dark:text-white/85; +} + +#blog-post .callout-warning { + @apply border-l-2 border-brown-200 bg-brown-100/[0.06] rounded-r-lg px-4 py-3 my-5 text-slate-700 + dark:border-brown-100 dark:bg-brown-100/[0.08] dark:text-white/85; +} + +#blog-post .callout-danger { + @apply border-l-2 border-orange-200 bg-orange-100/[0.05] rounded-r-lg px-4 py-3 my-5 text-slate-700 + dark:border-orange-100 dark:bg-orange-100/[0.08] dark:text-white/85; +} + #blog-post table { @apply w-full table-auto my-5 border-collapse rounded-lg overflow-hidden border border-slate-200 dark:border-white/10; @@ -190,6 +209,14 @@ code { @apply border-b border-slate-200 px-3 py-2 dark:border-white/10; } +#blog-post .highlight.green { + @apply bg-green-500/[0.08] dark:bg-green-400/[0.1]; +} + +#blog-post .highlight.red { + @apply bg-red-500/[0.08] dark:bg-red-400/[0.1]; +} + #blog-post img { @apply max-w-full h-auto rounded-lg my-6 mx-auto; } diff --git a/web/landing/src/Flow/Website/Blog/Posts.php b/web/landing/src/Flow/Website/Blog/Posts.php index a9428f1f50..4357cf4ee1 100644 --- a/web/landing/src/Flow/Website/Blog/Posts.php +++ b/web/landing/src/Flow/Website/Blog/Posts.php @@ -45,6 +45,12 @@ final class Posts 'date' => '2026-06-29', 'slug' => 'flow-php-release-0410', ], + [ + 'title' => 'Performance Optimizations', + 'description' => 'Floe is a dedicated binary format, created to speedup serialization and reduce serialized content size of Rows.', + 'date' => '2026-07-23', + 'slug' => 'performance-optimizations', + ], ]; /** diff --git a/web/landing/src/Flow/Website/Controller/BlogController.php b/web/landing/src/Flow/Website/Controller/BlogController.php index f18201735e..8a9abfb81b 100644 --- a/web/landing/src/Flow/Website/Controller/BlogController.php +++ b/web/landing/src/Flow/Website/Controller/BlogController.php @@ -29,4 +29,15 @@ public function posts(): Response 'posts' => (new Posts())->all(), ]); } + + #[Route('/rss.xml', name: 'blog_rss')] + public function rss(): Response + { + $response = $this->render('blog/rss.xml.twig', [ + 'posts' => (new Posts())->all(), + ]); + $response->headers->set('Content-Type', 'application/rss+xml; charset=UTF-8'); + + return $response; + } } diff --git a/web/landing/templates/base.html.twig b/web/landing/templates/base.html.twig index a4e8f1cdf7..937c7681b5 100644 --- a/web/landing/templates/base.html.twig +++ b/web/landing/templates/base.html.twig @@ -45,6 +45,8 @@ + + {% block stylesheets %} diff --git a/web/landing/templates/blog/post.html.twig b/web/landing/templates/blog/post.html.twig index 608b46a367..f6678149c1 100644 --- a/web/landing/templates/blog/post.html.twig +++ b/web/landing/templates/blog/post.html.twig @@ -12,13 +12,21 @@ {% block main %}
- +
+ + + + + RSS + +
{{ block('article') }} diff --git a/web/landing/templates/blog/posts.html.twig b/web/landing/templates/blog/posts.html.twig index 10a8456e1f..6f42e2f970 100644 --- a/web/landing/templates/blog/posts.html.twig +++ b/web/landing/templates/blog/posts.html.twig @@ -21,6 +21,11 @@

Release notes, design decisions, and stories from building Flow.

+ + + Subscribe via RSS +
diff --git a/web/landing/templates/blog/posts/2026-07-23/performance-optimizations/benchmark.php b/web/landing/templates/blog/posts/2026-07-23/performance-optimizations/benchmark.php new file mode 100644 index 0000000000..dde649cded --- /dev/null +++ b/web/landing/templates/blog/posts/2026-07-23/performance-optimizations/benchmark.php @@ -0,0 +1,8 @@ +read(from_xxx()) + ->batchSize(1000) + ->run(); \ No newline at end of file diff --git a/web/landing/templates/blog/posts/2026-07-23/performance-optimizations/encoder.php b/web/landing/templates/blog/posts/2026-07-23/performance-optimizations/encoder.php new file mode 100644 index 0000000000..0cab32540f --- /dev/null +++ b/web/landing/templates/blog/posts/2026-07-23/performance-optimizations/encoder.php @@ -0,0 +1,25 @@ + $batch + * + * @return list + */ + public function encode(array $batch): array; + + /** + * @param list $batch + * + * @return list + */ + public function decode(array $batch): array; +} diff --git a/web/landing/templates/blog/posts/2026-07-23/performance-optimizations/hydrator.php b/web/landing/templates/blog/posts/2026-07-23/performance-optimizations/hydrator.php new file mode 100644 index 0000000000..a6945a8a6e --- /dev/null +++ b/web/landing/templates/blog/posts/2026-07-23/performance-optimizations/hydrator.php @@ -0,0 +1,26 @@ + $batch + */ + public function cast(array $batch, ?Schema $schema = null): Rows; + + /** + * @return list + */ + public function dehydrate(Rows $rows): array; + + /** + * @param list $batch + */ + public function hydrate(array $batch, ?Schema $schema = null): Rows; +} diff --git a/web/landing/templates/blog/posts/2026-07-23/performance-optimizations/post.html.twig b/web/landing/templates/blog/posts/2026-07-23/performance-optimizations/post.html.twig new file mode 100644 index 0000000000..ffdc8b8ed0 --- /dev/null +++ b/web/landing/templates/blog/posts/2026-07-23/performance-optimizations/post.html.twig @@ -0,0 +1,514 @@ +{% extends 'blog/post.html.twig' %} +{% block article %} +
+

Performance Optimizations

+
+

+ From July 10 to 12, we organized the first Flow PHP hackathon together with + Józef (@stloyd) + and Aleksander (@MrHDOLEK). +

+

+ We had one goal: improve general Flow performance. We created a dedicated GitHub label for the event that + groups together all the work we pushed through those 3 days. Those changes also pushed me to redesign Flow's + serialization/hydration architecture and invent a new binary data format, called Floe. +

+

+ GitHub Label: hackaton-07-2026 +

+

Benchmarks

+

+ Before I explain what and how we changed, take a look at the numbers. +

+

+ Benchmarking Method
+ 100k rows, same dataset files, same machine, PHP 8.3 + opcache, phpbench (mode of 3 iterations, 1 warmup). + Delta = (1.0 - 0.41.1) / 0.41.1; negative is better.
+ 1.0 pipelines read input from Floe format, + 0.41.1 from Parquet (Floe did not exist yet); timings include input reading. +

+ +
{% apply escape %}{% include template_folder ~ '/benchmark.php' %}{% endapply %}
+

DataFrame Operations

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BenchmarkRowsTime 0.41.1Time 1.0Mem 0.41.1Mem 1.0Δ timeΔ mem
Join inner100k8.03s3.35s44.9mb30.8mb-58.3%-31.4%
Join left100k8.23s3.41s44.9mb30.8mb-58.6%-31.4%
Join left anti100k7.74s2.53s44.9mb29.5mb-67.3%-34.4%
Join right100k8.27s3.33s44.9mb30.9mb-59.7%-31.3%
Cross join100k5.26s1.36s45.7mb31.1mb-74.2%-32.0%
Join each100k6.32s1.77s45.8mb30.0mb-71.9%-34.6%
Sort (external)100k25.08s2.64s115.0mb93.3mb-89.5%-18.9%
Group by100k4.59s1.32s44.1mb23.9mb-71.2%-45.9%
Group by + aggregates100k5.85s1.85s44.1mb24.1mb-68.4%-45.5%
Filter100k4.96s0.87s36.9mb23.7mb-82.4%-35.7%
Schema inference100k6.85s1.61s44.8mb29.0mb-76.5%-35.2%
Cache write100k5.91s2.10s63.6mb34.9mb-64.5%-45.2%
Cache read100k4.37s0.62s133.7mb34.9mb-85.8%-73.9%
+

Data Formats & Storage

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BenchmarkRowsTime 0.41.1Time 1.0Mem 0.41.1Mem 1.0Δ timeΔ mem
CSV read100k2.52s2.57s9.1mb9.6mb+1.9%+5.2%
CSV write100k5.40s1.32s22.8mb23.4mb-75.6%+2.5%
JSON read100k4.29s3.91s9.1mb10.1mb-8.9%+10.9%
JSON write100k5.35s1.58s22.8mb24.3mb-70.5%+6.5%
Parquet read (php)100k6.60s3.97s210.1mb216.4mb-39.9%+3.0%
Parquet read (arrow)100k3.82s1.42s22.7mb23.1mb-62.8%+1.7%
Parquet write (php)100k17.82s4.22s212.3mb45.8mb-76.3%-78.4%
Parquet write (arrow)100k5.79s1.79s22.9mb23.6mb-69.0%+3.3%
XML read100k4.56s4.59s105.8mb105.7mb+0.5%-0.1%
XML write100k14.76s6.24s22.8mb23.7mb-57.7%+4.1%
Excel read100k14.21s13.01s9.1mb9.7mb-8.5%+7.1%
Excel write100k11.66s7.40s22.9mb22.8mb-36.5%-0.6%
Text read100k0.50s0.21s9.1mb8.9mb-57.5%-1.9%
Text write100k4.88s0.84s22.8mb17.5mb-82.7%-23.2%
PostgreSQL write100k8.57s5.37s45.0mb33.3mb-37.4%-26.0%
PostgreSQL read100k8.05s7.06s9.1mb10.9mb-12.3%+19.9%
Doctrine write100k9.61s6.10s44.6mb33.3mb-36.5%-25.3%
Doctrine read100k6.61s6.32s9.1mb10.3mb-4.4%+13.1%
+

+ + Parquet engines: both versions ship php and arrow engines. 1.0 pins the engine explicitly per benchmark; + 0.41.1 auto-selects (arrow whenever ext-arrow is loaded), so its php rows were measured with ext-arrow + disabled and its arrow rows with ext-arrow loaded. On 0.41.1 the selected engine applies to both the + input + read and the write inside the write benchmark; on 1.0 the write benchmark reads its input from Floe. + +

+

Floe

+
+ A floe (or ice floe) is a large, flat sheet of ice floating on the ocean, a river, or a lake. +
+

+ Floe is a dedicated, immutable binary data format created to speed up serialization/hydration of Flow Rows. + It combines ideas from Parquet & Avro, optimized for PHP and Flow Rows. +

+

+ Floe is a row-based binary format, with metadata stored as JSON in the footer. It is self-descriptive, which + means the format provides a schema (also in the footer) that supports deeply nested data structures like + lists/rows/maps. +

+

+ Floe also supports schema evolution without breaking backward compatibility. This means we can add new + columns between write batches, as long as those columns are nullable. +

+ +

Encoders and Hydrator

+ +

+ Introducing Floe was just the first step to optimize performance. It allowed us to replace the old Serializer + based on PHP's serialize & unserialize functions. +

+

+ The diagram below shows how Flow used to read and convert data from datasets into Rows, and then back to the + format expected by the destination storage. +

+
+ Flow workflow before Encoders / Hydrators contracts +
Flow workflow before Encoders / Hydrators contracts
+
+

+ As you can see, EntryFactory was responsible for encoding the values coming from a data source and casting them + to a specific schema, and then each loader converted them back to whatever format the destination storage expected. +

+

+ That was one of our biggest bottlenecks. Because of how generic it was, EntryFactory always cast data into a + specific format, regardless of which data format it was reading from. You might ask: what is wrong with this approach? + Most data formats like CSV, JSON or PostgreSQL return data as strings or simple scalar values anyway. +

+

+ That is correct, but there are also data formats that are self-descriptive (they come with a schema), like Parquet or Floe. + For those, casting — especially of nested data structures like lists / structures / maps — was redundant. That redundancy + is not so painful when working with a few thousand rows, but once we move beyond 100k, the pain becomes real. +

+

+ So with our new architecture, each data format provides its own dedicated Encoder/Decoder, which internally decides + how to handle data just after reading it from the source and just before we write it to the destination. +

+
{% apply escape %}{% include template_folder ~ '/encoder.php' %}{% endapply %}
+ +

+ Once our data is encoded into its plain PHP representation, we pass it to the Hydrator, whose only goal is to create the Rows instances + based on the passed RawRowValues. +

+ +
{% apply escape %}{% include template_folder ~ '/hydrator.php' %}{% endapply %}
+ +

+ This separation also allowed us to create two instances of the Hydrator: one based on PHP that internally uses EntryFactory, and a + Rust-based hydrator available when flow-php/flow-ext is installed. +

+ +

+ The Hydrator is not the only place where we managed to leverage the PHP extension. We used a similar pattern (php / native) for Floe's dedicated Encoder, + AdaptiveFloeEncoder, + which let us squeeze out even more performance. +

+
+ Flow workflow after Encoders / Hydrators contracts +
Flow workflow after Encoders / Hydrators contracts
+
+

Caching / Sorting / Grouping / Joining

+

+ Since all the mechanisms mentioned above need to dump Rows from memory to disk (or other storage) at some point, Floe became + the default serialization format, which allowed us to significantly improve the performance of those operations. +

+

+ But we had to slightly change the Serializer contract, which previously attempted to serialize/unserialize any object. + Now it focuses only on Rows, which can really represent any data structure. +

+
{% apply escape %}{% include template_folder ~ '/serializer.php' %}{% endapply %}
+

+ The new serialization contract also expects source/destination streams to read from and write to. +

+
+

Bucketing

+

+ The final step of this refactoring was improving bucketing (breaking the dataset into smaller buckets to reduce memory consumption). +

+

+ Sort / Join / Group By - all 3 are directly affected by those changes, and can now operate on datasets of any size. + I believe there is still room for optimization, especially around Sort, which currently still takes around 100mb of RAM to sort 100k rows. +

+

+ This is also not the final performance we are aiming for. All of the benchmarks above are executed in a single process. + True scalability will come with parallel processing, and that's another area where Floe will shine, since we'll be able to + move (shuffle) data between workers faster. +

+

+ Using PHP's native serializer, 100k rows produced a 1.5GB string; with Floe we managed to drop that to 36MB — and this is still before compression! +

+

+ The goal is to implement different compression algorithms and let the Floe reader/writer use them, like Snappy, which is pretty much the + industry standard today. +

+

The end goal

+

+ These numbers might not feel very impressive yet, but joining 100k rows in under 3 seconds with only 30MB of RAM might + already be a good reason to move some processes outside the database and work on plain files. +

+

+ Flow's true performance boost will come with parallel processing, which will make speed depend on another variable — + the number of processes, and maybe even nodes, that can process ETL pipelines in parallel. +

+

+ That's our final goal, and I hope it will make Flow a real alternative to other data processing frameworks. +

+ +
+{% endblock %} diff --git a/web/landing/templates/blog/posts/2026-07-23/performance-optimizations/serializer.php b/web/landing/templates/blog/posts/2026-07-23/performance-optimizations/serializer.php new file mode 100644 index 0000000000..ebf662895c --- /dev/null +++ b/web/landing/templates/blog/posts/2026-07-23/performance-optimizations/serializer.php @@ -0,0 +1,26 @@ + +{%- autoescape 'html' -%} + + + Flow PHP Blog + {{ url('blog') }} + + Articles, releases and guides from the Flow PHP data processing framework. + en + {%- if posts|length %} + {{ (posts|first).date|date('r') }} + {%- endif %} + {%- for post in posts %} + + {{ post.title }} + {{ url('blog_post', {date: post.date|date('Y-m-d'), slug: post.slug}) }} + {{ url('blog_post', {date: post.date|date('Y-m-d'), slug: post.slug}) }} + {{ post.description }} + {{ post.date|date('r') }} + + {%- endfor %} + + +{%- endautoescape -%}