Skip to content

Add Servo website to HTML parsing benchmarks - #813

Merged
SimonSapin merged 1 commit into
mainfrom
bench-atom
Nov 3, 2026
Merged

SimonSapin merged 1 commit into
mainfrom
bench-atom

Conversation

@SimonSapin

Copy link
Copy Markdown
Member

I found the existing HTML files used in benchmarks to use few HTML tags and attributes, so I’ve added:

  • A copy of the HTML returned by https://servo.org/
  • That HTML, modified to make every tag name a custom element and every attribute a data attribute.

Run them with:

cargo bench -p markup5ever_rcdom --bench deserialize-serialize -- servo

The latter has many strings to be intered with string-cache that are neither part of the static set (known HTML tag and attribute names) nor short enough to be inlined, so that interning of dynamic atoms is exercised.

The former serves as a baseline to compare against, with ~every atom in the static set and therefore not touching string-cache’s dynamic set.

This is for checking the real-world impact of fixes for servo/string-cache#314, as opposed to a micro-benchmark that does nothing but intern strings in a loop.

I found the existing HTML files used in benchmarks to use few HTML tags
and attributes, so I’ve added:

* A copy of the HTML returned by https://servo.org/
* That HTML, modified to make every tag name a custom element and every
  attribute a data attribute.

Run them with:

```
cargo bench -p markup5ever_rcdom --bench deserialize-serialize -- servo
```

The latter has many strings to be intered with `string-cache` that are
neither part of the static set (known HTML tag and attribute names) nor
short enough to be inlined, so that interning of dynamic atoms is exercised.

The former serves as a baseline to compare against, with ~every atom in the
static set and therefore not touching string-cache’s dynamic set.

This is for checking the real-world impact of fixes for
servo/string-cache#314, as opposed to a
micro-benchmark that does nothing but intern strings in a loop.

Signed-off-by: Simon Sapin <simon@igalia.com>
@github-actions github-actions Bot added the V-non-breaking A non-breaking change label Nov 3, 2026

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

Seems reasonable to me.

This is for checking the real-world impact of fixes for servo/string-cache#314, as opposed to a micro-benchmark that does nothing but intern strings in a loop.

Does that PR improve this benchmark noticeably?

@SimonSapin

Copy link
Copy Markdown
Member Author

The string-cache PR that I have not submitted yet intentionally regresses micro-benchmark performance in order to make the adversarial worst case harder to reach. This PR helps showing that the regression is not significant in a bigger benchmark (HTML parsing).

SimonSapin added a commit to servo/string-cache that referenced this pull request Nov 3, 2026
## Background

Interning a string goes through `impl From<Cow<str>> for Atom` which does:

1. If the string is short enough, make an inline atom
2. Otherwise if the string is part of the static set, make a static atom
3. Otherwise insert the string in the dynamic set and make a dynamic atom

Step 2 involves hashing the string with a keyed hash function whose key
is chosen at compile-time by the `phf` crate. phf key search uses a PRNG
but with a fixed seed, so the key is deterministic for a given static set.

Step 3 involves a separate hash set. To save CPU cycles, string-cache v0.11.0
and earlier re-use the hash already computed from step 2. The dynamic hash set
is a custom implementation with a fixed size and no open addressing, so a
12-bit hash collision is enough to put multiple strings in the same bucket
and hit the map’s worst case linear time insertion.

As reported in #314, an HTML
document adversarially crafted to have many tag or attribute names with hash
collisions in a given version of Servo could take quadratic time to parse in
that version, just for string interning.

## Mitigation

This commit changes the dynamic set to not reuse the hash from the static set,
but instead compute its own hash with a per-process instance the standard
library’s default hasher `std::hash::RandomState`, another keyed hash function
that picks random keys when initialized.

The worst case for dynamic atom interning is still linear time to the number
of items in the same bucket, but with a random hash key collisions are harder
to craft.

## Benchmarking disclaimer

All benchmarks results mentioned here are from a laptop. While benches were
running the CPU temperature varied between 60°C and 99°C and fan speed beetween
off and maximum, so it’s likely that the CPU speed was thermal-throttled some
but not all of the time.

Precise numbers should be considered unreliable but hopefully we can still
discern broad trends. Still it’d be nice if someone wants to reproduce
on hardware with more stable clock speed.

## Micro-benchmarks

Filtering micro-benchmarks in this repository to those that do string interning:

```
git switch main
cargo bench -p integration_tests --bench micro -- --save-baseline 2026-11-03 intern
git switch dynamic-hash # this commit
cargo bench -p integration_tests --bench micro -- --baseline 2026-11-03 intern
```

<details>
<summary>Full criterion output</summary>

```
static_atom intern      time:   [11.537 ns 11.677 ns 11.857 ns]
                        change: [−2.5985% −1.7356% −0.5713%] (p = 0.00 < 0.05)
                        Change within noise threshold.
Found 4 outliers among 100 measurements (4.00%)
  4 (4.00%) high severe

short_inline_atom intern
                        time:   [6.2984 ns 6.3004 ns 6.3027 ns]
                        change: [+5.5323% +5.7550% +5.9245%] (p = 0.00 < 0.05)
                        Performance has regressed.
Found 8 outliers among 100 measurements (8.00%)
  2 (2.00%) high mild
  6 (6.00%) high severe

medium_inline_atom intern
                        time:   [2.1287 ns 2.1320 ns 2.1357 ns]
                        change: [+1.4034% +1.8931% +2.3395%] (p = 0.00 < 0.05)
                        Performance has regressed.
Found 8 outliers among 100 measurements (8.00%)
  6 (6.00%) high mild
  2 (2.00%) high severe

min_dynamic_atom intern time:   [38.216 ns 38.245 ns 38.279 ns]
                        change: [+17.366% +17.463% +17.560%] (p = 0.00 < 0.05)
                        Performance has regressed.
Found 4 outliers among 100 measurements (4.00%)
  3 (3.00%) high mild
  1 (1.00%) high severe

longer_dynamic_atom intern
                        time:   [49.913 ns 50.593 ns 51.457 ns]
                        change: [+18.758% +20.419% +22.943%] (p = 0.00 < 0.05)
                        Performance has regressed.
Found 4 outliers among 100 measurements (4.00%)
  4 (4.00%) high severe

static_at_runtime intern
                        time:   [11.546 ns 11.555 ns 11.567 ns]
                        change: [−1.2172% −1.1122% −1.0048%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 12 outliers among 100 measurements (12.00%)
  9 (9.00%) high mild
  3 (3.00%) high severe

intern rand 008         time:   [60.782 ns 60.815 ns 60.851 ns]
                        change: [+11.075% +11.158% +11.241%] (p = 0.00 < 0.05)
                        Performance has regressed.

intern rand 032         time:   [62.676 ns 63.448 ns 64.446 ns]
                        change: [+4.9985% +5.8914% +7.2063%] (p = 0.00 < 0.05)
                        Performance has regressed.
Found 7 outliers among 100 measurements (7.00%)
  2 (2.00%) high mild
  5 (5.00%) high severe

intern rand 128         time:   [92.238 ns 93.480 ns 95.054 ns]
                        change: [+18.802% +20.677% +23.672%] (p = 0.00 < 0.05)
                        Performance has regressed.
Found 7 outliers among 100 measurements (7.00%)
  2 (2.00%) high mild
  5 (5.00%) high severe

intern rand 512         time:   [229.45 ns 229.58 ns 229.74 ns]
                        change: [+41.554% +41.679% +41.801%] (p = 0.00 < 0.05)
                        Performance has regressed.
Found 9 outliers among 100 measurements (9.00%)
  5 (5.00%) high mild
  4 (4.00%) high severe
```

</details>

Some number differences for static and inline atoms are deemed statistically
significant by Criterion but since no relevant source code has changed I’m
attributing this to thermal throttling.

As expected interning dynamic atoms is now slower, from +11% time for
8 byte strings to +41% time for 512 bytes. However this is doing nothing
but string interning.

## HTML parsing benchmarks

On servo/html5ever#813, which adds an HTML input file
with all tag and attribute named mofidied to be dynamic atoms.

```
# with string-cache from crates.io
cargo bench -p markup5ever_rcdom --bench deserialize-serialize -- --save-baseline 2026-11-03 servo
$EDITOR Cargo.toml # use patched string-cache, see below
cargo bench -p markup5ever_rcdom --bench deserialize-serialize -- --baseline 2026-11-03 servo
```

To use a patched string-cache, add to html5ever’s root `Cargo.toml`:

```toml
[patch.crates-io]
string_cache = {path = "../string-cache"} # string-cache on this commit
```

<details>
<summary>Full criterion output</summary>

```
deserialize "servo.org.html"
                        time:   [225.43 µs 225.55 µs 225.67 µs]
                        change: [+1.7193% +1.8193% +1.9216%] (p = 0.00 < 0.05)
                        Performance has regressed.
Found 4 outliers among 100 measurements (4.00%)
  1 (1.00%) low mild
  2 (2.00%) high mild
  1 (1.00%) high severe

Benchmarking deserialize "servo.org-custom.html": Collecting 100 samples in esdeserialize "servo.org-custom.html"
                        time:   [302.33 µs 302.72 µs 303.08 µs]
                        change: [+0.4744% +0.6366% +0.7950%] (p = 0.00 < 0.05)
                        Change within noise threshold.
Found 1 outliers among 100 measurements (1.00%)
  1 (1.00%) low mild

Benchmarking serialize "servo.org.html": Collecting 100 samples in estimated 5serialize "servo.org.html"
                        time:   [18.591 µs 18.643 µs 18.715 µs]
                        change: [−1.0722% +0.8353% +2.7065%] (p = 0.39 > 0.05)
                        No change in performance detected.
Found 13 outliers among 100 measurements (13.00%)
  10 (10.00%) low severe
  3 (3.00%) high severe

Benchmarking serialize "servo.org-custom.html": Collecting 100 samples in estiserialize "servo.org-custom.html"
                        time:   [27.822 µs 27.858 µs 27.890 µs]
                        change: [−4.6615% −3.6017% −2.4905%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 16 outliers among 100 measurements (16.00%)
  16 (16.00%) low severe
```

</details>

String interning only happens during HTML deserialize, and `servo.org.html`
contains ~no dynamic atoms so only `deserialize "servo.org-custom.html"`
is relevant here. Once again I attribute other changes to thermal throttling.

`deserialize "servo.org-custom.html"` shows not siginificant change,
which I believe indicates that the performance regression on string interning
is small when part of HTML parsing.

Signed-off-by: Simon Sapin <simon@igalia.com>
@SimonSapin
SimonSapin added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit a027c28 Nov 3, 2026
11 checks passed
@SimonSapin
SimonSapin deleted the bench-atom branch November 3, 2026 19:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

V-non-breaking A non-breaking change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants