Skip to content

perf: optimize native parse_url extraction - #7329

Open
sunchao wants to merge 3 commits into
apache:mainfrom
sunchao:sunchao/oss-parse-url-extraction
Open

sunchao wants to merge 3 commits into
apache:mainfrom
sunchao:sunchao/oss-parse-url-extraction

Conversation

@sunchao

@sunchao sunchao commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Which issue does this PR close?

No linked issue; this optimizes the existing native URL expressions.

Rationale for this change

Native parse_url and try_parse_url currently allocate a temporary string for each extracted component and run a general URI regex for ordinary HTTP/HTTPS URLs. That work is repeated for every input row.

What changes are included in this PR?

Borrow component slices until Arrow copies them into independently owned output buffers. FILE uses the contiguous path/query slice, and query-key matches retain the existing regex capture semantics. Lowercase HTTP/HTTPS prefixes use delimiter decomposition with the existing validation; other URLs keep the native regex parser.

Extend the benchmark to cover all three Arrow string layouts, short and long URLs, nulls, Unicode, query keys, invalid inputs, and FTP controls.

How are these changes tested?

  • All 31 native URL tests pass, including new decomposition-parity, FILE/query-capture, and sliced-array/output-ownership coverage for Utf8, LargeUtf8 and Utf8View.
  • Rust formatting and git diff --check pass.
  • Focused Clippy passes with warnings treated as errors (--lib --tests --bench parse_url -- -D warnings).

Benchmark measurements

Compared source commit a0c4ac35de with upstream 7f73d86fb841f5662f1343cf52b6e6b31eb17dfb, using the identical extended benchmark on both versions. These are complete native UDF calls, including argument broadcasting and Arrow output construction, over 8,192 rows per batch. They are not end-to-end Spark query measurements.

Selected results show the Criterion mean time per batch. Speedup is base time divided by PR time.

Workload Arrow layout Base (ms) PR (ms) Speedup
HTTP host Utf8 9.291 1.591 5.84×
HTTPS host Utf8 9.480 1.533 6.18×
Long HTTPS path Utf8 218.043 39.967 5.46×
Long HTTPS path Utf8View 197.220 21.031 9.38×
HTTPS query by key Utf8 10.783 3.438 3.14×
HTTPS path, 10% null Utf8 8.120 1.317 6.17×
HTTPS path, 90% null Utf8 1.099 0.265 4.15×
FTP host (control) Utf8 9.077 8.805 1.03×
Full results for all 25 benchmark cases
Case Base (ms) PR (ms) Speedup
dense_nulls/Utf8/PATH 1.099 0.265 4.15x
ftp/LargeUtf8/HOST 9.105 8.632 1.05x
ftp/Utf8View/HOST 8.813 8.678 1.02x
ftp/Utf8/HOST 9.077 8.805 1.03x
http/Utf8/HOST 9.291 1.591 5.84x
https/LargeUtf8/FILE 9.604 1.365 7.03x
https/LargeUtf8/HOST 8.884 1.513 5.87x
https/LargeUtf8/PATH 8.837 1.317 6.71x
https/Utf8View/FILE 9.676 1.392 6.95x
https/Utf8View/HOST 9.363 1.491 6.28x
https/Utf8View/PATH 9.197 1.339 6.87x
https/Utf8/AUTHORITY 9.013 1.343 6.71x
https/Utf8/FILE 9.635 1.328 7.26x
https/Utf8/HOST 9.480 1.533 6.18x
https/Utf8/PATH 9.114 1.326 6.87x
https/Utf8/PROTOCOL 8.782 1.327 6.62x
https/Utf8/QUERY 9.352 1.343 6.96x
https/Utf8/REF 9.027 1.335 6.76x
https_long/LargeUtf8/PATH 220.964 39.751 5.56x
https_long/Utf8View/PATH 197.220 21.031 9.38x
https_long/Utf8/PATH 218.043 39.967 5.46x
query_key 10.783 3.438 3.14x
sparse_nulls/Utf8/PATH 8.120 1.317 6.17x
try_invalid/Utf8/PATH 8.091 1.516 5.34x
unicode/Utf8/PATH 9.320 1.489 6.26x

FTP exercises the generic parser. Measurements varied across runs: an early run flagged a slowdown, but head/base/base/head repeats did not reproduce a consistent direction. The table reports only the final source after an equivalent prefix-check lint cleanup. Criterion flagged no regression in that final run. These measurements do not establish statistical equivalence or a precise FTP speedup.

Environment: Linux x86_64, AMD EPYC Milan, Rust 1.98.1, release profile with LTO disabled and 16 codegen units, default Rust allocator. Full paired runs: 20 samples, 1-second warmup, 2-second requested measurement time; FTP repeats: 40 samples and 3-second requested measurement time. Criterion extended sampling when necessary.

Local validation limitation: the environment's registry mirror lacks versions pinned by the upstream lockfile, including uuid 1.29.0. Tests, Clippy and both benchmark builds use the same uncommitted alternate lockfile in an isolated source copy. It retains DataFusion 55.2.0 and Arrow 59.3.1 but changes some transitive versions, including ICU 2.4 to 2.3. The submitted manifests and lockfile are unchanged. Exact-lock workspace build/Clippy and JVM integration tests remain for CI; the Maven portion of local make format could not write to the default read-only Maven cache.

From the root of a checkout of this PR, run the same benchmark harness on both revisions and save the baseline in a shared Criterion directory. Both builds use the same dependency lock.

bench_dir=$(mktemp -d)
git worktree add --detach "$bench_dir/base" 7f73d86fb841
bench_file=native/spark-expr/benches/parse_url.rs
cp "$bench_file" "$bench_dir/base/$bench_file"
export CRITERION_HOME="$bench_dir/criterion"
export CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16

bench() {
  cargo bench --locked -p datafusion-comet-spark-expr --bench parse_url -- \
    --sample-size 20 --warm-up-time 1 --measurement-time 2 --noplot "$@"
}
(cd "$bench_dir/base/native" && bench --save-baseline main)
(cd native && bench --baseline main)

@sunchao sunchao added run-benchmark-check Run the benchmark compile and lint check on this pull request instead of waiting for the merge queue run-spark-4.1-tests Run the Spark 4.1 SQL tests on this pull request instead of waiting for the merge queue labels Oct 30, 2026
@github-actions github-actions Bot added enhancement New feature or request performance area:expressions Expression evaluation labels Oct 30, 2026
@sunchao

sunchao commented Oct 30, 2026

Copy link
Copy Markdown
Member Author

cc @andygrove @comphead @viirya for review

@comphead comphead left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fast path sits after has_invalid_uri_chars and feeds the same bracket, scheme and :// checks, so it changes results only if the two decomposers disagree, and in a local probe of 3M random strings (558k starting with http:// or https://) they never did on inputs that pass validation. The query-key regex and group 2 match Spark's UrlExpressionEvalUtils, and FromIterator<Option<&str>> copies into fresh buffers for all three layouts. Three comments.

Separately, and not caused by this PR: a JDK 17 probe of java.net.URI shows main already differs from Spark on a few inputs. http://a_b.com/p and http://u@v@h/p give a null host in Java (registry-based authority), where extract_host returns a_b.com and h, and http://h/%zz and http://h/p#a#b throw URISyntaxException in Java, so Spark returns NULL or raises, while has_invalid_uri_chars lets both through. That seems worth its own issue.

file,
query,
fragment,
}) = decompose_http(value).or_else(|| decompose_generic(value))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Appendix B regex is a fixed delimiter split, so one hand-written splitter could replace both decompose_http and decompose_generic for every input, removing URI_REGEX, the second decomposer and the parity test, and extending the speedup to ftp://, HTTP://, mailto: and scheme-relative inputs (the FTP control is 1.03x today). With leftmost-first matching, (([^:/?#]+):)? is taken exactly when the first of :/?# is a : at index > 0, and the rest always matches unless there is a \n in the fragment, which has_invalid_uri_chars already rejects. Roughly:

