deepdiff-rs 0.11.0__tar.gz → 0.11.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/Cargo.lock +6 -6
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/Cargo.toml +1 -1
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/PKG-INFO +12 -15
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/README.md +11 -14
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-arrow/Cargo.toml +27 -2
- deepdiff_rs-0.11.2/crates/onix-arrow/examples/row_diff_profile.rs +316 -0
- deepdiff_rs-0.11.2/crates/onix-arrow/examples/row_diff_rss.rs +141 -0
- deepdiff_rs-0.11.2/crates/onix-arrow/examples/shared/gen_shapes.rs +245 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-arrow/src/lib.rs +3 -0
- deepdiff_rs-0.11.2/crates/onix-arrow/src/profile.rs +418 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-arrow/src/row_diff.rs +1848 -174
- deepdiff_rs-0.11.2/crates/onix-arrow/src/spool.rs +96 -0
- deepdiff_rs-0.11.2/crates/onix-arrow/tests/profile_passes.rs +116 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/Cargo.toml +4 -9
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/benchmarks/bench_bindings.py +26 -210
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/src/arrow.rs +12 -47
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/src/errors.rs +1 -10
- deepdiff_rs-0.11.2/crates/onix-py/src/fast_path.rs +42 -0
- deepdiff_rs-0.11.2/crates/onix-py/src/guard.rs +296 -0
- deepdiff_rs-0.11.2/crates/onix-py/src/lib.rs +26 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/tests/conftest.py +3 -12
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/tests/test_bindings_memory.py +7 -19
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/tests/test_differential_fuzz.py +78 -687
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/tests/test_non_finite.py +1 -7
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/tests/test_signed_zero.py +10 -43
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/tests/test_stub_signatures.py +7 -24
- deepdiff_rs-0.11.2/crates/onix-py/tests/test_suite_hygiene.py +32 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/tests/test_table_row_diff.py +141 -4
- deepdiff_rs-0.11.0/crates/onix-arrow/examples/row_diff_rss.rs +0 -293
- deepdiff_rs-0.11.0/crates/onix-py/src/fast_path.rs +0 -64
- deepdiff_rs-0.11.0/crates/onix-py/src/guard.rs +0 -452
- deepdiff_rs-0.11.0/crates/onix-py/src/lib.rs +0 -52
- deepdiff_rs-0.11.0/crates/onix-py/tests/test_suite_hygiene.py +0 -39
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-arrow/examples/type_stack_cost.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-arrow/src/error.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-arrow/src/json_rows.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-arrow/src/options.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-arrow/src/schema.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-arrow/src/table_diff.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/Cargo.toml +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/examples/stack_frame_cost.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/datetime.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/datetime_tests.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/diff/array.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/diff/dispatch.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/diff/mod.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/diff/object.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/diff/options.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/diff/scalar.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/diff/set.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/diff/tests.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/error.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/ignore_order/distance.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/ignore_order/fxhash.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/ignore_order/hash.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/ignore_order/memo.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/ignore_order/mod.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/ignore_order/pairing.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/ignore_order/tests.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/lcs.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/lcs_tests.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/lib.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/path.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/report.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/report_tests.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/test_support.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/unified_diff.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/unified_diff_tests.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/value.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/src/value_tests.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/tests/golden.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/tests/ignore_order_memory.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/tests/memory_footprint.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/tests/proptest_diff.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-core/tests/proptest_ignore_order.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/.python-version +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/deepdiff_rs.pyi +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/src/convert.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/src/deepdiff.rs +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/tests/test_conversions.py +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/tests/test_datetimes.py +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/tests/test_depth_guard.py +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/tests/test_golden_parity.py +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/tests/test_sets.py +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/tests/test_smoke.py +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/tests/test_stub_mypy.py +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/tests/test_table_diff.py +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/tests/test_timedeltas.py +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/tests/test_times.py +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/tests/test_tuples.py +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/crates/onix-py/tests/test_wheel_contents.py +0 -0
- {deepdiff_rs-0.11.0 → deepdiff_rs-0.11.2}/pyproject.toml +0 -0
|
@@ -629,11 +629,12 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
|
|
629
629
|
|
|
630
630
|
[[package]]
|
|
631
631
|
name = "onix-arrow"
|
|
632
|
-
version = "0.11.
|
|
632
|
+
version = "0.11.2"
|
|
633
633
|
dependencies = [
|
|
634
634
|
"arrow-array",
|
|
635
635
|
"arrow-buffer",
|
|
636
636
|
"arrow-cast",
|
|
637
|
+
"arrow-ipc",
|
|
637
638
|
"arrow-schema",
|
|
638
639
|
"arrow-select",
|
|
639
640
|
"getrandom 0.3.4",
|
|
@@ -642,11 +643,12 @@ dependencies = [
|
|
|
642
643
|
"serde",
|
|
643
644
|
"serde_json",
|
|
644
645
|
"siphasher",
|
|
646
|
+
"tempfile",
|
|
645
647
|
]
|
|
646
648
|
|
|
647
649
|
[[package]]
|
|
648
650
|
name = "onix-cli"
|
|
649
|
-
version = "0.11.
|
|
651
|
+
version = "0.11.2"
|
|
650
652
|
dependencies = [
|
|
651
653
|
"onix-core",
|
|
652
654
|
"serde_json",
|
|
@@ -654,7 +656,7 @@ dependencies = [
|
|
|
654
656
|
|
|
655
657
|
[[package]]
|
|
656
658
|
name = "onix-core"
|
|
657
|
-
version = "0.11.
|
|
659
|
+
version = "0.11.2"
|
|
658
660
|
dependencies = [
|
|
659
661
|
"num-bigint",
|
|
660
662
|
"num-traits",
|
|
@@ -667,10 +669,9 @@ dependencies = [
|
|
|
667
669
|
|
|
668
670
|
[[package]]
|
|
669
671
|
name = "onix-py"
|
|
670
|
-
version = "0.11.
|
|
672
|
+
version = "0.11.2"
|
|
671
673
|
dependencies = [
|
|
672
674
|
"arrow-array",
|
|
673
|
-
"arrow-ipc",
|
|
674
675
|
"arrow-schema",
|
|
675
676
|
"num-bigint",
|
|
676
677
|
"onix-arrow",
|
|
@@ -678,7 +679,6 @@ dependencies = [
|
|
|
678
679
|
"pyo3",
|
|
679
680
|
"pyo3-arrow",
|
|
680
681
|
"serde_json",
|
|
681
|
-
"tempfile",
|
|
682
682
|
]
|
|
683
683
|
|
|
684
684
|
[[package]]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deepdiff-rs
|
|
3
|
-
Version: 0.11.
|
|
3
|
+
Version: 0.11.2
|
|
4
4
|
Classifier: Programming Language :: Python :: 3
|
|
5
5
|
Classifier: Programming Language :: Rust
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -124,15 +124,10 @@ It reports the **schema** diff (which columns were added, removed, or changed ty
|
|
|
124
124
|
import pyarrow as pa
|
|
125
125
|
from deepdiff_rs import diff_tables
|
|
126
126
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
})
|
|
131
|
-
right = pa.table({
|
|
132
|
-
"id": pa.array([2, 3, 4], pa.int64()),
|
|
133
|
-
"amount": pa.array([20, 31, 40], pa.int64()),
|
|
134
|
-
"note": pa.array(["a", "b", "c"], pa.string()),
|
|
135
|
-
})
|
|
127
|
+
# 9 is a duplicate key
|
|
128
|
+
left = pa.table({"id": pa.array([1, 2, 3, 9, 9], pa.int64()), "amount": pa.array([10, 20, 30, 90, 91], pa.int32())})
|
|
129
|
+
right = pa.table({"id": pa.array([2, 3, 4], pa.int64()), "amount": pa.array([20, 31, 40], pa.int64()),
|
|
130
|
+
"note": pa.array(["a", "b", "c"], pa.string())})
|
|
136
131
|
|
|
137
132
|
diff = diff_tables(left, right, key=["id"])
|
|
138
133
|
print(diff.summary(), "added ids:", pa.table(diff.rows_added()).column("id").to_pylist(), "removed ids:", pa.table(diff.rows_removed()).column("id").to_pylist())
|
|
@@ -144,11 +139,13 @@ print("cells changed:", pa.table(diff.cells_changed()).to_pylist(), "duplicate k
|
|
|
144
139
|
cells changed: [{'id': 3, 'column': 'amount', 'old_value': '30', 'new_value': '31', 'change': 'value_changed'}] duplicate keys: [{'id': 9, 'left_count': 2, 'right_count': 0}]
|
|
145
140
|
```
|
|
146
141
|
|
|
147
|
-
A key appearing more than once on either side is reported in `duplicate_keys` (
|
|
142
|
+
A key appearing more than once on either side is reported in `duplicate_keys` (`left_count`/`right_count`) and excluded from added/removed/changed; a null key matches its counterpart and is counted in `null_keys`. Rows are compared by the non-key columns present on *both* sides, with onix's value semantics (integers and integral floats fold together, all NaNs compare equal, `1.00` equals `1.0000`, a timestamp compares by its instant and a time or duration by its value across units, dictionary-encoded values equal their plain form, and null equals null); a nested non-key column is skipped rather than compared. The exact rules are on the hashing functions in [`crates/onix-arrow/src/row_diff.rs`](crates/onix-arrow/src/row_diff.rs).
|
|
148
143
|
|
|
149
|
-
Type comparison uses the full logical Arrow type (timestamp unit and timezone, decimal precision and scale, and so on), but physical encodings
|
|
144
|
+
Type comparison uses the full logical Arrow type (timestamp unit and timezone, decimal precision and scale, and so on), but physical encodings sharing a logical type compare equal — a dictionary-encoded string equals a plain string, polars' `Utf8View` equals pyarrow's `Utf8`, the list variants normalize together, and a map compares equal however a library spells it — so the same table read through pyarrow, polars, or DuckDB reports no spurious type changes; nullability is ignored but reported in each record. The full rules are on `normalized_type`/`map_entries` in [`crates/onix-arrow/src/schema.rs`](crates/onix-arrow/src/schema.rs). Column names must be unique on each side; a repeated name raises `ValueError`. `diff.schema_arrow` is the same result as an Arrow table: it implements `__arrow_c_stream__`, so `polars.DataFrame(diff.schema_arrow)` needs no pyarrow, and `.to_pyarrow()` returns a `pyarrow.Table`; `pandas.api.interchange.from_dataframe(diff.schema_arrow)` also works, but needs pyarrow installed regardless.
|
|
150
145
|
|
|
151
|
-
`pyarrow` is optional:
|
|
146
|
+
`pyarrow` is optional: `pip install deepdiff-rs[arrow]`. It's needed only for `to_pyarrow()` and passing pyarrow objects in — importing `deepdiff_rs` and diffing polars or DuckDB tables need it not at all. An object implementing neither Arrow protocol raises `TypeError`; `to_pyarrow()` without pyarrow installed raises `ImportError` naming the extra. `diff.to_json()` gives the whole diff — schema, summary, and `rows_added`/`rows_removed`/`cells_changed`/`duplicate_keys` in full, one JSON object per row — as a single string with no pyarrow, polars, or pandas needed; see [Known limitations](#known-limitations) for its row cap.
|
|
147
|
+
|
|
148
|
+
Beyond a join-based diff, `diff_tables` reports duplicate and null keys rather than silently multiplying or dropping them, distinguishes `type_changed` from `value_changed` per cell, renders every value by one documented rule set (Python's, with `Duration` the one exception -- an ISO 8601 string, not Python's own `str()`), and produces byte-identical output at any thread count with memory proportional to row count from a streamed input. What that costs over a hand-rolled join is in [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md)'s polars-backed ceiling section.
|
|
152
149
|
|
|
153
150
|
## Performance
|
|
154
151
|
|
|
@@ -195,7 +192,7 @@ The engine's own diff-only time and peak resident memory against pinned `deepdif
|
|
|
195
192
|
| `ignore_order_10k` | 73.475 ms (71.672 ms-73.940 ms) | 12.976 s (12.900 s-13.005 s) | 176.60x | 60.11 MB | 345.19 MB | 5.74x | ✅ |
|
|
196
193
|
| `identical_1m` | 9.254 ms (6.726 ms-9.875 ms) | 15.790 s (15.660 s-15.989 s) | 1706.35x | 315.41 MB | 503.19 MB | 1.60x | ✅ |
|
|
197
194
|
|
|
198
|
-
Both reports carry their full methodology, fairness rules, and the reproduce command. `perf/RESULTS.md` is an upper bound (JSON parsed straight into the engine, no Python-object conversion); the bindings table is the product-surface number. Regenerate them with `perf/run_bench.sh` and `crates/onix-py/benchmarks/bench_bindings.py` (see [CONTRIBUTING.md](CONTRIBUTING.md)). The Arrow table diff's cost against two hand-rolled baselines — a DuckDB SQL join and a polars anti-join/inner-join diff, on two seeded ~5 GB parquet pairs and their own 1M-row subsets (a five-column narrow fixture, and a wide fixture covering every scalar Arrow type) — is in [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md): the row diff now hashes and classifies across every core, so at the 5 GB size its wall time is a small multiple of the two parallelized baselines' for the narrow fixture (down from the several-fold single-threaded gap); the wide fixture, where nearly every row's 34 columns are compared,
|
|
195
|
+
Both reports carry their full methodology, fairness rules, and the reproduce command. `perf/RESULTS.md` is an upper bound (JSON parsed straight into the engine, no Python-object conversion); the bindings table is the product-surface number. Regenerate them with `perf/run_bench.sh` and `crates/onix-py/benchmarks/bench_bindings.py` (see [CONTRIBUTING.md](CONTRIBUTING.md)). The Arrow table diff's cost against two hand-rolled baselines — a DuckDB SQL join and a polars anti-join/inner-join diff, on two seeded ~5 GB parquet pairs and their own 1M-row subsets (a five-column narrow fixture, and a wide fixture covering every scalar Arrow type) — is in [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md): the row diff now hashes and classifies across every core, so at the 5 GB size its wall time is a small multiple of the two parallelized baselines' for the narrow fixture (down from the several-fold single-threaded gap); the wide fixture, where nearly every row's 34 columns are compared and rendered, now spills each side's changed rows by key-hash partition and renders them across every core, cutting its 5 GB-per-side wall time 6.14x (149.674 -> 24.375 s; about 3.8x DuckDB and 6.2x polars) and its peak RSS 2.02x (about 67 GB -> 33.1 GB), see the linked results for both; regenerated with `perf/arrow/bench_tables.py`.
|
|
199
196
|
|
|
200
197
|
## Reference
|
|
201
198
|
|
|
@@ -250,7 +247,7 @@ perf/ # cross-language benchmark harness and RESULTS.md
|
|
|
250
247
|
- `diff_tables` inherits some Arrow-interchange quirks: a column name with an embedded NUL byte (`\0`) arrives truncated at the NUL through the C Data Interface (the report shows the truncated name; rare in practice); a list of structs named exactly `key`/`value` with a nullable key is not distinguished from a real map, so a migration between the two is not reported as a type change (polars exports both as the same Arrow type — see `map_entries` in [`crates/onix-arrow/src/schema.rs`](crates/onix-arrow/src/schema.rs)); and a polars all-null (`Null`-typed) column fails at Arrow C import with a `ValueError` (`the datatype "Null" doesn't expect buffer at index 0`), so give such a column a concrete type first (a pyarrow all-`None` column, inferred as `null`, works and compares as all-null).
|
|
251
248
|
- In `diff_tables`, DuckDB labels a `TIMESTAMP WITH TIME ZONE` column with the connection's *session* time zone when it exports to Arrow (a UTC session as `Timestamp(µs, "UTC")`, an `America/New_York` session as `Timestamp(µs, "America/New_York")`), so on a non-UTC machine such a column can be reported as a type change against a UTC column from another library. Run `SET TimeZone='UTC'` on the DuckDB connection first for a deterministic, machine-independent result.
|
|
252
249
|
- `diff_tables` refuses a column whose Arrow type is nested deeper than `MAX_NESTING_DEPTH` (128) with a `MaxDepthError`, because comparing arbitrarily deep nesting would overflow the native stack; 128 is far beyond any real schema. Importing a schema nested many thousands of levels deep is also slow regardless, a cost of the Arrow C Data Interface itself.
|
|
253
|
-
- `diff_tables`'s row diff costs, per call: RAM for a 32-byte hash per row per side (sorted), so peak memory is linear in row count (about 75 MB at 1M rows/side, 660 MB at 10M); the diff hashes and classifies across all cores by default, running single-threaded only when both sides stay under 50,000 rows and under 64 MB of decoded data (either bound reached first runs the parallel path), and appends the per-row hashes into shared per-partition buffers so there is no separate combined copy; the parallel path adds only the in-flight batches (worker count times batch size) plus the shared buffers' reallocation slack, measured at the linear shape as a peak of about 629 MB single-threaded and 857 MB at 18 threads at 8M rows/side (+228 MB) and about 2.9 GB and 3.6 GB at 37M rows/side (+685 MB) — roughly 10-15 bytes per row per side, under one extra copy of the 32-byte hash vectors, and within the run-to-run slack the single-threaded peak itself shows (2.9-4.2 GB at 37M); `threads=1` runs the sequential path; the size gate peeks each side only up to 50,000 rows or 64 MB of decoded data before choosing, reading the left side first; the peek holds at most 64 MB plus one producer batch per side — a streamed 49,999-row/side pair of 8 KB cells in 100-row batches peaks at about 78 MB at 18 threads and 14 MB single-threaded, rising to about 1.6 GB when the whole side arrives as one batch (see the size-gate peek table in [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md)); wall time is `N·log N` work spread across the workers, plus a second term for the duplicate-key report, which holds the key values of every *distinct duplicated* key — an all-duplicate 200k-rows/side table (100k distinct duplicated keys) peaks at about 37 MB with 16-byte keys and 1.05 GB with 1 KB keys, 1M rows/side (500k distinct) at about 165 MB with 16-byte keys; a third term for the per-cell diff, which
|
|
250
|
+
- `diff_tables`'s row diff costs, per call: RAM for a 32-byte hash per row per side (sorted), so peak memory is linear in row count (about 75 MB at 1M rows/side, 660 MB at 10M); the diff hashes and classifies across all cores by default, running single-threaded only when both sides stay under 50,000 rows and under 64 MB of decoded data (either bound reached first runs the parallel path), and appends the per-row hashes into shared per-partition buffers so there is no separate combined copy; the parallel path adds only the in-flight batches (worker count times batch size) plus the shared buffers' reallocation slack, measured at the linear shape as a peak of about 629 MB single-threaded and 857 MB at 18 threads at 8M rows/side (+228 MB) and about 2.9 GB and 3.6 GB at 37M rows/side (+685 MB) — roughly 10-15 bytes per row per side, under one extra copy of the 32-byte hash vectors, and within the run-to-run slack the single-threaded peak itself shows (2.9-4.2 GB at 37M); `threads=1` runs the sequential path; the size gate peeks each side only up to 50,000 rows or 64 MB of decoded data before choosing, reading the left side first; the peek holds at most 64 MB plus one producer batch per side — a streamed 49,999-row/side pair of 8 KB cells in 100-row batches peaks at about 78 MB at 18 threads and 14 MB single-threaded, rising to about 1.6 GB when the whole side arrives as one batch (see the size-gate peek table in [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md)); wall time is `N·log N` work spread across the workers, plus a second term for the duplicate-key report, which holds the key values of every *distinct duplicated* key — an all-duplicate 200k-rows/side table (100k distinct duplicated keys) peaks at about 37 MB with 16-byte keys and 1.05 GB with 1 KB keys, 1M rows/side (500k distinct) at about 165 MB with 16-byte keys; a third term for the per-cell diff, which spills each side's changed value rows — every common value column of every changed row, changed or not — to anonymous per-key-hash-partition Arrow IPC files and compares and renders one partition at a time across the workers. Its resident cost is the spilled changed value rows (the changed-row count times the total width of the common value columns, both sides — resident where written temp pages count, e.g. macOS or a RAM-backed `tmpfs`) plus about twice the `cells_changed` output (its one out-of-place reorder) plus the per-row hash vectors; the spill term dominates for wide rows with few changed cells, the output term for many changed cells. Measured (`row_diff_rss`, 18 threads): a 1 KB `string` cell changed on every row (output-dominated) peaks at 0.92 GB at 100k rows/side, 1.31 GB at 200k, and 4.81 GB at 1M (the single-threaded path, holding both sides, is 8.30 GB); eight 512 B value columns with only one differing (spill-dominated: 150k changed cells, ~160 MB output) peaks at about 2.09 GB at 150k rows/side, versus 0.84 GB with one such column (medians over 5 runs); these are at 18 threads, the low point of the range -- the same two shapes peak higher at the ends (spill-dominated 3.03 GB at 2 threads and 2.09 GB at 64; output-dominated, 200k, 1.98 GB at 2 and 1.49 GB at 64) as fewer partitions enlarge the resident chunk and the 64-way framing adds a little; two narrow `int64` columns with every row changed peak at 0.38 GB at 1M; the full wide benchmark fixture (34 columns, nearly every cell changed) measures about 33 GB whole-process at 16.875M rows/side at 18 threads, down from about 67 GB before the streaming cell pass. The two Arrow types whose `take` retains data beyond the selected rows are decoded before the spill -- byte-view columns (`Utf8View`/`BinaryView`) cast to their large i64-offset non-view type (`LargeUtf8`/`LargeBinary`, since one batch's retained view buffers can exceed the i32 offset type's ~2 GB ceiling) and dictionaries (what polars and DuckDB emit for strings) decoded to their value type -- so the spill stays compact and independent of the partition count; without the decode it would grow with the partition count (about 97 GB at 64 threads for the wide pair vs about 34 GB with it), see [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md). Then temp disk, because each input is re-read several times and so is spooled to an anonymous file (`tempfile`: unlinked at once, mode 0600, no predictable name — nothing is left on disk even on abnormal exit), and the cell pass additionally spills both sides' changed value rows to anonymous per-partition files (byte-view columns cast to their non-view type and dictionaries decoded to their value type first, so the spill is compact and does not grow with the thread count); the input spools and the partition spill are resident together, and for the full wide pair the whole process (input spool + spill + working set) peaks at about 33 GB at 18 threads, 45 GB at 2, and 34 GB at 64 (on Linux the spill may be a RAM-backed `tmpfs`), a full temp filesystem raising `ValueError` naming `TMPDIR`. None of these has a built-in cap: for untrusted input, bound the row count, the changed fraction, the column widths (key columns for duplicate-heavy data; for the cell pass, the total width of the common value columns — every one is spilled in full for each changed row, changed or not — plus the rendered changed cells; and any column's width for the size-gate peek, which buffers decoded cells whether or not they change), the producer's batch size, and the thread count (the cell pass's peak is lowest at the default and higher at 2 threads, where 2 partitions hold half the rows, and slightly higher at 64, the partition cap), since the peek holds up to one whole batch per side beyond the 64 MB bound. Figures are the peak resident set of `cargo run -p onix-arrow --release --example row_diff_rss` (worker count from `ROW_DIFF_THREADS`, rows per generated batch from `ROW_DIFF_BATCH`), single run, macOS on an Apple M-series laptop, 2026-09-06, same method as [Performance](#performance). See [`crates/onix-arrow/src/row_diff.rs`](crates/onix-arrow/src/row_diff.rs).
|
|
254
251
|
- `TableDiff.to_json()` is the one member with a built-in cap: it embeds `rows_added`, `rows_removed`, `cells_changed`, and `duplicate_keys` in full, one JSON object per row, so it refuses with `ValueError` — naming the row count and the cap — once those four together hold more than 10,000 rows. The cap bounds row count only, the same way the row-diff cost bullet above states its per-cell term as changed cells times cell width, not column count or cell width — so a table under the row cap but with wide or large cells can still be large; use the Arrow-returning accessors instead. See [`crates/onix-arrow/src/json_rows.rs`](crates/onix-arrow/src/json_rows.rs).
|
|
255
252
|
- `diff_tables` compares scalar columns by value (hashed: null, booleans, every integer, float and decimal width, strings and binary in every encoding, timestamps, dates, times, durations, intervals, and dictionaries of these; refused with `ValueError`: run-end encoded columns and any type-and-unit combination Arrow itself cannot build; nested non-key columns skipped, nested key columns refused), with the exact enumeration in the module doc of [`crates/onix-arrow/src/row_diff.rs`](crates/onix-arrow/src/row_diff.rs). It also refuses a key column whose type differs across the two inputs after encoding normalization (the conservative choice: a primary key that changed type is refused rather than guessed, not coerced). A row whose only difference is a lossless type change — an `Int32` widened to `Int64`, a timestamp unit change at the same instant — hashes equal on both sides, so it is in neither `rows_changed` nor `cells_changed`; the column's type change is still reported in `schema`.
|
|
256
253
|
- Output is byte-identical to DeepDiff except for the cases listed above and two path-rendering quirks; [`tests/golden/README.md`](tests/golden/README.md) enumerates every accepted exception, including integers past `2^53` (the limit of exact `f64` representation) inside ordered scalar lists and `ignore_order` pairing among naive datetimes, which DeepDiff ranks using the *process's local timezone* while onix reads a naive value as UTC everywhere.
|
|
@@ -106,15 +106,10 @@ It reports the **schema** diff (which columns were added, removed, or changed ty
|
|
|
106
106
|
import pyarrow as pa
|
|
107
107
|
from deepdiff_rs import diff_tables
|
|
108
108
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
})
|
|
113
|
-
right = pa.table({
|
|
114
|
-
"id": pa.array([2, 3, 4], pa.int64()),
|
|
115
|
-
"amount": pa.array([20, 31, 40], pa.int64()),
|
|
116
|
-
"note": pa.array(["a", "b", "c"], pa.string()),
|
|
117
|
-
})
|
|
109
|
+
# 9 is a duplicate key
|
|
110
|
+
left = pa.table({"id": pa.array([1, 2, 3, 9, 9], pa.int64()), "amount": pa.array([10, 20, 30, 90, 91], pa.int32())})
|
|
111
|
+
right = pa.table({"id": pa.array([2, 3, 4], pa.int64()), "amount": pa.array([20, 31, 40], pa.int64()),
|
|
112
|
+
"note": pa.array(["a", "b", "c"], pa.string())})
|
|
118
113
|
|
|
119
114
|
diff = diff_tables(left, right, key=["id"])
|
|
120
115
|
print(diff.summary(), "added ids:", pa.table(diff.rows_added()).column("id").to_pylist(), "removed ids:", pa.table(diff.rows_removed()).column("id").to_pylist())
|
|
@@ -126,11 +121,13 @@ print("cells changed:", pa.table(diff.cells_changed()).to_pylist(), "duplicate k
|
|
|
126
121
|
cells changed: [{'id': 3, 'column': 'amount', 'old_value': '30', 'new_value': '31', 'change': 'value_changed'}] duplicate keys: [{'id': 9, 'left_count': 2, 'right_count': 0}]
|
|
127
122
|
```
|
|
128
123
|
|
|
129
|
-
A key appearing more than once on either side is reported in `duplicate_keys` (
|
|
124
|
+
A key appearing more than once on either side is reported in `duplicate_keys` (`left_count`/`right_count`) and excluded from added/removed/changed; a null key matches its counterpart and is counted in `null_keys`. Rows are compared by the non-key columns present on *both* sides, with onix's value semantics (integers and integral floats fold together, all NaNs compare equal, `1.00` equals `1.0000`, a timestamp compares by its instant and a time or duration by its value across units, dictionary-encoded values equal their plain form, and null equals null); a nested non-key column is skipped rather than compared. The exact rules are on the hashing functions in [`crates/onix-arrow/src/row_diff.rs`](crates/onix-arrow/src/row_diff.rs).
|
|
130
125
|
|
|
131
|
-
Type comparison uses the full logical Arrow type (timestamp unit and timezone, decimal precision and scale, and so on), but physical encodings
|
|
126
|
+
Type comparison uses the full logical Arrow type (timestamp unit and timezone, decimal precision and scale, and so on), but physical encodings sharing a logical type compare equal — a dictionary-encoded string equals a plain string, polars' `Utf8View` equals pyarrow's `Utf8`, the list variants normalize together, and a map compares equal however a library spells it — so the same table read through pyarrow, polars, or DuckDB reports no spurious type changes; nullability is ignored but reported in each record. The full rules are on `normalized_type`/`map_entries` in [`crates/onix-arrow/src/schema.rs`](crates/onix-arrow/src/schema.rs). Column names must be unique on each side; a repeated name raises `ValueError`. `diff.schema_arrow` is the same result as an Arrow table: it implements `__arrow_c_stream__`, so `polars.DataFrame(diff.schema_arrow)` needs no pyarrow, and `.to_pyarrow()` returns a `pyarrow.Table`; `pandas.api.interchange.from_dataframe(diff.schema_arrow)` also works, but needs pyarrow installed regardless.
|
|
132
127
|
|
|
133
|
-
`pyarrow` is optional:
|
|
128
|
+
`pyarrow` is optional: `pip install deepdiff-rs[arrow]`. It's needed only for `to_pyarrow()` and passing pyarrow objects in — importing `deepdiff_rs` and diffing polars or DuckDB tables need it not at all. An object implementing neither Arrow protocol raises `TypeError`; `to_pyarrow()` without pyarrow installed raises `ImportError` naming the extra. `diff.to_json()` gives the whole diff — schema, summary, and `rows_added`/`rows_removed`/`cells_changed`/`duplicate_keys` in full, one JSON object per row — as a single string with no pyarrow, polars, or pandas needed; see [Known limitations](#known-limitations) for its row cap.
|
|
129
|
+
|
|
130
|
+
Beyond a join-based diff, `diff_tables` reports duplicate and null keys rather than silently multiplying or dropping them, distinguishes `type_changed` from `value_changed` per cell, renders every value by one documented rule set (Python's, with `Duration` the one exception -- an ISO 8601 string, not Python's own `str()`), and produces byte-identical output at any thread count with memory proportional to row count from a streamed input. What that costs over a hand-rolled join is in [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md)'s polars-backed ceiling section.
|
|
134
131
|
|
|
135
132
|
## Performance
|
|
136
133
|
|
|
@@ -177,7 +174,7 @@ The engine's own diff-only time and peak resident memory against pinned `deepdif
|
|
|
177
174
|
| `ignore_order_10k` | 73.475 ms (71.672 ms-73.940 ms) | 12.976 s (12.900 s-13.005 s) | 176.60x | 60.11 MB | 345.19 MB | 5.74x | ✅ |
|
|
178
175
|
| `identical_1m` | 9.254 ms (6.726 ms-9.875 ms) | 15.790 s (15.660 s-15.989 s) | 1706.35x | 315.41 MB | 503.19 MB | 1.60x | ✅ |
|
|
179
176
|
|
|
180
|
-
Both reports carry their full methodology, fairness rules, and the reproduce command. `perf/RESULTS.md` is an upper bound (JSON parsed straight into the engine, no Python-object conversion); the bindings table is the product-surface number. Regenerate them with `perf/run_bench.sh` and `crates/onix-py/benchmarks/bench_bindings.py` (see [CONTRIBUTING.md](CONTRIBUTING.md)). The Arrow table diff's cost against two hand-rolled baselines — a DuckDB SQL join and a polars anti-join/inner-join diff, on two seeded ~5 GB parquet pairs and their own 1M-row subsets (a five-column narrow fixture, and a wide fixture covering every scalar Arrow type) — is in [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md): the row diff now hashes and classifies across every core, so at the 5 GB size its wall time is a small multiple of the two parallelized baselines' for the narrow fixture (down from the several-fold single-threaded gap); the wide fixture, where nearly every row's 34 columns are compared,
|
|
177
|
+
Both reports carry their full methodology, fairness rules, and the reproduce command. `perf/RESULTS.md` is an upper bound (JSON parsed straight into the engine, no Python-object conversion); the bindings table is the product-surface number. Regenerate them with `perf/run_bench.sh` and `crates/onix-py/benchmarks/bench_bindings.py` (see [CONTRIBUTING.md](CONTRIBUTING.md)). The Arrow table diff's cost against two hand-rolled baselines — a DuckDB SQL join and a polars anti-join/inner-join diff, on two seeded ~5 GB parquet pairs and their own 1M-row subsets (a five-column narrow fixture, and a wide fixture covering every scalar Arrow type) — is in [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md): the row diff now hashes and classifies across every core, so at the 5 GB size its wall time is a small multiple of the two parallelized baselines' for the narrow fixture (down from the several-fold single-threaded gap); the wide fixture, where nearly every row's 34 columns are compared and rendered, now spills each side's changed rows by key-hash partition and renders them across every core, cutting its 5 GB-per-side wall time 6.14x (149.674 -> 24.375 s; about 3.8x DuckDB and 6.2x polars) and its peak RSS 2.02x (about 67 GB -> 33.1 GB), see the linked results for both; regenerated with `perf/arrow/bench_tables.py`.
|
|
181
178
|
|
|
182
179
|
## Reference
|
|
183
180
|
|
|
@@ -232,7 +229,7 @@ perf/ # cross-language benchmark harness and RESULTS.md
|
|
|
232
229
|
- `diff_tables` inherits some Arrow-interchange quirks: a column name with an embedded NUL byte (`\0`) arrives truncated at the NUL through the C Data Interface (the report shows the truncated name; rare in practice); a list of structs named exactly `key`/`value` with a nullable key is not distinguished from a real map, so a migration between the two is not reported as a type change (polars exports both as the same Arrow type — see `map_entries` in [`crates/onix-arrow/src/schema.rs`](crates/onix-arrow/src/schema.rs)); and a polars all-null (`Null`-typed) column fails at Arrow C import with a `ValueError` (`the datatype "Null" doesn't expect buffer at index 0`), so give such a column a concrete type first (a pyarrow all-`None` column, inferred as `null`, works and compares as all-null).
|
|
233
230
|
- In `diff_tables`, DuckDB labels a `TIMESTAMP WITH TIME ZONE` column with the connection's *session* time zone when it exports to Arrow (a UTC session as `Timestamp(µs, "UTC")`, an `America/New_York` session as `Timestamp(µs, "America/New_York")`), so on a non-UTC machine such a column can be reported as a type change against a UTC column from another library. Run `SET TimeZone='UTC'` on the DuckDB connection first for a deterministic, machine-independent result.
|
|
234
231
|
- `diff_tables` refuses a column whose Arrow type is nested deeper than `MAX_NESTING_DEPTH` (128) with a `MaxDepthError`, because comparing arbitrarily deep nesting would overflow the native stack; 128 is far beyond any real schema. Importing a schema nested many thousands of levels deep is also slow regardless, a cost of the Arrow C Data Interface itself.
|
|
235
|
-
- `diff_tables`'s row diff costs, per call: RAM for a 32-byte hash per row per side (sorted), so peak memory is linear in row count (about 75 MB at 1M rows/side, 660 MB at 10M); the diff hashes and classifies across all cores by default, running single-threaded only when both sides stay under 50,000 rows and under 64 MB of decoded data (either bound reached first runs the parallel path), and appends the per-row hashes into shared per-partition buffers so there is no separate combined copy; the parallel path adds only the in-flight batches (worker count times batch size) plus the shared buffers' reallocation slack, measured at the linear shape as a peak of about 629 MB single-threaded and 857 MB at 18 threads at 8M rows/side (+228 MB) and about 2.9 GB and 3.6 GB at 37M rows/side (+685 MB) — roughly 10-15 bytes per row per side, under one extra copy of the 32-byte hash vectors, and within the run-to-run slack the single-threaded peak itself shows (2.9-4.2 GB at 37M); `threads=1` runs the sequential path; the size gate peeks each side only up to 50,000 rows or 64 MB of decoded data before choosing, reading the left side first; the peek holds at most 64 MB plus one producer batch per side — a streamed 49,999-row/side pair of 8 KB cells in 100-row batches peaks at about 78 MB at 18 threads and 14 MB single-threaded, rising to about 1.6 GB when the whole side arrives as one batch (see the size-gate peek table in [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md)); wall time is `N·log N` work spread across the workers, plus a second term for the duplicate-key report, which holds the key values of every *distinct duplicated* key — an all-duplicate 200k-rows/side table (100k distinct duplicated keys) peaks at about 37 MB with 16-byte keys and 1.05 GB with 1 KB keys, 1M rows/side (500k distinct) at about 165 MB with 16-byte keys; a third term for the per-cell diff, which
|
|
232
|
+
- `diff_tables`'s row diff costs, per call: RAM for a 32-byte hash per row per side (sorted), so peak memory is linear in row count (about 75 MB at 1M rows/side, 660 MB at 10M); the diff hashes and classifies across all cores by default, running single-threaded only when both sides stay under 50,000 rows and under 64 MB of decoded data (either bound reached first runs the parallel path), and appends the per-row hashes into shared per-partition buffers so there is no separate combined copy; the parallel path adds only the in-flight batches (worker count times batch size) plus the shared buffers' reallocation slack, measured at the linear shape as a peak of about 629 MB single-threaded and 857 MB at 18 threads at 8M rows/side (+228 MB) and about 2.9 GB and 3.6 GB at 37M rows/side (+685 MB) — roughly 10-15 bytes per row per side, under one extra copy of the 32-byte hash vectors, and within the run-to-run slack the single-threaded peak itself shows (2.9-4.2 GB at 37M); `threads=1` runs the sequential path; the size gate peeks each side only up to 50,000 rows or 64 MB of decoded data before choosing, reading the left side first; the peek holds at most 64 MB plus one producer batch per side — a streamed 49,999-row/side pair of 8 KB cells in 100-row batches peaks at about 78 MB at 18 threads and 14 MB single-threaded, rising to about 1.6 GB when the whole side arrives as one batch (see the size-gate peek table in [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md)); wall time is `N·log N` work spread across the workers, plus a second term for the duplicate-key report, which holds the key values of every *distinct duplicated* key — an all-duplicate 200k-rows/side table (100k distinct duplicated keys) peaks at about 37 MB with 16-byte keys and 1.05 GB with 1 KB keys, 1M rows/side (500k distinct) at about 165 MB with 16-byte keys; a third term for the per-cell diff, which spills each side's changed value rows — every common value column of every changed row, changed or not — to anonymous per-key-hash-partition Arrow IPC files and compares and renders one partition at a time across the workers. Its resident cost is the spilled changed value rows (the changed-row count times the total width of the common value columns, both sides — resident where written temp pages count, e.g. macOS or a RAM-backed `tmpfs`) plus about twice the `cells_changed` output (its one out-of-place reorder) plus the per-row hash vectors; the spill term dominates for wide rows with few changed cells, the output term for many changed cells. Measured (`row_diff_rss`, 18 threads): a 1 KB `string` cell changed on every row (output-dominated) peaks at 0.92 GB at 100k rows/side, 1.31 GB at 200k, and 4.81 GB at 1M (the single-threaded path, holding both sides, is 8.30 GB); eight 512 B value columns with only one differing (spill-dominated: 150k changed cells, ~160 MB output) peaks at about 2.09 GB at 150k rows/side, versus 0.84 GB with one such column (medians over 5 runs); these are at 18 threads, the low point of the range -- the same two shapes peak higher at the ends (spill-dominated 3.03 GB at 2 threads and 2.09 GB at 64; output-dominated, 200k, 1.98 GB at 2 and 1.49 GB at 64) as fewer partitions enlarge the resident chunk and the 64-way framing adds a little; two narrow `int64` columns with every row changed peak at 0.38 GB at 1M; the full wide benchmark fixture (34 columns, nearly every cell changed) measures about 33 GB whole-process at 16.875M rows/side at 18 threads, down from about 67 GB before the streaming cell pass. The two Arrow types whose `take` retains data beyond the selected rows are decoded before the spill -- byte-view columns (`Utf8View`/`BinaryView`) cast to their large i64-offset non-view type (`LargeUtf8`/`LargeBinary`, since one batch's retained view buffers can exceed the i32 offset type's ~2 GB ceiling) and dictionaries (what polars and DuckDB emit for strings) decoded to their value type -- so the spill stays compact and independent of the partition count; without the decode it would grow with the partition count (about 97 GB at 64 threads for the wide pair vs about 34 GB with it), see [`perf/arrow/RESULTS.md`](perf/arrow/RESULTS.md). Then temp disk, because each input is re-read several times and so is spooled to an anonymous file (`tempfile`: unlinked at once, mode 0600, no predictable name — nothing is left on disk even on abnormal exit), and the cell pass additionally spills both sides' changed value rows to anonymous per-partition files (byte-view columns cast to their non-view type and dictionaries decoded to their value type first, so the spill is compact and does not grow with the thread count); the input spools and the partition spill are resident together, and for the full wide pair the whole process (input spool + spill + working set) peaks at about 33 GB at 18 threads, 45 GB at 2, and 34 GB at 64 (on Linux the spill may be a RAM-backed `tmpfs`), a full temp filesystem raising `ValueError` naming `TMPDIR`. None of these has a built-in cap: for untrusted input, bound the row count, the changed fraction, the column widths (key columns for duplicate-heavy data; for the cell pass, the total width of the common value columns — every one is spilled in full for each changed row, changed or not — plus the rendered changed cells; and any column's width for the size-gate peek, which buffers decoded cells whether or not they change), the producer's batch size, and the thread count (the cell pass's peak is lowest at the default and higher at 2 threads, where 2 partitions hold half the rows, and slightly higher at 64, the partition cap), since the peek holds up to one whole batch per side beyond the 64 MB bound. Figures are the peak resident set of `cargo run -p onix-arrow --release --example row_diff_rss` (worker count from `ROW_DIFF_THREADS`, rows per generated batch from `ROW_DIFF_BATCH`), single run, macOS on an Apple M-series laptop, 2026-09-06, same method as [Performance](#performance). See [`crates/onix-arrow/src/row_diff.rs`](crates/onix-arrow/src/row_diff.rs).
|
|
236
233
|
- `TableDiff.to_json()` is the one member with a built-in cap: it embeds `rows_added`, `rows_removed`, `cells_changed`, and `duplicate_keys` in full, one JSON object per row, so it refuses with `ValueError` — naming the row count and the cap — once those four together hold more than 10,000 rows. The cap bounds row count only, the same way the row-diff cost bullet above states its per-cell term as changed cells times cell width, not column count or cell width — so a table under the row cap but with wide or large cells can still be large; use the Arrow-returning accessors instead. See [`crates/onix-arrow/src/json_rows.rs`](crates/onix-arrow/src/json_rows.rs).
|
|
237
234
|
- `diff_tables` compares scalar columns by value (hashed: null, booleans, every integer, float and decimal width, strings and binary in every encoding, timestamps, dates, times, durations, intervals, and dictionaries of these; refused with `ValueError`: run-end encoded columns and any type-and-unit combination Arrow itself cannot build; nested non-key columns skipped, nested key columns refused), with the exact enumeration in the module doc of [`crates/onix-arrow/src/row_diff.rs`](crates/onix-arrow/src/row_diff.rs). It also refuses a key column whose type differs across the two inputs after encoding normalization (the conservative choice: a primary key that changed type is refused rather than guessed, not coerced). A row whose only difference is a lossless type change — an `Int32` widened to `Int64`, a timestamp unit change at the same instant — hashes equal on both sides, so it is in neither `rows_changed` nor `cells_changed`; the column's type change is still reported in `schema`.
|
|
238
235
|
- Output is byte-identical to DeepDiff except for the cases listed above and two path-rendering quirks; [`tests/golden/README.md`](tests/golden/README.md) enumerates every accepted exception, including integers past `2^53` (the limit of exact `f64` representation) inside ordered scalar lists and `ignore_order` pairing among naive datetimes, which DeepDiff ranks using the *process's local timezone* while onix reads a naive value as UTC everywhere.
|
|
@@ -9,6 +9,12 @@ description = "Arrow table diffing (schema and keyed rows) built on the onix dif
|
|
|
9
9
|
[lints]
|
|
10
10
|
workspace = true
|
|
11
11
|
|
|
12
|
+
[features]
|
|
13
|
+
# Compiles in the per-pass wall-time and peak-RSS instrumentation the
|
|
14
|
+
# `row_diff_profile` example reads. Off by default and never enabled by the
|
|
15
|
+
# release wheel, so the instrumentation is absent from the shipped `.so`.
|
|
16
|
+
profile = []
|
|
17
|
+
|
|
12
18
|
[dependencies]
|
|
13
19
|
# Pinned to an exact Arrow version so this crate and the `pyo3-arrow` bridge
|
|
14
20
|
# in `onix-py` (which requires `arrow` ^59) resolve to the identical Arrow
|
|
@@ -27,10 +33,19 @@ arrow-buffer = "=59.3.0"
|
|
|
27
33
|
# cell's value to its canonical string (`display::ArrayFormatter`);
|
|
28
34
|
# `arrow-select` filters the materialized rows, concatenates the output row
|
|
29
35
|
# batches, and `take`s the key columns into the per-cell diff. The row diff
|
|
30
|
-
# re-reads each side through the caller's re-openable `TableInput
|
|
31
|
-
#
|
|
36
|
+
# re-reads each *input* side through the caller's re-openable `TableInput`. The
|
|
37
|
+
# `spool` module owns the anonymous temporary Arrow IPC primitive used both by
|
|
38
|
+
# the Python bindings' input spool and by the parallel cell pass, which spills
|
|
39
|
+
# its changed-row key-hash partitions so it holds one partition in memory at a
|
|
40
|
+
# time rather than both sides' full changed rows.
|
|
32
41
|
arrow-cast = "=59.3.0"
|
|
33
42
|
arrow-select = "=59.3.0"
|
|
43
|
+
# The `spool` module writes to an anonymous `tempfile::tempfile()` as an Arrow
|
|
44
|
+
# IPC stream and re-reads it; the cell pass spills one key-hash partition per
|
|
45
|
+
# file so only one is resident at once. Pinned to the same 59.3.0 as the other
|
|
46
|
+
# arrow crates.
|
|
47
|
+
arrow-ipc = "=59.3.0"
|
|
48
|
+
tempfile = "3"
|
|
34
49
|
# Row identity is a keyed 128-bit SipHash-1-3 (`siphasher`), keyed from 16
|
|
35
50
|
# bytes of OS randomness per diff (`getrandom`), so the row-matching table on
|
|
36
51
|
# this default path cannot be forced into collisions by chosen input.
|
|
@@ -43,3 +58,13 @@ serde_json = "1"
|
|
|
43
58
|
proptest = "1"
|
|
44
59
|
# `half::f16` builds a Float16 test column; the same version arrow-array uses.
|
|
45
60
|
half = "2"
|
|
61
|
+
|
|
62
|
+
# The profiling harness reads the per-pass instrumentation, so it only builds
|
|
63
|
+
# with the `profile` feature (and never as part of the default build or wheel).
|
|
64
|
+
[[example]]
|
|
65
|
+
name = "row_diff_profile"
|
|
66
|
+
required-features = ["profile"]
|
|
67
|
+
|
|
68
|
+
[[test]]
|
|
69
|
+
name = "profile_passes"
|
|
70
|
+
required-features = ["profile"]
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
//! Per-pass wall-time and peak-RSS profile of one keyed row diff, the committed
|
|
2
|
+
//! harness every row-diff performance change posts a before/after table from.
|
|
3
|
+
//! Builds only with the `profile` feature, which the release wheel never enables.
|
|
4
|
+
//!
|
|
5
|
+
//! Each invocation runs a discarded warm-up diff, a timed uninstrumented diff
|
|
6
|
+
//! (the `uninstrumented wall` line), and an instrumented diff whose passes make
|
|
7
|
+
//! up the table. The closure line compares the passes' sum with the instrumented
|
|
8
|
+
//! wall minus the profiler's own boundary `ps` reads; `share` is each row's wall
|
|
9
|
+
//! over that net wall. Peak RSS is the process's, third diff in the process.
|
|
10
|
+
//!
|
|
11
|
+
//! - **file**: reads each side from an uncompressed Arrow IPC file, re-opened
|
|
12
|
+
//! and re-decoded by every pass. Convert a parquet fixture once with
|
|
13
|
+
//! `python -c "import pyarrow.parquet as p, pyarrow.feather as f;
|
|
14
|
+
//! f.write_feather(p.read_table('a.parquet'), 'a.arrow', compression='uncompressed')"`.
|
|
15
|
+
//! - **generated**: spools both sides of a deterministic proxy shape to anonymous
|
|
16
|
+
//! Arrow IPC files (the `spool write` line times the IPC writer alone).
|
|
17
|
+
//!
|
|
18
|
+
//! ```sh
|
|
19
|
+
//! cargo build -p onix-arrow --release --features profile --example row_diff_profile
|
|
20
|
+
//! target/release/examples/row_diff_profile file a.arrow b.arrow --key id --threads 18
|
|
21
|
+
//! target/release/examples/row_diff_profile 1000000 linear 18
|
|
22
|
+
//! target/release/examples/row_diff_profile 1000000 manycols 18 34 64
|
|
23
|
+
//! ```
|
|
24
|
+
//!
|
|
25
|
+
//! Generated args: `[rows [shape [threads [shape params...]]]]`, key `id`,
|
|
26
|
+
//! `threads` defaulting to `ROW_DIFF_THREADS` or available parallelism. Shapes:
|
|
27
|
+
//!
|
|
28
|
+
//! - `linear`: `id`/`value` int64; 1% of keys added, 1% removed, ~2% changed.
|
|
29
|
+
//! - `allchange`: `id`/`value` int64, every row changed.
|
|
30
|
+
//! - `wide [width=512]`: `id` plus one `width`-byte string, every row changed.
|
|
31
|
+
//! - `manycols [ncols=34 [width=64]]`: `id` plus `ncols` `width`-byte strings,
|
|
32
|
+
//! only the first differing, so the spill carries every value column.
|
|
33
|
+
|
|
34
|
+
use std::fs::File;
|
|
35
|
+
use std::io::BufReader;
|
|
36
|
+
use std::num::NonZeroUsize;
|
|
37
|
+
use std::path::PathBuf;
|
|
38
|
+
use std::time::{Duration, Instant};
|
|
39
|
+
|
|
40
|
+
use arrow_array::RecordBatchReader;
|
|
41
|
+
use arrow_ipc::reader::FileReader;
|
|
42
|
+
use arrow_schema::SchemaRef;
|
|
43
|
+
use onix_arrow::{TableDiffError, TableDiffOptions, TableInput, diff_tables, profile, spool};
|
|
44
|
+
|
|
45
|
+
#[path = "shared/gen_shapes.rs"]
|
|
46
|
+
mod gen_shapes;
|
|
47
|
+
use gen_shapes::{Case, Generated, batch_rows};
|
|
48
|
+
|
|
49
|
+
/// A table read from an Arrow IPC file, re-opened on every `open`.
|
|
50
|
+
struct FileInput {
|
|
51
|
+
path: PathBuf,
|
|
52
|
+
schema: SchemaRef,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
impl FileInput {
|
|
56
|
+
fn load(path: &str) -> FileInput {
|
|
57
|
+
let reader = open_ipc(path).unwrap_or_else(|e| panic!("open {path}: {e}"));
|
|
58
|
+
FileInput {
|
|
59
|
+
path: PathBuf::from(path),
|
|
60
|
+
schema: reader.schema(),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
fn open_ipc(path: &str) -> Result<FileReader<BufReader<File>>, TableDiffError> {
|
|
66
|
+
let file = File::open(path).map_err(|e| TableDiffError::Read {
|
|
67
|
+
message: format!("open {path}: {e}"),
|
|
68
|
+
})?;
|
|
69
|
+
FileReader::try_new(BufReader::new(file), None).map_err(|e| TableDiffError::Read {
|
|
70
|
+
message: format!("read Arrow IPC {path}: {e}"),
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
impl TableInput for FileInput {
|
|
75
|
+
fn schema(&self) -> SchemaRef {
|
|
76
|
+
self.schema.clone()
|
|
77
|
+
}
|
|
78
|
+
fn open(&self) -> Result<Box<dyn RecordBatchReader + Send>, TableDiffError> {
|
|
79
|
+
let path = self.path.to_string_lossy();
|
|
80
|
+
Ok(Box::new(open_ipc(&path)?))
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// A side spooled to an anonymous Arrow IPC file, re-read on every `open` — the
|
|
85
|
+
/// same re-openable spool the Python bindings hand the row diff.
|
|
86
|
+
struct Spooled {
|
|
87
|
+
file: File,
|
|
88
|
+
schema: SchemaRef,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
impl TableInput for Spooled {
|
|
92
|
+
fn schema(&self) -> SchemaRef {
|
|
93
|
+
self.schema.clone()
|
|
94
|
+
}
|
|
95
|
+
fn open(&self) -> Result<Box<dyn RecordBatchReader + Send>, TableDiffError> {
|
|
96
|
+
Ok(Box::new(spool::reopen(&self.file)?))
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// Spools one generated side, returning it and the time spent in the IPC
|
|
101
|
+
/// writer (generation excluded).
|
|
102
|
+
fn spool_side(side: &Generated) -> (Spooled, Duration) {
|
|
103
|
+
let (file, mut writer) = spool::open(&side.schema).expect("open spool");
|
|
104
|
+
let mut writing = Duration::ZERO;
|
|
105
|
+
for batch in side.open().expect("open generator") {
|
|
106
|
+
let batch = batch.expect("generate batch");
|
|
107
|
+
let start = Instant::now();
|
|
108
|
+
writer.write(&batch).expect("spool write");
|
|
109
|
+
writing += start.elapsed();
|
|
110
|
+
}
|
|
111
|
+
let start = Instant::now();
|
|
112
|
+
writer.finish().expect("spool finish");
|
|
113
|
+
writing += start.elapsed();
|
|
114
|
+
let spooled = Spooled {
|
|
115
|
+
file,
|
|
116
|
+
schema: side.schema.clone(),
|
|
117
|
+
};
|
|
118
|
+
(spooled, writing)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// Runs one discarded warm-up diff, one timed uninstrumented diff, and one
|
|
122
|
+
/// instrumented diff, then prints both walls, the closure of the instrumented
|
|
123
|
+
/// wall over its passes, and the per-pass table.
|
|
124
|
+
fn run(left: &impl TableInput, right: &impl TableInput, options: &TableDiffOptions, label: &str) {
|
|
125
|
+
let _ = diff_tables(left, right, options).expect("diff succeeds");
|
|
126
|
+
let start = Instant::now();
|
|
127
|
+
let diff = diff_tables(left, right, options).expect("diff succeeds");
|
|
128
|
+
let uninstrumented = start.elapsed().as_secs_f64();
|
|
129
|
+
let summary = diff.summary();
|
|
130
|
+
|
|
131
|
+
let session = profile::begin();
|
|
132
|
+
let start = Instant::now();
|
|
133
|
+
let _ = diff_tables(left, right, options).expect("diff succeeds");
|
|
134
|
+
let instrumented = start.elapsed().as_secs_f64();
|
|
135
|
+
let report = session.finish();
|
|
136
|
+
|
|
137
|
+
let boundary: f64 = report
|
|
138
|
+
.iter()
|
|
139
|
+
.filter(|row| row.label == profile::BOUNDARY_LABEL)
|
|
140
|
+
.map(|row| row.wall_secs)
|
|
141
|
+
.sum();
|
|
142
|
+
let passes: f64 = report
|
|
143
|
+
.iter()
|
|
144
|
+
.filter(|row| row.peak_rss_mib.is_some())
|
|
145
|
+
.map(|row| row.wall_secs)
|
|
146
|
+
.sum();
|
|
147
|
+
let closed = instrumented - boundary;
|
|
148
|
+
|
|
149
|
+
println!("{label}");
|
|
150
|
+
println!("threads: {}", options.threads());
|
|
151
|
+
println!("uninstrumented wall: {uninstrumented:.3} s");
|
|
152
|
+
println!("instrumented wall: {instrumented:.3} s");
|
|
153
|
+
println!("boundary ps reads: {boundary:.3} s");
|
|
154
|
+
println!(
|
|
155
|
+
"passes sum: {passes:.3} s of {closed:.3} s (instrumented minus boundary); residual {:.3} s ({:.1}%)",
|
|
156
|
+
closed - passes,
|
|
157
|
+
100.0 * (closed - passes) / closed
|
|
158
|
+
);
|
|
159
|
+
println!(
|
|
160
|
+
"rows_added={} rows_removed={} rows_changed={} duplicate_keys={} cells_changed={}",
|
|
161
|
+
summary.rows_added,
|
|
162
|
+
summary.rows_removed,
|
|
163
|
+
summary.rows_changed,
|
|
164
|
+
summary.duplicate_keys,
|
|
165
|
+
summary.cells_changed
|
|
166
|
+
);
|
|
167
|
+
println!();
|
|
168
|
+
println!(
|
|
169
|
+
"{:<44} {:>10} {:>8} {:>14}",
|
|
170
|
+
"pass", "wall (s)", "share", "peak RSS (MB)"
|
|
171
|
+
);
|
|
172
|
+
for row in report
|
|
173
|
+
.iter()
|
|
174
|
+
.filter(|row| row.label != profile::BOUNDARY_LABEL)
|
|
175
|
+
{
|
|
176
|
+
let share = 100.0 * row.wall_secs / closed;
|
|
177
|
+
match row.peak_rss_mib {
|
|
178
|
+
Some(rss) => println!(
|
|
179
|
+
"{:<44} {:>10.3} {:>7.1}% {:>14.1}",
|
|
180
|
+
row.label, row.wall_secs, share, rss
|
|
181
|
+
),
|
|
182
|
+
None => println!(
|
|
183
|
+
"{:<44} {:>10.3} {:>7.1}% {:>14}",
|
|
184
|
+
format!(" {}", row.label),
|
|
185
|
+
row.wall_secs,
|
|
186
|
+
share,
|
|
187
|
+
"-"
|
|
188
|
+
),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const USAGE: &str =
|
|
194
|
+
"usage: row_diff_profile file <left.arrow> <right.arrow> [--key col[,col...]] [--threads N]
|
|
195
|
+
row_diff_profile [rows [linear|allchange|wide|manycols [threads [shape params...]]]]";
|
|
196
|
+
|
|
197
|
+
fn usage_error(message: &str) -> ! {
|
|
198
|
+
eprintln!("{message}\n{USAGE}");
|
|
199
|
+
std::process::exit(2);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/// Parses a positive integer argument, exiting with a usage error otherwise.
|
|
203
|
+
fn positive(what: &str, value: &str) -> NonZeroUsize {
|
|
204
|
+
value.parse().unwrap_or_else(|_| {
|
|
205
|
+
usage_error(&format!("{what} must be a positive integer, got {value:?}"))
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
fn options_for(keys: Vec<String>, threads: Option<NonZeroUsize>) -> TableDiffOptions {
|
|
210
|
+
let threads = threads.or_else(|| {
|
|
211
|
+
std::env::var("ROW_DIFF_THREADS")
|
|
212
|
+
.ok()
|
|
213
|
+
.map(|v| positive("ROW_DIFF_THREADS", &v))
|
|
214
|
+
});
|
|
215
|
+
let options = TableDiffOptions::new(keys);
|
|
216
|
+
match threads {
|
|
217
|
+
Some(t) => options
|
|
218
|
+
.with_threads(t)
|
|
219
|
+
.unwrap_or_else(|e| usage_error(&e.to_string())),
|
|
220
|
+
None => options,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
fn file_mode(args: &[String]) {
|
|
225
|
+
let [left_path, right_path, flags @ ..] = args else {
|
|
226
|
+
usage_error("file mode needs <left.arrow> <right.arrow>");
|
|
227
|
+
};
|
|
228
|
+
let mut keys = Vec::new();
|
|
229
|
+
let mut threads = None;
|
|
230
|
+
let mut flags = flags.iter();
|
|
231
|
+
while let Some(flag) = flags.next() {
|
|
232
|
+
let Some(value) = flags.next() else {
|
|
233
|
+
usage_error(&format!("{flag} needs a value"));
|
|
234
|
+
};
|
|
235
|
+
match flag.as_str() {
|
|
236
|
+
"--key" => keys.extend(value.split(',').map(str::to_string)),
|
|
237
|
+
"--threads" => threads = Some(positive("--threads", value)),
|
|
238
|
+
_ => usage_error(&format!("unknown flag {flag:?}")),
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if keys.is_empty() {
|
|
242
|
+
keys.push("id".to_string());
|
|
243
|
+
}
|
|
244
|
+
let left = FileInput::load(left_path);
|
|
245
|
+
let right = FileInput::load(right_path);
|
|
246
|
+
run(
|
|
247
|
+
&left,
|
|
248
|
+
&right,
|
|
249
|
+
&options_for(keys, threads),
|
|
250
|
+
&format!("file: {left_path} vs {right_path}"),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
fn generated_mode(args: &[String]) {
|
|
255
|
+
let rows = args.first().map_or(1_000_000, |a| {
|
|
256
|
+
i64::try_from(positive("rows", a).get()).unwrap_or_else(|_| usage_error("rows too large"))
|
|
257
|
+
});
|
|
258
|
+
let shape = args.get(1).map_or("linear", String::as_str);
|
|
259
|
+
let threads = args.get(2).map(|a| positive("threads", a));
|
|
260
|
+
let params: Vec<usize> = args
|
|
261
|
+
.iter()
|
|
262
|
+
.skip(3)
|
|
263
|
+
.map(|a| positive("shape parameter", a).get())
|
|
264
|
+
.collect();
|
|
265
|
+
let param = |i: usize, default: usize| params.get(i).copied().unwrap_or(default);
|
|
266
|
+
let (case, arity) = match shape {
|
|
267
|
+
"linear" => (Case::Linear, 0),
|
|
268
|
+
"allchange" => (Case::AllChange, 0),
|
|
269
|
+
"wide" => (Case::Wide(param(0, 512)), 1),
|
|
270
|
+
"manycols" => (
|
|
271
|
+
Case::ManyCols {
|
|
272
|
+
ncols: param(0, 34),
|
|
273
|
+
width: param(1, 64),
|
|
274
|
+
},
|
|
275
|
+
2,
|
|
276
|
+
),
|
|
277
|
+
_ => usage_error(&format!("unknown shape {shape:?}")),
|
|
278
|
+
};
|
|
279
|
+
if params.len() > arity {
|
|
280
|
+
usage_error(&format!("shape {shape} takes at most {arity} parameter(s)"));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let (schema, left_shape, right_shape, key) = case.build(rows);
|
|
284
|
+
let options = options_for(vec![key.to_string()], threads);
|
|
285
|
+
let batch = batch_rows();
|
|
286
|
+
let (left, left_write) = spool_side(&Generated {
|
|
287
|
+
schema: schema.clone(),
|
|
288
|
+
rows,
|
|
289
|
+
shape: left_shape,
|
|
290
|
+
batch,
|
|
291
|
+
});
|
|
292
|
+
let (right, right_write) = spool_side(&Generated {
|
|
293
|
+
schema,
|
|
294
|
+
rows,
|
|
295
|
+
shape: right_shape,
|
|
296
|
+
batch,
|
|
297
|
+
});
|
|
298
|
+
println!(
|
|
299
|
+
"spool write (both sides, before the diff): {:.3} s",
|
|
300
|
+
(left_write + right_write).as_secs_f64()
|
|
301
|
+
);
|
|
302
|
+
run(
|
|
303
|
+
&left,
|
|
304
|
+
&right,
|
|
305
|
+
&options,
|
|
306
|
+
&format!("rows per side: {rows} (shape={shape})"),
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
fn main() {
|
|
311
|
+
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
312
|
+
match args.split_first() {
|
|
313
|
+
Some((mode, rest)) if mode == "file" => file_mode(rest),
|
|
314
|
+
_ => generated_mode(&args),
|
|
315
|
+
}
|
|
316
|
+
}
|