Benchmarks and verification
Performance
How the benchmarks run
- Hardware: an Apple M5 Ultra.
- Software: Homebrew PHP 8.5.10 on macOS, and phpolars built at the commit given with each table.
- Threads: Polars limited to its default of 2 threads unless a row says otherwise.
- Repetitions: each implementation runs several times in one process, and the table reports the fastest run. The fastest run leaves out one-time costs of the first run, such as starting Polars’ thread pool and reading the file from disk into the file cache.
A CSV pipeline
bench/pipeline.php reads a 1M-row, 16 MB CSV, filters it, groups and aggregates it, and returns PHP arrays. bench/make-csv.php generates the CSV with a fixed seed: a string group column with 50 values, an integer column with about 2% missing values, and a float column. Each run is timed from read_csv() through toDicts(), so the time includes parsing the file and building the PHP arrays. Each implementation runs five times, and the fastest run is reported. All produce the same result. These numbers were recorded on 2026-10-15 at commit 8fd9d36.
| Implementation | Time |
|---|---|
| phpolars, 1 thread | 49 ms |
| phpolars, 2 threads (default) | 27 ms |
| phpolars, 4 threads | 17 ms |
plain PHP arrays (fgetcsv loop) |
342 ms |
phplyr pipelines on three backends
bench/phplyr.php runs phplyr pipelines on phibble’s in-memory DataFrame, PolarsTable, and phibble’s SqlTable over SQLite. The data is a sales table (store, product, day, units with 2% NA and 5% zeros, price) and a 100-row store lookup. Loading is not timed. Each pipeline is timed through collect(), which builds a Phibble\DataFrame, and the fastest of three runs is reported. The results of all backends are compared.
- A: filter, left join, grouped summary, then a ranked window within regions.
- B: per-store windows over every row:
lag(order_by: day),cumsum, and a share of the group sum. - C: pipeline A with a PHP closure in
filter().
| Pipeline | Rows | phibble DataFrame | PolarsTable | SqlTable |
|---|---|---|---|---|
| A | 100k | 0.166 s | 0.009 s | 0.044 s |
| B | 100k | 0.212 s | 0.048 s | 0.279 s |
| C | 100k | 0.180 s | 0.088 s | 0.291 s |
| A | 1M | 1.517 s | 0.062 s | 0.456 s |
| B | 1M | 2.677 s | 0.438 s | 3.571 s |
| C | 1M | 1.670 s | 0.909 s | 2.875 s |
These runs used phpolars at 5b4b0fa and phibble at dbd8b1e, on 2026-10-16. SqlTable’s pipeline B results differed from the other backends then; phibble fixed this in ccafa98.
Where the time goes
Polars runs the verbs themselves 10 to 100 times faster than the in-memory data frame. Returning rows to PHP is the cost that remains. Collecting 1M rows into PHP takes about 0.2 s for plain columns, plus about 0.5 s per datetime column, because each DateTimeImmutable is a PHP object.
PolarsTable is the faster backend when a pipeline reduces the data (summaries, filters, joins followed by summaries) or chains several verbs before collecting. Reading files with Phpolars\read_csv() or read_parquet() and wrapping the result with PolarsTable::fromPolars() also avoids parsing the file in PHP.
A verb given a PHP closure converts the table to a phibble DataFrame and back, about 1 s per million rows with a datetime column. On large data, use expressions, or use phibble’s in-memory DataFrame for closure-heavy pipelines.
Memory on large files
scan_csv() pushes filters into the reader with either engine, so a filter that keeps no rows reads a 500 MB CSV in 45 to 100 MB of process memory. Rows that a filter keeps cost more than their own size. Polars’ filter keeps references into the buffers that the reader parsed, so a String column that keeps a few of the strings from a batch holds the batch’s whole buffer. With the default in-memory engine, a filter that keeps 4% of a 500 MB, 22-column CSV peaks at about 550 MB, although the result is 17 MB.
collect(engine: 'streaming'), added in v0.2.1, runs the query in batches, and phpolars then copies the strings that each batch keeps into buffers of their own. The same query peaks at about 100 MB with one thread and 130 MB with four. These measurements were made on 2026-10-30 at 810f689. The numbers above are from macOS, where the reported footprint leaves out the file’s own pages. On Linux, in Docker with the dist build, the query ends holding 485 MB of anonymous memory with the in-memory engine, and 80 MB with one thread or 115 MB with four with the streaming engine. tests/memory.php checks the bound on both. Polars maps the CSV file into memory, so a process’s resident set size also includes up to the size of the file in pages that the operating system can reclaim. The sink*() methods stream a result to a file without collecting it.
Memory used by Polars does not count against PHP’s memory_limit, but the PHP arrays built from a result do. rows() on 200,000 rows of 22 strings uses about 340 MB of PHP memory, and so does iterRows(), which builds the same arrays. Iterating iterSlices(10000) and calling rows() on each slice keeps PHP’s peak near 20 MB.
Binary size
| Build | opt-level | Size | Benchmark, 1 / 4 threads |
|---|---|---|---|
all features (dist default) |
s | 64.5 MB | 47 / 23 ms |
without sql |
s | 60.4 MB | |
v0.1.0, all features, release profile (no LTO) |
3 | 117 MB | |
| v0.1.0, all features | 3 | 57.3 MB | 44 / 23 ms |
v0.1.0, all features (dist default) |
s | 40.2 MB | 45 / 21 ms |
| v0.1.0, all features | z | 23.9 MB | 86 / 33 ms |
v0.1.0, without fmt |
3 | 57.2 MB | |
v0.1.0, without json |
3 | 56.8 MB | |
v0.1.0, without ipc |
3 | 56.9 MB | |
v0.1.0, without parquet and ipc |
3 | 52.4 MB | |
| v0.1.0, CSV only (no optional features) | 3 | 51.8 MB | |
| v0.1.0, CSV only (no optional features) | s | 35.9 MB |