Note: gists have no directories, so files referenced below as harness/src/... and results/... appear here under their bare filenames.
Standalone benchmarks for apache/datafusion-comet PR #5612, comparing:
- BASE: commit
8729f6e6a(main at the time, one regex compile per batch invocation) - HEAD: commit
b4d915236(branchperf/compile-user-regex-once, one-slot PatternCache per planned expression)
- Apple M5, 10 cores (4 performance + 6 efficiency), macOS (Darwin 25.6.0)
- rustc 1.98.0 (88d9e12ae 2026-08-18), cargo 1.98.0
- All builds
--release(opt-level 3, thin LTO, codegen-units 1) - Dependency versions match the repo workspace pins: datafusion 54.1.0, arrow 58.4.0, regex 1.13.1 (resolved from the workspace's
regex = "1.12.3"), regex-automata 0.4.16
harness/Cargo.toml has a path dependency on the repo's native/spark-expr crate, so the identical harness source builds against whichever commit is checked out in the repo. The harness uses only symbols that exist in both commits: UDFs are constructed through create_comet_physical_fun (the serde entry point) and evaluated through ScalarUDF::invoke_with_args, with the regex pattern supplied as a scalar argument on every invoke, exactly as the engine does. Build each commit into its own target dir so the two builds never clobber each other:
cd harness
# with the repo checked out at HEAD (b4d915236):
cargo build --release --target-dir ../target-head
# with the repo checked out at BASE (git checkout 8729f6e6a):
cargo build --release --target-dir ../target-base
Adjust the absolute path in harness/Cargo.toml to your checkout location.
bench5612(src/main.rs): the main matrix. Functions {regexp_extract, regexp_extract_all, split} x sharing {shared single UDF instance across threads, one instance per worker} x workers {1,2,4,8} x batch rows {512, 8192} x pattern regime {warm, alternating}. Also measures allocations per batch (counting global allocator), cold first-invoke cost, and FNV-1a hashes of all outputs for a BASE vs HEAD equality check. Run:bench5612 <label> <out_dir>.sort_repro(src/bin/sort_repro.rs): the sort-key sharing scenario. Builds the exact plan shape Comet produces (PhysicalSortExpr wrapping a scalar function expression, passed to SortExec with no fetch, single partition memory source of 128 x 8192-row batches, about 21 MB so the reservation is far past the 1 MiBsort_in_place_threshold_bytesdefault). DataFusion 54.1.0ExternalSorter::in_mem_sort_streamthen sorts every buffered batch in its own tokio task viaspawn_buffered(datafusion-physical-plan-54.1.0/src/sorts/sort.rs lines 586-646), all tasks sharing the same UDF instance. A tracking shim around one comet UDF instance records total invokes and max concurrent in-flight invocations. Run:sort_repro <label> <function> <workers> <iters>on a multithread tokio runtime with<workers>worker threads.shared_clone(src/bin/shared_clone.rs): commit-independent micro benchmark that isolates the mechanism of the shared-instance slowdown using the regex crate directly. Modes price separately: fresh compile per invoke (BASE), mutex slot + clone from a shared source (HEAD shared), mutex slot + clone from a per-thread source (HEAD per worker), plain clones from shared vs private sources, hoisted clones, direct shared use, and clone-only loops. Run:shared_clone [invokes_per_thread].
results/{base,head}_cells.csvand{base2,head2}_cells.csv: two full replicates of the main matrix (throughput, mean/p50/p99 per-batch latency, allocations per batch)results/*_cold.csv: cold first-invoke cost, 30 fresh instances eachresults/*_verify.csv: output hashes; identical across all four runs (BASE and HEAD produce byte-identical results)results/sort_head_w1.csv,sort_head_w8.csv,sort_base_w1.csv,sort_base_w8.csv: first sort_repro runs (regexp_extract_all only, before the function column was added)results/sort_{base,head}_all.csv: sort_repro for both regexp_extract_all and regexp_extract at 1 and 8 workersresults/sort_extract_w1_rep2.csv,sort_extractall_w8_rep2.csv: interleaved BASE/HEAD confirmation runsresults/shared_clone_micro.csv: mechanism micro benchmarkanalyze.py: merges the main-matrix CSVs into markdown tables and checks the output hashes
- Warm patterns (the realistic case; Comet plans regexp patterns as literals, one expression instance per Spark task thread): HEAD removes a full regex compile per batch. Small batches benefit most (regexp_extract, 512 rows, 8 per-worker threads: 5.8 to 62.8 Mrows/s). Allocations per batch drop up to 92 percent.
- Alternating patterns on one instance (one-slot cache worst case, 1 worker): within 2 percent of BASE for all three functions.
- Concurrent same-instance evaluation does happen on the SortExec path (max in-flight 9 at 8 runtime workers, 2 at 1 worker because the merge evaluates on the driver thread concurrently with one spawned sort task).
- On that sort path, HEAD regresses vs BASE for regexp_extract_all as the sort key (about 1.45x wall at 1 worker, about 2.2x at 8 workers), while regexp_extract as the sort key shows parity to a small HEAD win.
- Mechanism (shared_clone_micro.csv): not the PatternCache mutex and not the per-invoke Regex clone. regex-automata 0.4.16
impl Clone for Regex(src/meta/regex.rs lines 1916-1926) shares the compiled program via Arc but creates a fresh private scratch pool, so scratch state is never shared. The contention is per row:Regex::captures_itercallscreate_captures(meta/regex.rs line 657), which isCaptures::all(self.group_info().clone())(line 1571), anArc::cloneof the program-ownedGroupInfo, plus one moreCapturesclone per match (CapturesMatches::next). When many threads run captures_iter over clones of ONE compiled program, they all hammer the refcount cache line of that singleGroupInfoInnerallocation. In the micro benchmark this caps every shared-program mode at about 6-15 Mrows/s regardless of thread count (2 threads drop below 1 thread total), while per-thread-program modes scale to 60-80 Mrows/s. regexp_extract is unaffected because it reuses oneCaptureLocationsacross rows (captures_read, native/spark-expr/src/string_funcs/regexp_extract.rs lines 94-107); split is unaffected becausefind_itercreates noCaptures. BASE never shares a program across threads (it compiles per invocation), which is why BASE shared and per-worker numbers are identical.
After the analysis above, regexp_extract_all was changed on the PR branch (commit f35bc97fa) to drive iteration with find_iter and resolve capture groups through captures_read_at into one CaptureLocations reused for the whole batch, in both extract_all_array and extract_one. That removes the per-row create_captures and the per-match Captures clone, and with them both the shared GroupInfo Arc refcount traffic and the per-match heap allocations.
Result files: results/sort_fix.csv (labels base3 and headfix, interleaved runs), results/{headfix,base3}_{cells,cold,verify}.csv.
Sort-key scenario (median wall seconds, regexp_extract_all as the SortExec key):
| workers | BASE | HEAD before fix | HEAD with fix |
|---|---|---|---|
| 1 | 0.171 | 0.251 (1.45x slower) | 0.166 (3 percent faster than BASE) |
| 8 | 0.130 | 0.287 (2.2x slower) | 0.120 (8 percent faster than BASE) |
Main-matrix cells that regressed (regexp_extract_all, warm, 8192 rows, Mrows/s, BASE3 / HEAD before fix / HEAD with fix):
| sharing | w=1 | w=2 | w=4 | w=8 |
|---|---|---|---|---|
| shared | 13.1 / 14.9 / 15.3 | 13.0 / 9.4 / 27.8 | 10.3 / 9.5 / 52.7 | 7.1 / 6.3 / 72.4 |
| perworker | 13.1 / 14.9 / 15.4 | 13.0 / 13.9 / 27.4 | 10.3 / 10.8 / 53.3 | 7.1 / 7.2 / 74.2 |
The fix does more than remove the contention: eliminating the per-match allocations lets the function scale nearly linearly to 8 workers in BOTH sharing modes (previously even the uncontended per-worker mode was allocator-bound at about 7 Mrows/s). Single thread: +17 percent vs BASE at 8192 rows (+3 percent vs unfixed HEAD); at 512 rows +89 percent vs BASE but about 12 percent below unfixed HEAD, the cost of rerunning the capture engine at each match start. Alternating-pattern worst case stays within 2 percent of BASE. Output hashes for all functions, batch sizes, and regimes remain byte-identical across BASE, both HEAD builds, and the fixed build (0 mismatches in 12 hash cells x 4 runs).