Skip to content

[TASK] Decide plain relative paths without parsing them as URIs - #1405

Open
CybotTM wants to merge 1 commit into
phpDocumentor:mainfrom
CybotTM:perf/relative-url-check
Open

CybotTM wants to merge 1 commit into
phpDocumentor:mainfrom
CybotTM:perf/relative-url-check

Conversation

@CybotTM

@CybotTM CybotTM commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Merging this stops AbstractUrlGenerator::generateInternalUrl() from running a full League\Uri parse for every internal link it writes, which accounts for a fifth of the CPU samples of a render of the 1003 documents of TYPO3CMS-Reference-CoreApi. That share is what this change accounts for directly; measured, the render needs 27 % less wall time and 29 % less CPU time (median ratios of 13 paired rounds), the 94 document TYPO3 rendering test 20 % less wall time, the output is identical apart from the rendered timestamp and the peak memory is unchanged.

What changes

Before computing a link, generateInternalUrl() checks that the canonical url is a relative path, through BaseUri::from($url)->isRelativePath(). The links of the global menu, rendered into every page, reach it through AssetsExtension::renderLink() and generateCanonicalOutputUrl(); they make nearly all of the 1,092,322 calls in one CoreApi render (98.5 % of the samples in isRelativeUrl() sit below GlobalMenuExtension::renderMenu()). The check now first tests the url against ~^(?!/)[\x21-\x39\x3B-\x7E]+\z~ — printable ASCII, no colon, not starting with a slash. Such a url has neither a scheme nor an authority, so it is a relative path, and League\Uri parses all of it without an exception. Everything else still goes to BaseUri::from() exactly as before, so every input ends as it did: the computed path, InvalidUrlException, or League\Uri's own SyntaxError (it throws for mailto:x or a control character, for instance). No cache, no state.

Numbers

Each step can be checked on its own; the scripts and raw times are in the comment below.

1. How often the check runs, and how often the new path answers it. Counted by instrumenting isRelativeUrl() over a full render:

corpus documents calls per render distinct urls answered without parsing
this repository's docs 28 2,204 99 100 %
TYPO3 rendering test 94 46,978 512 99.99 % (4 calls fall back, a url with spaces)
TYPO3CMS-Reference-CoreApi 1003 1,092,322 4,608 100 %

2. What one check costs. phpbench over the 4,608 distinct CoreApi urls, each counted once, 10 iterations of 20 revolutions: BaseUri::from()->isRelativePath() 90.068 ms (±15.0 %), the pattern with its fallback 0.565 ms (±9.6 %) — 19.5 µs against 0.12 µs per url. Times 1,092,322 calls, that predicts about 21 s less per CoreApi render.

3. Where the time goes. A CPU profile (Excimer, 2 ms samples, one render each) of CoreApi: isRelativeUrl() holds 20.02 % of all samples on main (20.42 % in an earlier profile of the same base) and 0.06 % on this branch.

4. Full renders. PHP 8.5.11 CLI with opcache off, as the CLI defaults to (as did phpbench), one process per render, main and this branch alternating within every round, output kept and compared. The machine was shared with other work during the measurement, which moved absolute times by up to 80 % between rounds, so the ratio within a round is the figure that holds. Two series, the first measured e790aa5e, the second 20eaab5e; from those to this head only the test and the comment in isRelativeUrl() changed.

corpus rounds ratio branch / main, median per series range rounds the branch was faster
TYPO3CMS-Reference-CoreApi 13 0.729 0.745 (5), 0.719 (8) 0.49 – 0.99 13 of 13
TYPO3 rendering test 20 0.801 0.809 (10), 0.795 (10) 0.74 – 0.85 20 of 20
this repository's docs 20 0.942 one series 0.81 – 1.11 17 of 20, one tie