let (scheme, rest) = match value.find([':', '/', '?', '#']) {
    Some(i) if i > 0 && value.as_bytes()[i] == b':' => (Some(&value[..i]), &value[i + 1..]),
    _ => (None, value),
};
// then the authority split on "//" and the remainder as in decompose_http

In a local probe outside the repo, against regex 1.13.1 from the lockfile, such a splitter matched URI_REGEX on 1.78M random inputs that pass validation, and decomposed 8,192 benchmark-shaped FTP URLs in about 0.16 ms against 4.3 to 5.4 ms for the regex (decomposition only, not the UDF).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1. One more reason it's safe: on any input that passes has_invalid_uri_chars, URI_REGEX always matches. Every group is optional or can be empty, and the only way to fail is a \n inside the fragment (. excludes it), which is already rejected as a control character. So the let … else { return Ok(None) } arm here and the caps.get(5)? in decompose_generic are unreachable today. A total splitter would make that explicit, and URI_REGEX could move into the tests as the oracle for a parity test covering all schemes, not just http/https. I also fuzzed both decompose_http and the splitter sketched above against the regex on ~1.4M random validated inputs (biased toward :/?#@[], non-ASCII, and http:///HTTP:///// fragments) and found no mismatch.

// matcher return group 2 of the full pattern, not its last group.
("https://h/?abc=1&v=2", "QUERY", Some("(abc)"), "abc"),
("https://h/?abc=1&v=2", "QUERY", Some("v"), "2"),
("https://h/?abc=1&v=2", "QUERY", Some("(abc)"), "abc"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This row repeats line 496 exactly.


- Performance (tuned 2026-07-11, PR [#4893](https://github.com/apache/datafusion-comet/pull/4893)): cache the compiled query regex in a thread-local single-slot cache keyed by the query key, eliminating per-row `Regex::new()` recompilation in the QUERY-with-key path (the key is constant across a batch in practice). Up to 50x faster. Benchmark: `benches/parse_url.rs`.

- Performance (tuned 2026-10-30, PR [#7329](https://github.com/apache/datafusion-comet/pull/7329)): borrow raw URL components and use delimiter decomposition for HTTP/HTTPS. Non-null HTTP(S) component microbenchmarks are 5.5–9.4x faster and query-key extraction is 3.1x faster. The PR records the alternate-lockfile measurement environment and generic-parser controls. Benchmark: `benches/parse_url.rs`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other entries here name the technique, the speedup, the PR and the benchmark. The lockfile sentence describes how this PR was measured, which the description already covers, and will not help someone checking later whether parse_url has been tuned. Could it go?

@viirya viirya left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HTTP(S) fast path is equivalent to the regex on validated input: it splits at the first #, then at the first ?, which is what [^?#]* / [^#]* do. Borrowed slices are copied into fresh output buffers for all three layouts. The FTP control (1.03x) suggests nearly all of the gain comes from skipping the regex rather than from dropping the per-row Strings. That makes the single-splitter route comphead suggested the more valuable direction: it would carry the same speedup to every scheme instead of only lowercase http:///https:// prefixes.

}

#[test]
fn sliced_string_layouts_produce_owned_output() -> Result<()> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test can't fail because of anything this PR changes. It checks that Arrow's FromIterator<Option<&str>> copies into fresh buffers, which also held before this PR, since the old code collected Option<String>. "The output remains valid after dropping all inputs" is already guaranteed by ArrayRef being 'static. The only thing it would catch is a future switch to zero-copy views of the input, which nothing here is moving toward. Unless that risk is concrete, I'd drop this test and the matching comment on process_parse_url (line 423), since slicing over the three layouts is Arrow's iterator behavior, not parse_url logic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:expressions Expression evaluation enhancement New feature or request performance run-benchmark-check Run the benchmark compile and lint check on this pull request instead of waiting for the merge queue run-spark-4.1-tests Run the Spark 4.1 SQL tests on this pull request instead of waiting for the merge queue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants