Skip to content

RFC: url_pattern_list — compiled route-set matching (URLPatternList) - #1234

Open
FranciscoThiesen wants to merge 13 commits into
ada-url:mainfrom
FranciscoThiesen:url-pattern-list
Open

RFC: url_pattern_list — compiled route-set matching (URLPatternList)#1234
FranciscoThiesen wants to merge 13 commits into
ada-url:mainfrom
FranciscoThiesen:url-pattern-list

Conversation

@FranciscoThiesen

Copy link
Copy Markdown

What this is

An RFC implementation of set-level URLPattern matchingada::url_pattern_list — opened as a draft to gather feedback on whether this belongs in ada and in what shape, before I polish it further.

Routing over N routes with URLPattern today means a sequential exec() loop: O(N) per request, each step running regexes. Matteo Collina's "You should not use URLPattern to route HTTP requests on the server" documents the consequence, and the WHATWG fix — URLPatternList, whatwg/urlpattern#166 — has been open since 2022 with no implementation anywhere. Since ada's URLPattern is what Node 24 and Workers ship, ada seems like the right place for the first one.

What it does

url_pattern_list::create(patterns) compiles the whole route set once:

  • Patterns are parsed by ada's existing URLPattern machinery (so canonicalization and part semantics are inherited, not re-invented); parts classify into literal / :param / * segments.
  • The safe subset (which is ~all real route tables) compiles into a segment trie with per-node dispatch tables computed at build time — the same trick ada already hand-rolls for schemes ((2 * scheme.size() + scheme[0]) & 7), generalized: the builder searches for the few bytes that distinguish each node's children, plus two levels of whole-route lookup tables for fully-static and fixed-shape parameterized routes.
  • Routes with regex groups, and inputs beyond the fast-path limits, fall back to a sequential path with identical semantics — correctness never depends on the fast path.
  • match(pathname) is allocation-free: segment scan, table probes, bounded trie walk, captures returned as slices.

Two properties worth calling out:

  1. No regex engine needed for the safe subset — a compiled list of static/:param/* routes never touches a regex provider. Given that ada currently has to label its only bundled provider unsafe, a routing path with no provider requirement at all seemed worth having.
  2. Bounded worst-case matching — the walk is backtracking-bounded by construction (≤ 2·segments + constant node visits), i.e. the complexity-attack story for routing becomes a structural property instead of a regex-engine property.

Numbers

From the self-contained benchmark included in this PR (benchmarks/), ~100 realistic REST routes, Apple M3 Max, LLVM 22:

routes: 101 / urls in stream: 512 (incl. ~20% near-misses, one live regexp route) / sequential-vs-list disagreements: 0
BasicBench_SequentialURLPatternExec   58.42 us/url   (17.1k urls/s)
BasicBench_URLPatternListMatch        54.00 ns/url   (18.5M urls/s)     ~1,082x

In a larger external harness (100–500 routes, 73k verified URLs, cross-checked against find-my-way and a naive matcher for identical answers), the same engine measures 5–35 ns/match — ~7–23× find-my-way and 2,000–16,700× a sequential exec() loop. Happy to share that harness or run any benchmark you'd prefer.

Open questions (the feedback I'm after)

  1. Is this desirable in ada at all — or would you rather see it as a separate library on top of ada?
  2. Match-order semantics. This implements specificity order (static > :param > * per segment, insertion-order tiebreak — find-my-way/Express-compatible). The spec PR stalled partly on exactly this question; insertion-order-first-match is implementable but forfeits some of the table shortcuts. Which contract should ada expose?
  3. API shape/namingurl_pattern_list mirrors the WHATWG proposal; open to whatever fits ada's surface. Related: captures are currently returned as (offset,length) slices of the input (allocation-free), and regexp-route group values are not materialized (the caller re-execs that route's url_pattern) — is that the right trade for ada?
  4. Scope — v1 is pathname-only (other components wildcard). Reasonable for an RFC?
  5. The builder is deliberately constexpr-friendly — with C++26 the whole table set can freeze into .rodata (zero init; I keep that tier in a companion since ada is C++20). If there's interest I can sketch how it composes with the more-constexpr direction.

Limits, design notes, and tests (including a randomized differential test against an independent reference matcher) are in the code. If the direction is wrong, telling me so bluntly saves us both time — that's what the draft status is for.

ada::url_pattern_list<regex_provider> compiles a set of URLPattern
pathname patterns into one dispatch structure (segment trie with
per-node witness-byte dispatch, a whole-pathname exact table for fully
static routes, and per-shape dispatch tables for parameterized routes),
answering "which route matches this pathname, and what are the
parameter values?" in tens of nanoseconds instead of a loop of
url_pattern::exec calls. This is the URLPatternList use case
(whatwg/urlpattern#166), scoped to the pathname component.

Design points:

- The pattern side reuses ada's own URLPattern machinery:
  parse_pattern_string + canonicalize_pathname classify each pattern's
  part list; the static / ":param" / "*" subset is compiled, and
  patterns needing regexp semantics are matched through the regex
  provider while participating in the same priority order.
- Match priority is specificity order (literal < ":param" < "*",
  per-segment from the left), insertion order breaking ties -
  find-my-way/Express-compatible. Whether URLPatternList should use
  insertion order instead is an open question for the RFC.
- Fast-path limits (4096-byte inputs, 24 input segments, 16 pattern
  segments, 8 captures, 254-entry dispatch tables) are performance
  gates, not match contracts: inputs and routes beyond them fall back
  to a sequential matcher with identical semantics, and failed offline
  table searches demote to linear scans. No input aborts; construction
  errors return tl::expected (errors::type_error, like the URLPattern
  constructor).
- The matcher is allocation-free and regex-free on the fast path; the
  NEON segment scan has a portable scalar fallback
  (ADA_URL_PATTERN_LIST_NO_NEON) and the packed-window compares are
  endian-safe.

Includes a GTest suite (priority/boundary cases, out-of-fast-path
inputs, a semantics pin against ada::url_pattern, and a randomized
differential test against an independent reference matcher) and a
benchmark (benchmarks/urlpattern_list.cpp) comparing a sequential
url_pattern::exec loop with url_pattern_list::match over a 101-route
REST table: 58.4 us/url vs 54.0 ns/url (~1080x) on Apple M3 Max,
answers cross-checked identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lemire

lemire commented Aug 26, 2026

Copy link
Copy Markdown
Member

There are compile-time regex engines so supporting some flavor of regex is 'easy'.

The application is obviously fantastic. You could possibly be massively faster with a compile-time expression.

But my impression is that URL Patterns are meant and used in a dynamic context.

@anonrig

anonrig commented Aug 26, 2026

Copy link
Copy Markdown
Member

I'm not against it but this needs to be:

  • tested (500 line tests is not enough)
  • fuzzed (there is no fuzzer change in this PR)
  • the API surface area should be guarded with private doc comments - other than public API.
  • needs to be communicated with the WHATWG changes consider exposing URLPatternList whatwg/urlpattern#30

@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.18136% with 166 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.59%. Comparing base (b2c2d7f) to head (e0a63c9).

Files with missing lines Patch % Lines
src/url_pattern_list.cpp 84.93% 7 Missing and 87 partials ⚠️
include/ada/url_pattern_list-inl.h 85.06% 0 Missing and 43 partials ⚠️
include/ada/implementation-inl.h 47.16% 1 Missing and 27 partials ⚠️
include/ada/url_pattern_list.h 94.11% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1234      +/-   ##
==========================================
+ Coverage   63.27%   65.59%   +2.32%     
==========================================
  Files          39       43       +4     
  Lines        7705     8694     +989     
  Branches     3514     3879     +365     
==========================================
+ Hits         4875     5703     +828     
- Misses        752      758       +6     
- Partials     2078     2233     +155     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@codspeed-hq

codspeed-hq Bot commented Aug 26, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 47 untouched benchmarks
🆕 5 new benchmarks
⏩ 4 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
🆕 BasicBench_SequentialURLPatternExec N/A 7.2 ms N/A
🆕 BasicBench_URLPatternListMatch N/A 17.1 µs N/A
🆕 BasicBench_URLPatternListMatch_ParamHits N/A 16.4 µs N/A
🆕 BasicBench_URLPatternListMatch_StaticHits N/A 13 µs N/A
🆕 BasicBench_URLPatternListMatch_WithRegexpRoute N/A 23.8 µs N/A

Comparing FranciscoThiesen:url-pattern-list (e0a63c9) with main (b2c2d7f)

Open in CodSpeed

Footnotes

  1. 4 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

… docs

Addresses the review feedback on the url_pattern_list RFC:

- Tests: coverage-driven expansion of url_pattern_list_tests.cpp (598 ->
  1168 lines, 16 -> 32 tests). Adds a data-driven semantics table (~130
  pattern/input/capture triplets over every subset syntax feature, subset
  boundary, and canonicalization interaction, cross-checked row by row
  against ada::url_pattern::test), targeted tests for every dispatch and
  demotion rung (witness-plan exhaustion for nodes, shape groups and the
  exact table, >64-key slot tables, >254-entry linear demotions, 16-bit
  key overflow, segment-count gate boundaries, probe-order promotion and
  its dependent-group counterpart, kind-sequence packing caps), group-name
  and capture-alignment tests including duplicate names across routes, and
  a per-route url_pattern::test cross-check sampled inside the existing
  differential test. Local llvm-cov: url_pattern_list.cpp 99.67% lines /
  98.57% branches / 100% functions; url_pattern_list-inl.h and
  url_pattern_list.h 100% on all metrics.

- Fuzzing: new fuzz/url_pattern_list.cc (with .options, wired into
  fuzz/build.sh) drives three strategies from one harness: arbitrary
  construction over 1..64 derived patterns exercising the full public
  surface, a differential oracle that rebuilds each route through the
  sequential helpers (or the compiled pathname component) and aborts on
  any winner or capture-slice disagreement, and fast-path-gate crossings
  around the 4096-byte and 24-segment limits. 372k executions over 10
  minutes under ASan+UBSan locally: no findings.

- Docs: every internal type, function and member in url_pattern_list.h is
  now guarded with @Private doc comments (27 markers), following
  character_sets.h/checkers.h conventions; compute_kind_sequence moved out
  of the public header into the translation unit. Doxygen runs clean over
  the tree with no warnings for these files.

- Benchmarks: the shared URL stream shrinks from 512 to 32 URLs so one
  iteration of the sequential url_pattern::exec baseline stays well under
  CodSpeed's per-iteration budget (~2.1 ms locally, was ~34 ms); both
  benchmarks still iterate the identical stream and the disagreement
  cross-check is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@FranciscoThiesen

FranciscoThiesen commented Aug 26, 2026

Copy link
Copy Markdown
Author

@lemire — agreed on the dynamic context, and that's what this PR ships: the "compilation" here happens at create() time in microseconds-to-milliseconds, so fully dynamic registration (Node-style) is the primary path. The C++-compile-time tier exists as a companion experiment — same builder run under constexpr, tables verified byte-identical, zero init — for deployments where the route set is fixed per build (Workers-style, embedded). Happy to sketch how it composes with the more-constexpr direction if there's interest. And yes on compile-time regex engines — a CTRE-style residual in the frozen tier is a lovely direction; in the dynamic tier I went the other way for the common case: the static/:param/* subset provably needs no regex engine at all, which seemed worth having given the provider situation.

@anonrig — all four addressed in ad28a3d:

  • Tests: expanded coverage-first — built with llvm-cov and closed the uncovered branches Codecov flagged. Patch coverage on the new files now measures 99.7% line / 98.6% branch locally; the residual is a handful of defensive demotion paths behind offline-search failure that are not adversarially constructible (happy to detail). Added a ~130-case data-driven table, property tests cross-checking winners against per-route ada::url_pattern itself, and group-name/duplicate-param cases.
  • Fuzzer: fuzz/url_pattern_list.cc following fuzz/url_pattern.cc conventions (FuzzedDataProvider, .options, wired into build.sh), with three strategies: arbitrary-pattern-set construction, a differential oracle (fast path vs reference matcher, abort() on disagreement so libFuzzer catches it), and fast-path-gate boundary crossing. Ran locally 10 min under ASan+UBSan: 372k execs, zero findings (one investigated non-finding: ASan allocator high-water from std::regex-heavy units, matching the existing url_pattern fuzzer's exposure; options mirror url_pattern.options).
  • Private API guarding: internal machinery now carries @private doc comments per ada convention (and what could move out of the public header did).
  • WHATWG: drafting a note for consider exposing URLPatternList whatwg/urlpattern#30 referencing this implementation, the no-regex-engine data point, and the match-order question that stalled fix: We had two unnecessary comparisons. #166 — will post it there shortly.

On CodSpeed: the three flagged regressions are URL-setter benchmarks (SetHash/SetProtocol/SetPassword) that this additive, ADA_INCLUDE_URL_PATTERN-gated diff doesn't touch — CodSpeed's own report flags "different runtime environments" for those comparisons. The new sequential-exec benchmark was also slimmed so simulator iterations stay cheap.

@anonrig

anonrig commented Aug 26, 2026

Copy link
Copy Markdown
Member

Can we stop the AI responses and just talk for a second...

@lemire

lemire commented Aug 26, 2026

Copy link
Copy Markdown
Member

@FranciscoThiesen

Ok, I misread the PR, so 'build time' is not 'compile time' here.

Sorry, I did not read the code yet.

So I think that this might be more applicable then. This could be called from JavaScript... so you'd have your set of routes, the C++ code would not some magic and then dispatch them.

@anonrig This sounds like it could be a big deal if Node.js would accept it, no?

@FranciscoThiesen

FranciscoThiesen commented Aug 26, 2026

Copy link
Copy Markdown
Author

Can we stop the AI responses and just talk for a second...

"Please reply to Yagiz and try not to sound like AI from now on. Make no mistakes!", just kidding (:

Sure! Let me know what else you want to know about the PR. Will post on the WHATWG thread later today, want to make sure I have full context on the thread.

@anonrig @lemire Feel free to push back on any aspects of the PR, as I am a tourist here...

…identical to main)

Including the ~1800-line route-set compiler in the unity ada.cpp reshuffles
GCC's unit-wide inlining budget: url_aggregator setter hot paths lose inlined
std::string growth (e.g. append_base_pathname 200 -> 106 instructions), which
CodSpeed reported as SetHash -12%, SetProtocol -6%, SetPort -5%, SetHostname
-3%. Clang is unaffected.

Mirror the ADA_PERCENT_ENCODE_SIMD_SEPARATE_TU pattern from ada-url#1230: build
url_pattern_list.cpp as a separate TU under CMake (ADA_URL_PATTERN_LIST_SEPARATE_TU)
while the amalgamated single-file build keeps including it inline. With the
guard active the unity TU's pre-existing functions are opcode-identical to
main (0 of 793 changed, GCC 14 -O3). Also drops an unused constant that the
standalone TU surfaced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@FranciscoThiesen

FranciscoThiesen commented Aug 27, 2026

Copy link
Copy Markdown
Author

whatwg/urlpattern#30 @anonrig @lemire here is my reply on the correlated WHATWG issue

@anonrig

anonrig commented Aug 27, 2026

Copy link
Copy Markdown
Member

@jasnell are you interested in adding this to cloudflare workers?

@jasnell

jasnell commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Not opposed but will have to evaluate. Won't be an immediate priority

@anonrig anonrig 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.

Two things before this grows further:

  1. Regex engine. The regex_provider template is correct. It still does not match parse_url_pattern: no free function, no url_pattern_options / ignore_case, regexp routes use fast_test and drop captures instead of regex_search, and tests only ever instantiate std_regex_provider.
  2. Don’t add code we don’t use. shape_group::{mask,n_static,witness_ids} are never read at match time, n_ids_out is a dead out-parameter, and the public header ships the whole compiler (trie_node, shape_group, compile_route_set, …). eq_bytes and the NEON scanner are extra unless we can show we need them.

Public API should stay url_pattern_list + match result + limits, with the provider passed the same way as URLPattern.

Comment thread include/ada/url_pattern_list.h Outdated
Comment thread include/ada/url_pattern_list.h Outdated
Comment thread include/ada/url_pattern_list.h Outdated
Comment on lines +42 to +50
/**
* Internal machinery for ada::url_pattern_list: the provider-independent
* route-set compiler and matcher. Nothing in this namespace is part of the
* supported public API except the limit constants and the `capture` struct
* (re-exported by url_pattern_list_match_result), which are documented
* because they delimit the fast path and the result contract. Everything
* else lives here only because the url_pattern_list class template needs it
* and is marked @private; it may change at any time.
* @namespace ada::url_pattern_list_helpers

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 is all in the public header (ada.h includes it). @private comments are not enough — anyone including ada sees trie_node, shape_group, compile_route_set, etc.

Public surface should be url_pattern_list, the match result, and the limit constants. The compiler types can live in a header the fuzzer includes, not in ada.h.

@FranciscoThiesen FranciscoThiesen Sep 2, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Moved.

  • include/ada/url_pattern_list.h keeps the limits, the match result, url_pattern_list, and the table records the inline matcher reads, under url_pattern_list_detail. The walk is inline per your other comment, so those records have to be visible.
  • The compiler (route_info, classify_parts, compile_route_set, the witness planners, the arena packing) is in src/url_pattern_list_compiler.h. ada.h does not include it and it is not installed.
  • The fuzzer gets it through the amalgamated ada.cpp, like the other fuzzers. The OSS-Fuzz script only has -I build/singleheader.

One more thing in that header since: url_pattern_list-inl.h includes <arm_neon.h> under ADA_NEON, now that the NEON scan is back. The src/ files did already; this is the first public header that does. Say if that bothers you.

Comment thread include/ada/url_pattern_list-inl.h Outdated
Comment on lines +92 to +100
if (candidate.mode == helpers::route_mode::regexp) {
const url_pattern_component<regex_provider>& component =
regexp_components_[static_cast<size_t>(candidate.regexp_component)];
if (component.fast_test(pathname)) {
// Capture slices are not recoverable through the provider interface;
// see url_pattern_list_match_result.
best = helpers::engine_result{};
best.route = static_cast<int32_t>(route_index);
}

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 provider already has regex_search. Don’t drop captures here and tell the caller to re-exec a url_pattern.

url_pattern uses regex_provider::regex_search for exec. Do the same. fast_test / regex_match only answers yes/no.

@FranciscoThiesen FranciscoThiesen Sep 2, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Changed. A regexp route now runs regex_match and then regex_search, the same test/exec split url_pattern has, and the result carries the groups: regexp_route plus regexp_groups, aligned with group_names(). Subset routes keep the (offset, length) slices.

I kept the regex_match step in front on purpose. With libc++ a regex_search miss on a (\d+) route scans from every start position, so a near miss costs far more than a failed regex_match. With the pruning from your other comment the provider is rarely reached at all, so this only matters for the routes that do get tested, but there it is the difference between a cheap rejection and a scan.

Comment thread include/ada/url_pattern_list-inl.h Outdated
Comment on lines +47 to +54
} else {
// The pattern needs URLPattern regexp semantics: compile it as a
// pathname component through the provider; it participates in the
// priority order via its approximated kind sequence.
auto compile_options = url_pattern_compile_component_options::PATHNAME;
auto component = url_pattern_component<regex_provider>::compile(
list.patterns_[i], url_pattern_helpers::canonicalize_pathname,
compile_options);

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.

url_pattern_component::compile takes url_pattern_compile_component_options, which has ignore_case. This always compiles with the default (case-sensitive).

parse_url_pattern forwards url_pattern_options::ignore_case into the component. This path should too, otherwise a custom provider never sees the flag.

@FranciscoThiesen FranciscoThiesen Sep 2, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed: compile_options.ignore_case = options->ignore_case for the regexp components. The counting-provider test checks that the flag reaches create_instance.

The static / :param / * subset folds ASCII to match. Literals are folded at creation, and the input is folded into a stack copy at match time, so captures still slice the original. There is a parity test against url_pattern with the option set.

Comment thread src/url_pattern_list.cpp Outdated
Comment on lines +166 to +194
inline bool eq_bytes(const char* a, const char* b, size_t len) noexcept {
if (len < 4) {
if (len == 0) {
return true;
}
return a[0] == b[0] && a[len - 1] == b[len - 1] &&
a[len >> 1] == b[len >> 1];
}
if (len <= 8) {
uint32_t a0, a1, b0, b1;
std::memcpy(&a0, a, 4);
std::memcpy(&b0, b, 4);
std::memcpy(&a1, a + len - 4, 4);
std::memcpy(&b1, b + len - 4, 4);
return ((a0 ^ b0) | (a1 ^ b1)) == 0;
}
uint64_t acc = 0;
size_t i = 0;
for (; i + 8 < len; i += 8) { // last <= 8 bytes handled by the tail load
uint64_t x, y;
std::memcpy(&x, a + i, 8);
std::memcpy(&y, b + i, 8);
acc |= x ^ y;
}
uint64_t x, y;
std::memcpy(&x, a + len - 8, 8);
std::memcpy(&y, b + len - 8, 8);
return (acc | (x ^ y)) == 0;
}

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.

Do we need a hand-rolled eq_bytes? memcmp is enough. Same question for the NEON segment scan below — the scalar loop is the fallback and is what every non-ARM host runs. Don’t add a second scanner unless it is required for the API.

@FranciscoThiesen FranciscoThiesen Sep 2, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

No we don't, I've removed it.

A segment compare is now:

  • 8 to 16 bytes: two 8-byte loads inside the segment
  • under 8 bytes: one masked load when 8 bytes are readable, a byte gather otherwise
  • over 16 bytes: memcmp against the blob

The NEON scan I kept, though. You asked for a 1k+ pathname benchmark before keeping it (your comment on src/url_pattern_list.cpp:338), and it came out faster at every length, not just on long inputs; the numbers are there. The SWAR loop is the portable path and also handles inputs under 16 bytes on ARM.

Comment thread src/url_pattern_list.cpp Outdated
Comment on lines +650 to +652
bool find_shape_plan(const std::vector<transient_shape_entry>& entries,
uint32_t n_static, std::array<uint8_t, 4>& ids_out,
uint8_t& n_ids_out) {

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.

n_ids_out is written and never read by compile_shape_group. Dead out-parameter.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Removed.

Comment on lines +13 to +14
using regex_provider = ada::url_pattern_regex::std_regex_provider;
using list_type = ada::url_pattern_list<regex_provider>;

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.

Every test hardcodes std_regex_provider. url_pattern is specifically designed so Node/Workers can pass their own engine. Add one test with a different provider (even a thin wrapper around std_regex_provider that counts create_instance / regex_search) so we know the template is actually used.

@FranciscoThiesen FranciscoThiesen Sep 2, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added counting_provider, a wrapper around std_regex_provider that counts create_instance, regex_search and regex_match and records the ignore_case flag it was given. The tests that instantiate url_pattern_list<counting_provider> assert exact counts:

  • one create_instance per regexp route, none for subset routes
  • one regex_match plus one regex_search on a regexp hit
  • zero provider calls after a subset hit that nothing can outrank

@anonrig anonrig 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.

Performance notes on the match path. The walk is already in the tens of nanoseconds; the remaining time is extra work after a hit, and layout.

Highest impact:

  1. Don’t walk auxiliary_routes_ after a decisive exact/shape hit — especially if any route is regexp (std::regex will dominate).
  2. Inline the matcher; keep only the builder in the extra TU.
  3. Direct-compare small fanouts (~8) instead of projecting at 3 children.
  4. Pack the ~10 table vectors into one arena; shrink trie_node so dispatch 0/1 nodes don’t carry hash payload.
  5. SWAR slash scan + byte-compare short tails (drop endpad / NEON unless a long-pathname bench says otherwise). memcmp past 16 bytes. First-byte index at the root.

I would not add more shape-table machinery or constexpr tables for the Node path. The provider template is the speed knob for regexp routes — V8/JIT, not std::regex.

Comment thread include/ada/url_pattern_list-inl.h Outdated
Comment on lines +111 to +126
url_pattern_list_match_result url_pattern_list<regex_provider>::match(
std::string_view pathname) const {
namespace helpers = url_pattern_list_helpers;
helpers::engine_result best = helpers::match_compiled(compiled_, pathname);
if (!best.within_fast_path) {
// The input exceeds a fast-path limit (length, segment count, or no
// leading '/'): the sequential fallback matches every route with
// identical priority semantics.
return match_sequential(pathname);
}
// Routes the compiled tables cannot answer for still participate in the
// priority order: the winner is decided by (kind sequence, insertion
// index), never by which path matched it.
for (const uint32_t route_index : auxiliary_routes_) {
consider_route(route_index, pathname, best);
}

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 loop always runs, even after an exact-table hit. A full-static winner already has the best kind sequence — almost nothing can outrank it.

At create(), record which auxiliary routes can actually beat each compiled winner (usually none, or a couple of regexp routes). After an exact/shape hit, only test those.

This is the first thing to fix if the table has even one (\\d+) route. std::regex on the leftover list will dominate the 50ns walk.

@FranciscoThiesen FranciscoThiesen Sep 2, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for pointing this out, it was the biggest win.

At creation each trie route now records which auxiliary routes both outrank it and could match the same input (literal prefixes agree, segment counts are compatible, :param never binds an empty segment). After a hit only those run, and for most routes the list is empty. On top of that, a regexp route made of fixed text and :name groups has an exact segment shape, and one with custom groups an anchored literal prefix; that is checked before any provider call, on hits and on misses.

The table with one (\d+) route went from 110.7 to 42.5 ns/url. On that stream the regex now runs once in 32 URLs (an /api/v1/invoices/.../zz miss that lands on /*, where the regexp route legitimately could win) instead of on every URL. Test: auxiliary_routes_are_pruned_after_a_fast_path_hit.

Comment thread src/CMakeLists.txt Outdated
Comment on lines +67 to +71
# Keep the URLPattern route-set compiler in its own TU for the same reason:
# ~1800 extra lines in the unity TU reshuffle GCC's unit-wide inlining budget
# and de-inline url_aggregator setter hot paths (CodSpeed: SetHash -12%).
# The amalgamated single-file build still includes it inline.
target_compile_definitions(ada PRIVATE ADA_URL_PATTERN_LIST_SEPARATE_TU=1)

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.

Right split for GCC setter inlining, wrong split for a 50ns match. match_compiled / scan_segments / dispatch_static cannot inline into the caller from this TU.

Keep compile_route_set here. Put the walk in a header as ada_really_inline.

@FranciscoThiesen FranciscoThiesen Sep 2, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Moved. scan_segments, verify_edge, dispatch_static and match_compiled are ada_really_inline in url_pattern_list-inl.h. Only the builder stays in src/url_pattern_list.cpp.

I verified the GCC 14 -O3 output of the unity ada.cpp against main: all 793 functions, url_aggregator setters included, still have identical opcode streams.

Comment thread src/url_pattern_list.cpp
Comment on lines +875 to +882
if (nk == 0) {
nd.dispatch = 0;
continue;
}
if (nk <= 2) {
nd.dispatch = 1;
continue;
}

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.

Projection at 3 children, then you still verify_edge. For /users, /posts, /health that is gather + multiply + slot + compare, vs two or three 4–8 byte compares.

Raise the direct threshold to ~8 and measure. Keep the hash path for a wide root node.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I've set to 8, after measuring where the projection starts to win. One node with N static children plus a :rest sibling, inputs spread evenly over the children. Keys are either normal names of mixed length (users, orders, ...) or all the same length and prefix (seg + three letters, so the length check rejects nothing). ns/url, median of 3 runs, projection / direct:

N hits, mixed hits, same length misses, mixed misses, same length
3 20.5 / 19.0 16.8 / 21.4 20.6 / 20.3 17.5 / 18.9
8 20.6 / 19.7 16.8 / 21.8 20.7 / 23.7 17.5 / 25.5
12 21.1 / 20.4 16.8 / 24.9 21.2 / 26.1 17.5 / 31.0
16 21.0 / 21.7 16.9 / 27.1 21.1 / 28.9 17.5 / 36.5
24 20.7 / 23.6 16.8 / 31.1 20.7 / 34.1 17.5 / 47.4

With mixed lengths your intuition is right: 2-3 compares win by ~1.5 ns at 3 children, ~1 ns at 8, even by 12-16. With same-length keys the projection wins at every N (4-5 ns on hits, more on misses), presumably because the loop's exit branch mispredicts once the length check stops filtering. 8 keeps the common case and bounds the other; on the PR table it's a wash against projection from 3 (28.0 vs 28.3 static, 34.9 vs 35.0 :param).

I had 16 at first: +2.5 ns on the PR table, but only because that stream is skewed to /api and the exit becomes predictable. The sweep I ran is about 120 lines on google benchmark; I can push it or gist it if you want.

Comment thread include/ada/url_pattern_list.h
Comment thread include/ada/url_pattern_list.h Outdated
Comment thread src/url_pattern_list.cpp Outdated
Comment on lines +264 to +338
ada_really_inline uint32_t scan_segments(const char* url, uint32_t ulen,
uint16_t* soff) noexcept {
uint32_t nseg = 0;
uint32_t s = 1;
bool overflow = false;
#if ADA_URL_PATTERN_LIST_USE_NEON
const uint8x16_t slash = vdupq_n_u8('/');
auto emit = [&](uint64_t m, uint32_t base) { // one start per '/' lane
while (m) {
const uint32_t tpos =
base + static_cast<uint32_t>(__builtin_ctzll(m) >> 2);
m &= ~(0xFull << ((tpos - base) * 4));
if (nseg >= max_fast_path_segments) {
overflow = true;
return;
}
soff[nseg++] = static_cast<uint16_t>(s);
s = tpos + 1;
}
};
if (ulen >= 16) {
uint32_t i = 0;
for (; i + 16 <= ulen; i += 16) {
const uint8x16_t v0 = vld1q_u8(reinterpret_cast<const uint8_t*>(url) + i);
uint64_t m =
vget_lane_u64(vreinterpret_u64_u8(vshrn_n_u16(
vreinterpretq_u16_u8(vceqq_u8(v0, slash)), 4)),
0);
if (i == 0) {
m &= ~0xFull; // leading '/'
}
emit(m, i);
}
if (i < ulen) { // overlapped 16-byte tail
const uint32_t off = ulen - 16;
const uint8x16_t v0 =
vld1q_u8(reinterpret_cast<const uint8_t*>(url) + off);
uint64_t m =
vget_lane_u64(vreinterpret_u64_u8(vshrn_n_u16(
vreinterpretq_u16_u8(vceqq_u8(v0, slash)), 4)),
0);
m &= ~0ull << ((i - off) * 4); // drop lanes already processed
emit(m, off);
}
} else { // short input: scalar scan (<= 15 bytes)
for (uint32_t i = 1; i < ulen; i++) {
if (url[i] == '/') {
if (nseg >= max_fast_path_segments) {
overflow = true;
break;
}
soff[nseg++] = static_cast<uint16_t>(s);
s = i + 1;
}
}
}
#else
for (uint32_t i = 1; i < ulen; i++) {
if (url[i] == '/') {
if (nseg >= max_fast_path_segments) {
overflow = true;
break;
}
soff[nseg++] = static_cast<uint16_t>(s);
s = i + 1;
}
}
#endif
if (overflow || nseg >= max_fast_path_segments) {
return 0;
}
soff[nseg++] = static_cast<uint16_t>(s);
soff[nseg] = static_cast<uint16_t>(ulen + 1); // sentinel
return nseg;
}

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.

Typical pathnames are 20–40 bytes with 2–4 slashes. NEON setup + a ctz loop is for long inputs.

A portable SWAR slash scan will beat this on the common case. Keep NEON only if a 1k+ pathname benchmark says so.

@FranciscoThiesen FranciscoThiesen Sep 3, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I ran the 1k+ benchmark before deciding. Two-node table where /files/* wins, so the scan dominates; NEON is the scanner from before the refactor, SWAR the portable loop that had replaced it. ns/url:

pathname NEON SWAR
32 B, 2 segments 15.4 18.7
128 B, 2 segments 17.3 26.8
1 KB, 2 segments 40 132
4 KB, 2 segments 126 460
128 B, 24 segments 24.1 37.2
1 KB, 24 segments 47 137
4 KB, 24 segments 131 585

So it does say so, and not only past 1 KB: NEON wins at every length on the M3, 1 to 3 ns on 20 to 40 byte paths.

So it stays. The refactor had dropped it; the latest push puts it back under ADA_NEON for inputs of 16 bytes or more (16 bytes per step, the / compare narrowed to a nibble mask, an overlapped last block so nothing is read past the input), with the SWAR loop as the portable path and for shorter inputs. The sweep test runs both against a naive scanner over lengths 1 to 70 and every slash placement. 32 bytes is back at parity (15.6 vs 15.8). What is left on long * tails, 2x at 4 KB, is the line-terminator check for (.*), a second pass over the tail; it could fold into the scan if that ever matters.

Building this also caught a bug of mine: the tail check for * routes walked the tail byte by byte, 1.3 us at 4 KB. It is a SWAR pass now.

Comment thread src/url_pattern_list.cpp Outdated
Comment thread src/url_pattern_list.cpp Outdated
Comment thread src/url_pattern_list.cpp Outdated
FranciscoThiesen and others added 8 commits August 29, 2026 09:59
parse_url_pattern_impl forwarded options->ignore_case into the component
compile options but never stored it on the url_pattern, so
url_pattern::ignore_case() always returned false. parse_url_pattern_list's
url_pattern-object overload reads that flag, so store it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… compiler private

API:
- ada::parse_url_pattern_list<Provider>(span<const string_view>, base_url*,
  url_pattern_options*) next to parse_url_pattern, plus an overload taking
  span<const url_pattern<Provider>> that reuses the objects' compiled
  pathname components and their ignore_case(). url_pattern_list::create is
  private; the free functions are the entry points.
- ignore_case reaches url_pattern_compile_component_options for regexp
  routes (the provider sees the flag) and the static/":param"/"*" subset
  compares literals with ASCII case folding (compiled literals folded at
  creation, the input folded into a stack copy at match time; offsets are
  unchanged so captures still slice the original).
- Regexp routes are tested with regex_match and executed with
  regex_search for their group values, URLPattern's own test/exec split;
  the match result carries them as regexp_groups (owned, aligned with
  group_names) next to the zero-allocation (offset, length) slices of
  subset routes, with regexp_route telling the two forms apart.

Public header and TU split:
- include/ada/url_pattern_list.h now holds only the limits, the match
  result, url_pattern_list and the table records the inline matcher reads;
  the route-set compiler (route_info, classify_parts, compile_route_set,
  witness planners, arena packing) moved to src/url_pattern_list_compiler.h,
  which ada.h does not include. The fuzzer reaches it through the
  amalgamated ada.cpp, as the other fuzzers do.
- The walk (scan_segments, verify_edge, dispatch_static, match_compiled)
  is ada_really_inline in url_pattern_list-inl.h and inlines into
  url_pattern_list::match; only the builder stays in the separate TU. The
  parse_url_pattern_list definitions live in implementation-inl.h, after
  the defaulted declarations, as parse_url_pattern's does. The unity
  ada.cpp codegen is unchanged under GCC 14 -O3: all 793 functions,
  url_aggregator setters included, have identical opcode streams.

Match path:
- Auxiliary routes (regexp mode, sequential mode) are no longer walked
  after a fast-path hit: at creation each trie route records the auxiliary
  routes that both outrank it and could match the same input (literal
  agreement and segment-count compatibility); after a hit only those are
  tested. A regexp route whose parts are fixed text and ":name" groups has
  an exact segment shape, and one with custom groups an anchored literal
  prefix; match_regexp_shape rejects inputs that cannot fit it before any
  provider call, on hits and misses alike.
- Direct compares up to 16 children (measured against 8: faster on every
  stream, static hits 28.0 -> 25.9 ns/url); a 256-entry first-byte index at the
  root (children sorted by first byte, runs of at most 16); projection only
  for wider nodes.
- One arena holds every table (nodes, hash payloads, edges, slots, blob,
  route records, segment table, aux table, root index), addressed by
  section offsets. node_record is 24 bytes; the projection payload lives
  in a separate hash_record only for hashed nodes.
- Portable SWAR slash scan (8 bytes per step, exact zero-lane test on
  x ^ '/'-fill, byte-exact partial tail load, no over-read); keys of 8..16
  bytes verify from two whole-word loads inside the segment, shorter keys
  from a masked load only when 8 bytes are readable and from a byte gather
  otherwise; memcmp past 16 bytes. eq_bytes, the NEON scanner and endpad
  are gone.
- The whole-pathname exact table and the shape tables are removed: with
  the root index and direct compares, static hits measured within noise
  of the exact table and param hits within noise of the shape tables, and
  the shape tables depended on the exact table's completeness. Empty
  literal segments ("/users/") are now trie routes.
  shape_group::{mask, n_static, witness_ids}, n_ids_out and
  covered_by_static_table go with them.

Tests: a counting provider wrapper (create_instance / regex_search /
regex_match calls and the ignore_case flag), ignore_case parity with
url_pattern, base URL processing, url_pattern objects as input, auxiliary
route pruning, SWAR scan sweep, short tails, root index, direct fanout,
and match() over exactly sized unterminated buffers. The fuzzer's oracle
is now each route's url_pattern pathname component (regex_search
included) ranked by the compiler's kind sequences, over exactly sized
input buffers.

Benchmark (benchmarks/urlpattern_list.cpp, 32-URL stream, ns/url, median
of 6 interleaved runs, before -> after): mixed stream 52.4 -> 34.0,
static hits 27.7 -> 26.6, param hits 31.9 -> 32.7,
table with one "(\d+)" route 114.9 -> 41.9.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"*" stands for "(.*)" in the URLPattern regexp, and "." in an ECMAScript
regular expression never matches LF or CR, so url_pattern rejects a raw
line terminator in the wildcard tail while the compiled matcher accepted
it (found by the fuzzer's url_pattern oracle: pattern "/*", input
"/\n"). Apply the rule in the trie walk and in the sequential matcher.
Canonical pathnames percent-encode both bytes, so only raw inputs are
affected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Measured on a synthetic node with N static children under a fixed prefix
(plus a ":rest" sibling), hit uniformly, with keys of varied length and
with keys of one length and prefix, which the length gate never rejects.
Three builds: projection from 3 children, direct compares up to 8, direct
compares at every fanout. ns/url, median of 3 interleaved runs, written
as projection / direct:

  hits, varied keys    N=3 20.5/19.0  N=8 20.6/19.7  N=12 21.1/20.4  N=16 21.0/21.7  N=24 20.7/23.6
  hits, same length    N=3 16.8/21.4  N=8 16.8/21.8  N=12 16.8/24.9  N=16 16.9/27.1  N=24 16.8/31.1
  misses, varied keys  N=3 20.6/20.3  N=8 20.7/23.7  N=12 21.2/26.1  N=16 21.1/28.9
  misses, same length  N=3 17.5/18.9  N=8 17.5/25.5  N=12 17.5/31.0  N=16 17.5/36.5

Direct compares win only for keys of varied length, by about 1 ns, and
only up to about 12 children; for keys of one length the projection wins
by 4-5 ns at every fanout, and by more on misses. Sixteen had gained
2.5 ns on the PR benchmark table because that stream is skewed toward
/api, which makes the direct loop's exit predictable. Eight keeps the
common-case win and bounds the loss; on the PR table it is a wash against
projection from 3 (static hits 28.0 vs 28.3, ":param" hits 34.9 vs 35.0).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBz2pfstt62q1rGwqbjP9X
…line

The check added in 5a40de0 walked the captured tail byte by byte on every
"*" hit: about 0.3 ns per byte, 1.3 us for a 4 KB pathname and 5-8 ns on a
typical one. Now 8 bytes per step with the "some byte below 0x20" test
(exact as a yes/no answer) and the exact byte check only for a tail that
holds a control byte. Kept out of line on purpose: inlined into the walk,
the loop cost the static and ":param" paths, which never run it, about
5 ns through register allocation (27.7 -> 32.4 and 34.9 -> 39.6 ns/url on
the PR benchmark); as a call it costs only wildcard hits.

Long-pathname benchmark (two-node table, "/files/*" wins, ns/url), the
NEON scanner at 840804f vs the SWAR scanner:

  32 B, 2 segments    15.4 vs 18.7     128 B, 24 segments   24.1 vs 37.2
  128 B, 2            17.3 vs 26.8     1 KB, 24             47 vs 137
  1 KB, 2             40 vs 132        4 KB, 24             131 vs 585
  4 KB, 2             126 vs 460

PR benchmark, 840804f -> this commit: mixed stream 50.0 -> 34.2, static
hits 26.6 -> 27.6, ":param" hits 30.5 -> 35.6, table with one "(\d+)"
route 110.9 -> 42.6.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBz2pfstt62q1rGwqbjP9X
The 1k+ pathname benchmark asked for in review: a two-node table where
"/files/*" wins, so the scan dominates; the NEON scanner at 840804f
against the SWAR loop at a4ab137, ns/url:

  32 B, 2 segments    15.4 vs 18.7     128 B, 24 segments   24.1 vs 37.2
  128 B, 2            17.3 vs 26.8     1 KB, 24             47 vs 137
  1 KB, 2             40 vs 132        4 KB, 24             131 vs 585
  4 KB, 2             126 vs 460

NEON is faster at every length on an Apple M3, 1-3 ns on 20-40 byte paths
and 3-4x past 1 KB, so 12e6b41 removed it wrongly. Restored under
ADA_NEON for inputs of 16 bytes or more: 16 bytes per step, the '/'
compare narrowed to one nibble per byte, an overlapped last block so the
input is never over-read. The SWAR loop stays as the portable scan and
for shorter inputs, and the sweep test exercises both. The wildcard tail
check takes the same 16-byte step (a running byte minimum). With this the
32-byte case is back at parity (15.6 vs 15.8 ns/url); the remaining 2x on
4 KB "*" tails is the line-terminator check's second pass over the tail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBz2pfstt62q1rGwqbjP9X
A counting provider over the benchmark's 32-URL stream shows the "(\d+)"
route is tested once, on an "/api/v1/invoices/.../zz" miss that lands on
"/*", where it legitimately could win; the comment claimed std::regex
never runs on that stream.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBz2pfstt62q1rGwqbjP9X
@FranciscoThiesen
FranciscoThiesen marked this pull request as ready for review September 4, 2026 15:21
@anonrig

anonrig commented Sep 6, 2026

Copy link
Copy Markdown
Member

One thing we should make sure before landing is:

  • We should use url_pattern_regex_provider as a templated argument for this rather than implementing a regex parser etc. This is required because Node.js and other solutions might want to use v8 rather than any other solution.

@anonrig

anonrig commented Sep 6, 2026

Copy link
Copy Markdown
Member

Can you also add a documentation to the readme, github.com/ada-url/website as well?

@FranciscoThiesen

Copy link
Copy Markdown
Author

One thing we should make sure before landing is:

  • We should use url_pattern_regex_provider as a templated argument for this rather than implementing a regex parser etc. This is required because Node.js and other solutions might want to use v8 rather than any other solution.

It already is. url_pattern_list is templated on the provider exactly like url_pattern, under the same regex_concept, and the only regex calls it makes are the provider's regex_match and regex_search through url_pattern_component.

There is no regex parsing in the list: custom (...) groups are opaque parts, and the whole component goes to the provider. Node's URLPatternRegexProvider satisfies the concept as is, so ada::url_pattern_list<URLPatternRegexProvider> compiles against V8 unchanged. The static / :param / * routes never touch a regex engine, which the counting-provider tests check (zero provider calls on those hits).

Same shape as the URLPattern section above it: a short introduction, an
example with the same provider, and a few plain notes on what is matched,
the priority order, the options and the limits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A6djgGCa4kARsjHSipVCbH
@anonrig
anonrig requested a review from lemire September 7, 2026 13:05
@anonrig

anonrig commented Sep 7, 2026

Copy link
Copy Markdown
Member

CI is failing

Apple clang 15 does not match a friend declaration that redeclares a
constrained function template, so parse_url_pattern_list could not reach
the private members of url_pattern_list and the macOS 14 job failed on
every use. Declare the two overloads before the class and befriend those
specializations instead. Their default arguments stay in implementation.h,
which this header always pulls in first, since a function template may not
gain default arguments in a later declaration.

MSVC's ada_never_inline is __declspec(noinline) with no inline linkage, so
the wildcard_tail_ok definition in the header was emitted in every
translation unit and the link failed with LNK2005. Move it next to the
other detail functions in url_pattern_list.cpp. It was already never
inlined, so the call is unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants