Skip to content

Speed up the html_attr_relaxed escaping strategy for strings that need no escaping - #4982

Open
Kocal wants to merge 1 commit into
twigphp:3.xfrom
Kocal:perf/escaper-html-attr-fast-path
Open

Kocal wants to merge 1 commit into
twigphp:3.xfrom
Kocal:perf/escaper-html-attr-fast-path

Conversation

@Kocal

@Kocal Kocal commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

html_attr_relaxed is the strategy html_attr() from twig/html-extra uses to escape attribute names, and Symfony UX components use it the same way. Names almost never contain a character that needs escaping, yet every call ran a UTF-8 validity check (preg_match('//u')) and a preg_replace_callback() with a closure regardless.

When the charset is UTF-8 and every byte is one of the characters the strategy leaves untouched, a single strspn() call now detects it and the string is returned as is. Other charsets still go through the conversion, since safe-looking bytes can encode other characters there (in UTF-16BE, "ab" is U+6162). html_attr, mostly used on values, keeps its own case and pays nothing for it.

  • html_attr_relaxed on safe names: 338-411 ns per call before, 156-189 ns after (about 2.2x)
  • html_attr_relaxed on names that need escaping (rare): 554-642 ns before, 632-865 ns after, the cost of the extra strspn()
  • html_attr: unchanged
  • end to end, 100 renders of 300 Symfony UX components: 294-302 ms before, 279-282 ms after (about 4.5%)
<?php

// Run from the repository root: php bench.php
require getcwd().'/vendor/autoload.php';

$escaper = new Twig\Runtime\EscaperRuntime();
$names = ['class', 'id', 'type', 'role', 'href', 'tabindex', 'aria-label', 'aria-hidden', 'data-slot', 'data-controller', 'data-action', 'data-dropdown-menu-target', '@click', ':class', 'x-on:click'];
$cases = [
    ['html_attr_relaxed', 'safe names', $names],
    ['html_attr_relaxed', 'names to escape', ['data-foo bar', 'a"b', 'é']],
    ['html_attr', 'safe values', $names],
    ['html_attr', 'values to escape', ['p-2 align-middle', 'click->dropdown#toggle', 'Hello "world"', 'éàü', '<b>bold</b>']],
];
foreach ($cases as [$strategy, $label, $strings]) {
    $times = [];
    for ($run = 0; $run < 7; ++$run) {
        $start = hrtime(true);
        for ($i = 0; $i < 20000; ++$i) {
            foreach ($strings as $string) {
                $escaper->escape($string, $strategy);
            }
        }
        $times[] = (hrtime(true) - $start) / 1e6;
    }
    sort($times);
    $escaped = array_map(static fn (string $string): string => $escaper->escape($string, $strategy), $strings);
    printf("%-17s %-16s %3.0f ns/escape | sha1 %s\n", $strategy, $label, $times[3] * 1e6 / 20000 / count($strings), sha1(implode('|', $escaped)));
}

Blackfire profiles (300 Symfony UX components rendered 100 times):

Blackfire's per-call instrumentation flattens this kind of gain (it only shows 4.86 s -> 4.79 s), which is why the raw numbers above matter.

This came out of profiling Symfony UX components on https://github.com/Kocal/sf-ux-perfs-twig-components.

@Kocal
Kocal force-pushed the perf/escaper-html-attr-fast-path branch from 1d45195 to 8bf58c4 Compare October 7, 2026 05:39
@Kocal
Kocal force-pushed the perf/escaper-html-attr-fast-path branch from 8bf58c4 to 1d85b43 Compare October 15, 2026 19:32
@Kocal

Kocal commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

(rebasing)

@Kocal
Kocal force-pushed the perf/escaper-html-attr-fast-path branch from 1d85b43 to 03a1abd Compare October 16, 2026 08:57
…eed no escaping

`html_attr_relaxed` is the strategy `html_attr()` from twig/html-extra uses to escape attribute names, and Symfony UX components use it the same way. Names almost never contain a character that needs escaping, yet every call ran a UTF-8 validity check (`preg_match('//u')`) and a `preg_replace_callback()` with a closure regardless.

When the charset is UTF-8 and every byte is one of the characters the strategy leaves untouched, a single `strspn()` call now detects it and the string is returned as is. Other charsets still go through the conversion, since safe-looking bytes can encode other characters there (in UTF-16BE, "ab" is U+6162). `html_attr`, mostly used on values, keeps its own `case` and pays nothing for it.

- `html_attr_relaxed` on safe names: **338-411 ns** per call before, **156-189 ns** after (about 2.2x)
- `html_attr_relaxed` on names that need escaping (rare): **554-642 ns** before, **632-865 ns** after, the cost of the extra `strspn()`
- `html_attr`: unchanged
- end to end, 100 renders of 300 Symfony UX components: **294-302 ms** before, **279-282 ms** after (about 4.5%)

```php
<?php

// Run from the repository root: php bench.php
require getcwd().'/vendor/autoload.php';

$escaper = new Twig\Runtime\EscaperRuntime();
$names = ['class', 'id', 'type', 'role', 'href', 'tabindex', 'aria-label', 'aria-hidden', 'data-slot', 'data-controller', 'data-action', 'data-dropdown-menu-target', '@click', ':class', 'x-on:click'];
$cases = [
    ['html_attr_relaxed', 'safe names', $names],
    ['html_attr_relaxed', 'names to escape', ['data-foo bar', 'a"b', 'é']],
    ['html_attr', 'safe values', $names],
    ['html_attr', 'values to escape', ['p-2 align-middle', 'click->dropdown#toggle', 'Hello "world"', 'éàü', '<b>bold</b>']],
];
foreach ($cases as [$strategy, $label, $strings]) {
    $times = [];
    for ($run = 0; $run < 7; ++$run) {
        $start = hrtime(true);
        for ($i = 0; $i < 20000; ++$i) {
            foreach ($strings as $string) {
                $escaper->escape($string, $strategy);
            }
        }
        $times[] = (hrtime(true) - $start) / 1e6;
    }
    sort($times);
    $escaped = array_map(static fn (string $string): string => $escaper->escape($string, $strategy), $strings);
    printf("%-17s %-16s %3.0f ns/escape | sha1 %s\n", $strategy, $label, $times[3] * 1e6 / 20000 / count($strings), sha1(implode('|', $escaped)));
}
```

Blackfire profiles (300 Symfony UX components rendered 100 times):
- Before: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/2c2141c7-5f2d-4be5-9864-9e131dae962b/graph
- After: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/85def003-180c-4fc6-94c1-f63fdd26a139/graph
- Comparison: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/compare/2c2141c7-5f2d-4be5-9864-9e131dae962b...85def003-180c-4fc6-94c1-f63fdd26a139/graph

Blackfire's per-call instrumentation flattens this kind of gain (it only shows **4.86 s** -> **4.79 s**), which is why the raw numbers above matter.

This came out of profiling Symfony UX components on https://github.com/Kocal/sf-ux-perfs-twig-components.
@Kocal
Kocal force-pushed the perf/escaper-html-attr-fast-path branch from 03a1abd to 5163041 Compare October 25, 2026 12:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant