Skip to content

perf: fast path for absolute http(s) URLs, and better join buffer sizing - #1145

Closed
bartlomieju wants to merge 6 commits into
servo:mainfrom
bartlomieju:perf/absolute-fast-path
Closed

perf: fast path for absolute http(s) URLs, and better join buffer sizing#1145
bartlomieju wants to merge 6 commits into
servo:mainfrom
bartlomieju:perf/absolute-fast-path

Conversation

@bartlomieju

@bartlomieju bartlomieju commented Aug 1, 2026

Copy link
Copy Markdown

Two changes to speed up parsing.

First, Url::join sized its parse buffer from the relative reference, but the
result is roughly the base plus that reference, so every join grew the buffer
through three reallocations. It now sizes for both.

Second, and this is where most of the win is, a fast path for plain absolute
http(s) URLs. Most URLs are already canonical, so there is nothing to normalise,
but the parser still walks them character by character, calls IDNA, and checks
every path byte against a percent-encode set. The fast path spots that case up
front and builds the Url directly. It only takes a lowercase ASCII domain with
a path and query that need no encoding, and bails on anything else (uppercase,
userinfo, ports, fragments, percent-encoding, dot segments, IP literals,
punycode, non-ASCII). On a 100k corpus of real URLs, 97% take it. Url::join
uses it too when the specifier is already absolute, since the base is ignored
there anyway. A differential test checks that everything the fast path accepts
comes out identical to the general parser, field for field. WPT is green.

Benchmarks on an Apple M5, criterion with paired baselines:

before after
short absolute URL 97.5 ns 34.6 ns -64.5%
bulk parse, 100k URLs 21.82 ms 11.42 ms -47.3%
bulk parse plus getters 21.89 ms 11.26 ms -48.1%
join, absolute specifier 328.1 ns 113.0 ns -65.6%
join, relative, base reparsed 218 ns 119.9 ns -45.0%
join, relative, base pre-parsed 109 ns 88.3 ns -19.0%
five module specifiers 519 ns 347.2 ns -33.1%
IDN host (fast path bails) 300 ns 302.9 ns +0.8%

That last row is the cost of trying the fast path and bailing, which is within
noise here. Happy to share the benchmark harness if it would help review.

I tested all of these changes against ada and on my machine in most of
the benchmarks rust-url is now faster. Both parsers still give identical result
in the ada test corpus, the only difference is performance.

I made sure this change doesn't clash with #1142
or potential redesign in #1135.

Disclaimer: I used AI to research, implement and test this change.

`ParseOptions::parse` sized the serialization buffer from `input.len()`.
That is exact for an absolute URL, but `Url::join` routes through the same
path with the *relative reference* as `input`, so the buffer was sized for
the reference while the result is roughly the base plus the reference. Every
join therefore grew the buffer a reallocation at a time.

Measured with a counting global allocator, `join` performed 2 allocations
and 3 reallocations against `parse`'s 1 and 0, on every relative form tested,
including the short bare specifiers that dominate module resolution.

Size the buffer for base + input when the input has no scheme. Inputs that
do have a scheme ignore the base, so they keep their exact `input.len()`
capacity and are unaffected.

  join, base pre-parsed        181.5 ns -> 150.0 ns  (-17.3%)
  five module specifiers       778.2 ns -> 533.1 ns  (-31.6%)

Plain `Url::parse` is unchanged, still 1 allocation and 0 reallocations.
The scheme check only sizes the buffer, so a wrong answer costs a little
capacity and never correctness.
The general parser walks the input character by character through a `Chars`
iterator that re-tests every character for ASCII tab/newline, runs the host
through IDNA ToASCII, and re-checks every path byte against a percent-encode
set. For the common case -- an already-canonical absolute http(s) URL with a
lowercase ASCII domain and a path needing no encoding -- none of that work
changes the output.

Recognize that case up front and build the `Url` directly: match the scheme
with byte compares, validate the host and path against conservative byte
classes, and copy the input verbatim as the serialization.

The fast path declines anything unusual -- uppercase, userinfo, a port, a
query, a fragment, percent-encoding, dot segments, backslashes, IP literals,
punycode, non-ASCII, empty labels, a host ending in a number -- so it never
has to reproduce the general parser's handling of those. It is attempted only
when no base URL, encoding override or violation callback is set.

  single short parse    159.8 ns -> 54.2 ns  (-66%)
  bulk parse, 100k      33.2 ms  -> 23.8 ms  (-30%)

A differential test asserts that every input the fast path accepts yields a
field-for-field identical `Url` to the general parser. Over a 100k real-world
URL corpus, 75,526 inputs take the fast path and all agree. WPT stays green.
The fast path scanned the whole host looking for the path separator before
validating any of it, so an input it was always going to decline paid for the
scan first. Review of the benchmark run flagged a possible ~2-3% regression on
`parse_idn` from exactly this.

A host that does not begin with a lowercase ASCII letter can never be verbatim,
so check that byte before scanning. One compare now covers the common declines
-- non-ASCII and punycode hosts, IPv4 (leading digit), IPv6, uppercase -- which
previously walked the host only to be rejected afterwards.

Purely a reordering: the set of inputs the fast path accepts is unchanged, at
75,526 of the 100,025 corpus URLs, and the differential test, the full suite and
WPT all still pass.
Mutation-checked the existing coverage by deliberately breaking the fast path,
which found two mutations that only WPT caught and the dedicated tests missed:
allowing uppercase inside a host label, and passing `xn--` through verbatim.

The first slipped through because the existing uppercase case, `EXAMPLE.com`,
is still rejected by the leading-byte check, so it never reached the label
scan; the second because the existing punycode case is valid and round-trips
unchanged. Added hosts that are uppercase only after a lowercase first byte,
and punycode that fails IDNA validation, so both are now caught without relying
on WPT.

Also broadened the differential cases to cover length boundaries, inputs
shorter than the scheme literal, hyphens and digits at label edges, every byte
the path class admits and several just outside it, dot segments in each
position, and repeated separators.

Added public-API tests: one parses each input twice, once with a syntax
violation callback to force the general parser, and compares every observable;
one mutates a fast-path `Url` through the setters, which is what would catch a
wrong component offset that the getters alone would not reveal; and one checks
that `join`'s buffer sizing did not change what it resolves to.
Categorizing why the fast path declined 24,499 of the 100,025 corpus URLs found
one dominant reason: 22,679 of them (22.7% of the corpus) carry a query string.
Everything else combined -- path bytes needing encoding, fragments, ports,
userinfo, uppercase hosts, punycode, dot segments -- accounts for under 2%.

Accept a query when a path precedes it, validating its bytes against the
complement of the special-query percent-encode set. `%` is included, since the
parser does not re-encode existing escapes in a query. Fragments stay declined:
'#' is outside both byte classes, so they fall back without extra handling.

Corpus coverage rises from 75,526 to 96,980 of 100,025 URLs, 75.5% to 97.0%.

  bulk parse, 100k     -22.9%
  single short parse    +1.4%

The single-URL cost is the tradeoff and is deliberate: locating the query is
fused into the path validation loop rather than run as a separate scan, so a
URL without a query pays one extra byte comparison per path byte instead of a
second pass. Given queries appear in 22.7% of real URLs, that trade is clearly
worth making, but it is not free.

`https://host?q` is still declined -- it gains a "/" in its serialization, so
the input is no longer copied verbatim, and the shape is rare enough not to
justify a second output form.
The fast path refused to run whenever a base URL was set, which meant
`Url::join` never used it -- including for an absolute input, where the base is
ignored anyway.

That guard was stricter than necessary. `parse_with_scheme` consults the base
only in the "special relative" state, entered when fewer than two slashes
follow the scheme. The fast path accepts nothing but `http://` and `https://`,
which is exactly two, so the base is provably ignored for every input it
accepts and excluding it bought nothing.

An encoding override still changes how a query is serialized and a violation
callback still expects to be called, so both remain excluded.

  join with an absolute specifier   537.4 ns -> 176.6 ns  (-66.9%)

Relative resolution is unaffected, as it must be: `https:/d` and `https:d`
carry a scheme but fewer than two slashes, so they stay in the special-relative
state and resolve against the base. Both are covered by a test, alongside one
asserting that `join` and `parse` agree for absolute inputs across every base
shape -- special, non-special, cannot-be-a-base, and one with userinfo and a
port.
@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.51553% with 4 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@00a6ce5). Learn more about missing BASE report.

Files with missing lines Patch % Lines
url/src/lib.rs 97.43% 2 Missing ⚠️
url/tests/unit.rs 97.59% 2 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #1145   +/-   ##
=======================================
  Coverage        ?   87.50%           
=======================================
  Files           ?       26           
  Lines           ?     5424           
  Branches        ?        0           
=======================================
  Hits            ?     4746           
  Misses          ?      678           
  Partials        ?        0           

☔ 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.

@Manishearth

Manishearth commented Aug 3, 2026

Copy link
Copy Markdown
Member

Hi! Current Servo AI policy does not allow AI contributions

https://book.servo.org/contributing/getting-started#ai-contributions

Thank you for working on this.

@Manishearth Manishearth closed this Aug 3, 2026
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.

2 participants