The CoreApi minimum of 0.49 is a load spike in the main render of that round, and the first round was only 1.2 % faster (0.988). Peak RSS, median of the second series: CoreApi 315,604 KB on main and 315,474 KB on this branch, TYPO3 rendering test 85,416 KB and 85,074 KB.

Output. CoreApi: all 1,189 written files identical. TYPO3 rendering test: 3 of 144 differ, by the rendered timestamp only. docs: all 32 identical.

What does not add up yet. The saving measured in full renders is larger than the direct cost of the parse: on CoreApi 27 % of wall and 29 % of CPU time against the 20 % of samples in the profile (and the 21 s phpbench predicts); on the TYPO3 rendering test about 20 % against a predicted 14 % (46,978 calls × the 19.4 µs saved per call, of 6.475 s). The extra is CPU work, not waiting: the CPU time (user and system) falls by about the same ratio as the wall time — user time alone by 29 %, system time, around a second per render, by 3 % — and the branch profile has 30 % fewer samples in total (22,370 on the branch against 31,941 on main) although only 20 % sat in isRelativeUrl(). It is not the garbage collector: gc_status() after a render reports the same runs (645 on CoreApi, 54 on the rendering test) and the same collected count on both, and nearly the same collector time (3.65 s on main and 3.68 s on the branch for CoreApi). I have not found the cause, and the conservative figure for this change is the direct one, 20 %.

Proof that every input ends as before

  • AbstractUrlGeneratorIsRelativeUrlTest compares the outcome of generateInternalUrl() with League\Uri as oracle, for 43 named cases (including a 100 KB and a 1 MB url), every byte in two positions, and 5,000 seeded random strings over all printable non-alphanumeric ASCII, space, tab, newline, DEL and non-ASCII bytes.
  • Each of five injected defects turns it red: admitting a colon, a leading slash, control characters or bytes above 0x7E, and anchoring with $, which lets a trailing newline through — the first version of this change had that defect, and this test found it.
  • Every one of the 4,608 + 512 + 99 urls harvested from the three renders gets the same answer from both.
  • The package allows league/uri ^7.5.1. The CI matrix has lowest jobs besides locked and highest, and the same inputs plus all harvested urls were also checked directly against 7.5.1 and 7.8.1: of 10,756 inputs, 5,923 take the new path, and League\Uri calls every one of them a relative path, in both versions.

Relation to #1398

Both change AbstractUrlGenerator.php and conflict on the property block, trivially. In the first series #1398 alone took the TYPO3 rendering test from 6.475 s to 5.635 s, this change alone to 5.290 s, both together to 5.305 s: with this change in, #1398 made no measurable difference there (both / this, median ratio 0.994 over 10 rounds, range 0.85 – 1.09). On CoreApi five rounds were too noisy to say either way.

Gates

phpunit unit 653, functional 120, integration 266, phpcs, phpstan, deptrac — green on PHP 8.5.11; CI green on 8ed03f90.

Assisted by claude-code:claude-opus-5-5 — Session

generateInternalUrl() checks every canonical url through
BaseUri::from()->isRelativePath() before computing the link, so each
internal link rendered - the global menu of every page included - pays
for a full League\Uri parse, host normalisation and encoding.

A url of printable ASCII without a colon that does not start with a
slash has neither a scheme nor an authority, and League\Uri accepts all
of it, so it is a relative path; that is now answered by one anchored
pattern. Everything else, including the input League\Uri refuses with an
exception, still goes through BaseUri::from(), so every input ends as
before. The pattern ends in \z, not $, which would let a trailing
newline through.

Assisted-by: claude-code:claude-opus-5-5
Agent-Session: https://claude.ai/code/session_01XzfnUQmxDxanqouHSEHHky
Agent-Host: 32116e
Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
@CybotTM

CybotTM commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

How to repeat each number. Base 04ed0c39, branch 8ed03f90, PHP 8.5.11 CLI.

Corpora. TYPO3CMS-Reference-CoreApi at 5ad40c01 (Documentation/), TYPO3-Documentation/render-guides at cef632b3 (Documentation-rendertest/), and this repository's docs/. From the two TYPO3 guides.xml files the <extension class="\T3Docs\..."> element is removed, so they render with this repository alone; they log warnings about TYPO3 directives, identical in both arms. Each corpus sits as docs/ inside a directory, and a render is cd <dir> && php <checkout>/vendor/bin/guides --no-progress docs --output=<tmp>.

1. Call counts. Insert $GLOBALS['__urls'][$url] = ($GLOBALS['__urls'][$url] ?? 0) + 1; as the first line of isRelativeUrl() on main, and render with -d auto_prepend_file= pointing at:

<?php
register_shutdown_function(static function () { file_put_contents(getenv('HARV_OUT'), json_encode($GLOBALS['__urls'] ?? [])); });

2. Per call. phpbench, runner.bootstrap set to the checkout's vendor/autoload.php, urls.php returning the distinct CoreApi urls from step 1:

#[Bench\Revs(20)] #[Bench\Iterations(10)] #[Bench\Warmup(2)]
final class IsRelativeUrlBench
{
    private array $urls;
    public function __construct() { $this->urls = require __DIR__ . '/urls.php'; }
    public function benchLeagueUri(): void
    {
        foreach ($this->urls as $url) { BaseUri::from($url)->isRelativePath(); }
    }
    public function benchPatternThenLeagueUri(): void
    {
        foreach ($this->urls as $url) {
            if (preg_match('~^(?!/)[\x21-\x39\x3B-\x7E]+\z~', $url) === 1) { continue; }
            BaseUri::from($url)->isRelativePath();
        }
    }
}

3. Profile. php:8.5-cli with pecl install excimer, rendering with this prepended:

<?php
$p = new ExcimerProfiler(); $p->setPeriod(0.002); $p->setEventType(EXCIMER_CPU); $p->setMaxDepth(250); $p->start();
register_shutdown_function(static function () use ($p) { $p->stop(); file_put_contents(getenv('PROF_OUT'), $p->getLog()->formatCollapsed()); });

A frame's share is the samples of every stack that contains it, over all samples.

4. Full renders. Per round, one render of main and one of the branch, one after the other, each timed with /usr/bin/time -f "%e %U %S %M"; the ratio is taken within a round. Raw wall seconds, CoreApi, main / branch:

series A: main  119.57 134.87 176.93 110.12 112.75 
          this  118.18 92.59 86.84 85.55 83.97 
series B: main  157.84 177.31 141.23 140.03 111.92 130.88 125.40 110.97 
          this  132.70 124.98 125.12 85.36 84.31 92.89 83.14 80.92 

TYPO3 rendering test:

series A: main  8.63 6.66 6.46 6.14 6.44 6.49 6.60 6.45 6.51 6.38 
          this  6.80 5.49 5.30 5.01 5.16 5.30 5.28 5.17 5.51 5.08 
series B: main  8.98 9.39 9.34 10.15 10.21 9.67 9.69 9.43 11.01 10.23 
          this  6.81 7.46 7.93 8.07 8.22 7.36 8.00 7.89 8.65 7.60 

Series A ran four arms per round (also #1398 alone and both together), series B two. Series A measured e790aa5e and series B 20eaab5e; from those to 8ed03f90 only the test and the comment in isRelativeUrl() changed.

League 7.5.1. A separate composer.json requiring league/uri 7.5.1 and league/uri-interfaces 7.5.0, and a script that feeds the test inputs plus all harvested urls through the pattern and asserts BaseUri::from($url)->isRelativePath() without an exception for each one the pattern accepts.

Assisted by claude-code:claude-opus-5-5 — Session

@CybotTM
CybotTM marked this pull request as ready for review September 30, 2026 12:24
@CybotTM

CybotTM commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

Hey @jaapio - what do you think?

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.

1 participant