Skip to content

[TASK] Add caching layer for rendering operations - #1287

Open
CybotTM wants to merge 2 commits into
phpDocumentor:mainfrom
CybotTM:perf/caching-improvements
Open

[TASK] Add caching layer for rendering operations#1287
CybotTM wants to merge 2 commits into
phpDocumentor:mainfrom
CybotTM:perf/caching-improvements

Conversation

@CybotTM

@CybotTM CybotTM commented Jan 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds caching layers for frequently accessed rendering operations to improve performance.

Changes

  • SluggerAnchorNormalizer: Cache slugger instance and normalization results
  • TwigTemplateRenderer: Cache Twig globals across render calls
  • PreNodeRendererFactory: Cache renderer lookups by class name
  • TwigEnvironment: Cache template by path
  • RenderContext: O(1) document lookup via hash map
  • DocumentNameResolver: Cache URL resolution results
  • UrlGenerator: Cache URL generation results
  • ExternalReferenceResolver: O(1) hash set lookup for URI schemes (~6x faster than regex)

Performance Impact

See Performance Analysis Report for detailed benchmarks.


Related PRs

PR Description Status
#1287 This PR - Rendering caching layer
#1288 RST parsing optimizations Open, reworked after review
#1289 CLI container caching Closed — the cache was never written
#1291 Symfony 8 compatibility ✅ Merged
#1293 ProjectNode O(1) document lookup Independent

All PRs can be merged independently in any order.

Update: reworked after review

The Twig compile cache is gone, and EnvironmentBuilder is byte-identical to main again. @jaapio's point stands on its own — a compile cache only pays off where the filesystem survives between runs, which is not where this tool mostly runs, and @garvinhicking confirmed that for the TYPO3 pipeline. Independently of that it was unconditional and pointed at a fixed path in the shared temp directory: the second user to build on a shared runner hits "Unable to write in the cache directory", the load path include_onces PHP from a world writable place, and a template whose mtime moves backwards is served stale indefinitely.

PreNodeRendererFactory follows @jaapio's suggestion. There is a new PreNodeRendererCachableSupports interface with cacheSupport(): bool, and the factory decides once per node class only when every pre-renderer implements it and returns true. supports() receives a node instance and may inspect its state, so caching by class froze whatever the first node of a class happened to yield — a third party pre-renderer answering for external links only would have been switched off for the whole build by the first internal one. Both pre-renderers here look at the class alone and say so. Two unit tests cover both directions.

The relative path cache is held for one document instead of being keyed by output file. The generator lives for the whole render and every document renders the same site wide menu, so the old key let the table grow as documents times link targets. The repetition it exploits is within a document, so the speedup survives while the size no longer depends on the number of documents.

The globals memo in TwigTemplateRenderer is keyed on the environment as well as the context. EnvironmentBuilder::setEnvironmentFactory() is public API and its replacement carries no globals, so the memo would have skipped re-applying them and every template would have rendered against env = null.

The four remaining review threads are the ones answered with measurements; those are left as they are.

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

@CybotTM
CybotTM force-pushed the perf/caching-improvements branch 4 times, most recently from d2f2cfa to 0fd7a3e Compare January 22, 2026 00:51
@CybotTM CybotTM changed the title perf: Add caching layer for frequently accessed operations perf: Add caching layer for rendering operations Jan 22, 2026
@CybotTM
CybotTM force-pushed the perf/caching-improvements branch 2 times, most recently from ee3b8de to 55158bb Compare January 22, 2026 01:22
@CybotTM
CybotTM force-pushed the perf/caching-improvements branch from 55158bb to 44a9023 Compare January 22, 2026 01:31
CybotTM added a commit to CybotTM/guides that referenced this pull request Jan 22, 2026
Add SUPPORTED_SCHEMAS_LIST and isSupportedScheme() to ExternalReferenceResolver
for O(1) hash set lookup instead of regex matching against 371 IANA schemes.
This is ~6x faster than the 5600+ character regex pattern.

InlineLexer now uses ExternalReferenceResolver::isSupportedScheme() to
validate URI schemes during tokenization.

Note: This change is also in PR phpDocumentor#1287 - when both PRs merge, the conflict
is trivially resolved by keeping one version.
@CybotTM
CybotTM force-pushed the perf/caching-improvements branch 2 times, most recently from e2bd863 to 44a9023 Compare January 22, 2026 19:09
@CybotTM
CybotTM force-pushed the perf/caching-improvements branch from 44a9023 to c49d97f Compare January 23, 2026 13:00
CybotTM added a commit to CybotTM/guides that referenced this pull request Jan 23, 2026
Add SUPPORTED_SCHEMAS_LIST and isSupportedScheme() to ExternalReferenceResolver
for O(1) hash set lookup instead of regex matching against 371 IANA schemes.
This is ~6x faster than the 5600+ character regex pattern.

InlineLexer now uses ExternalReferenceResolver::isSupportedScheme() to
validate URI schemes during tokenization.

Note: This change is also in PR phpDocumentor#1287 - when both PRs merge, the conflict
is trivially resolved by keeping one version.
@CybotTM
CybotTM marked this pull request as ready for review January 23, 2026 13:06

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

Thanks for these improvements. I see a lot of very nice spots to focus on. And some really good suggestions.
I can see how many on these changes would safe cpu cycles. But I'm a little concerned about the memory impact of some of the changes.

Please keep in mind that this tool is running mostly in CI enviroments in single runs. Not many people are using it as a quick feedback tool. And if they do, they use the build in dev-server that gives them response in less than a second.

Maybe we can split the parts that we are more sure about from the debatable topics to make the improvements land fast, and not block the whole pr. Ofcourse I can be wrong at specify topics. But the attached benchmark does not really show the improvements in this specify PR if I understand that correctly?
Maybe we can use some small benchmarks to show the improvements and impact of the changes in the smaller algorithms. I'm open to help you with that. Before I used phpBench and xdebug profiles to test simple algorithms in isolation. And pushed thousands of variants through logic to verify the improvements.

Comment on lines +24 to +27
*
* Note: Caching assumes PreNodeRenderer::supports() only checks the node's
* class type, not instance-specific properties. If a PreNodeRenderer needs
* to check node properties, caching by class would return incorrect results.

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.

We cannot make sure nobody is relying on this behavior. I concider this to be a breaking change. I see how this could improve the speed, but the actual impact of this change might be limited.

What we could do is introduce a new interface that can be implemented by the PreNodeRenderer that already exist.
PreNodeRendererCachableSupports would have a method cacheSupport() : bool

When preRenderers is not empty and all preRenderers are cachable, we can create add it to cache.

I think that will make sure we are backward compatible but still can cache the node renderers.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Implemented as you suggested, in 3be2457. There is now a PreNodeRendererCachableSupports interface with cacheSupport(): bool; PreNodeRendererFactory decides once per node class only when every pre-renderer implements it and returns true, and falls back to asking per node otherwise. Both pre-renderers in this repository look at the node class alone and say so, while a third party one that inspects instance state keeps working — previously the first node of a class would have frozen the answer for the whole build. Two unit tests cover both directions.

$uri = BaseUri::from($url);
if ($uri->isAbsolute()) {
return $url;
$cacheKey = $basePath . '|' . $url;

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.

I'm wondering how much this would improve. There is not much heavy logic in this method. And I think the actual increase of memory usage does not add up to the small improvement this change has.

A more specify benchmark on this method could help me to understand why this improves the speed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

BaseUri::from($url) is quite heavy!

absoluteUrl() performance for Documentation-rendertest:

Scenario Calls Time/call Total time Memory Speedup
Old (singleton) 355 3.95 µs 1.40 ms 0 KB -
New (singleton) 355 0.08 µs 0.03 ms +66 KB 53x
Old (new instance) 355 3.97 µs 1.41 ms 0 KB -
New (new instance) 355 2.00 µs 0.71 ms +66 KB 2x

"new instance" means if DocumentNameResolver would use a new instance for every document but it is a Symfony service (singleton).

Comment on lines +258 to +261
// O(1) lookup using hash map instead of O(n) iteration
$file = $entryNode->getFile();
if (isset($this->documentsByFile[$file])) {
return $this->documentsByFile[$file];

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.

Maybe we can combine the logic of building the cache and finding the correct entry in here, This would prevent extra load for the people not using the getDocumentNodeForEntry. And also centralizes the creating which is now in two methods.

We could still pass the cache to the new instance like you do right now. Which would speed up things. 👍

Comment on lines +99 to +103
if (isset($this->relativeUrlCache[$url])) {
return $this->relativeUrlCache[$url];
}

return $this->relativeUrlCache[$url] = BaseUri::from($url)->isRelativePath();

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.

I do not see the value of this extra cache. I think the extra memory consumption is not worth the less cpu cycles.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

For Documentation-rendertest

Calls per render 7,566
Unique 969
Repetition rate 87%
Scenario Time/call Total time Memory Speedup
Old (no cache) 3.82 µs 28.9 ms 0 KB -
New (with cache) 0.01 µs 0.1 ms +40 KB 280x

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The measured numbers still hold, but your concern about the memory does too, and it was the sharper problem here: the key contained the output file, so on a service that lives for the whole render and a manual where every document renders the same site wide menu, the table grew as documents times link targets. In 3be2457 the cache is held for one document and dropped when the next one starts. The repetition it exploits is within a document, so the speedup survives, and the size no longer depends on the number of documents.

['debug' => true],
[
'debug' => true,
'cache' => new FilesystemCache(sys_get_temp_dir() . '/guides-twig-cache'),

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.

Adding twig cache only adds value when the filesystem persists between runs. Which is not the case for people using this tool in CI or in docker. The extra file writes do even slow down the execution in the benchmarks I did in the past. This is why there is no cache enabled in this project.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adding twig cache only adds value when the filesystem persists between runs.

You are right, twig has an in-memory cache. I missed this.

Which is not the case for people using this tool in CI or in docker.

You do not know how people are using this, nor how they are using this in their CI or how they use the container. Caching is also easy possible in CI and/or with container.

using this tool in CI or in docker

Would also not require supporting PHP 8.1 - 8.5 ... ;-)

The use-case was repeated rendering while working on the docs. Or repeated rendering of different docs. Dunno how TYPO3 docs deployment works currently - with an ever running job benefitting from in-memory caching, or new processes for every new docs package without caching.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(TYPO3 CI docs deployment runs are performed with a usual clean runner; IIRC some composer packages are cached. But the render is performed with a fresh env using the docker container that has no state.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You are right, and it is removed in 3be2457. Beyond the point you make about the filesystem not surviving between runs, the cache was unconditional and pointed at a fixed path in the shared temp directory: on a shared runner the second user to build hits "Unable to write in the cache directory", the load path include_onces PHP from a world writable place, and a template whose mtime moves backwards — rsync -t, a tarball, a fixed SOURCE_DATE_EPOCH — is served stale indefinitely. The file is back to what main has.

Comment on lines +32 to +38

// Only update globals when context changes (once per document, not per template)
if ($this->lastContext !== $context) {
$this->lastContext = $context;
$twig->addGlobal('env', $context);
$twig->addGlobal('debugInformation', $context->getLoggerInformation());
}

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.

I would be very surprised if this would improve the performance of the application. But as this method is called hundreds of times in a normal render, if could.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

for Documentation-rendertest

Metric Old New Notes
Call counts
addGlobal() calls 57,070 72 99.9% reduction
getLoggerInformation() calls 28,535 36 Only on context change
Simulated renderTemplate() only ~30k calls, trivial template
Time 36.25 ms 30.58 ms 15.6% faster
Savings - 5.67 ms
Full Documentation-rendertest Parsing, compiling, I/O, etc.
Estimated total time ~2-4 sec ~2-4 sec 5.67 ms = ~0.2% of total

Comment on lines +460 to +464
if (self::$schemaHashSet === null) {
self::$schemaHashSet = array_fill_keys(self::SUPPORTED_SCHEMAS_LIST, true);
}

return isset(self::$schemaHashSet[$scheme]);

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.

Would a simple array_flip not be faster?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is called only once during a whole documentation rendering process. And both methods are equal ~1.3µs.

CybotTM added a commit to CybotTM/guides that referenced this pull request Mar 9, 2026
Add SUPPORTED_SCHEMAS_LIST and isSupportedScheme() to ExternalReferenceResolver
for O(1) hash set lookup instead of regex matching against 371 IANA schemes.
This is ~6x faster than the 5600+ character regex pattern.

InlineLexer now uses ExternalReferenceResolver::isSupportedScheme() to
validate URI schemes during tokenization.

Note: This change is also in PR phpDocumentor#1287 - when both PRs merge, the conflict
is trivially resolved by keeping one version.
@CybotTM
CybotTM force-pushed the perf/caching-improvements branch from c49d97f to 24285c2 Compare March 9, 2026 00:22
@CybotTM
CybotTM force-pushed the perf/caching-improvements branch from 24285c2 to ba0d957 Compare April 16, 2026 21:43
CybotTM added a commit to CybotTM/guides that referenced this pull request Apr 16, 2026
Add SUPPORTED_SCHEMAS_LIST and isSupportedScheme() to ExternalReferenceResolver
for O(1) hash set lookup instead of regex matching against 371 IANA schemes.
This is ~6x faster than the 5600+ character regex pattern.

InlineLexer now uses ExternalReferenceResolver::isSupportedScheme() to
validate URI schemes during tokenization.

Note: This change is also in PR phpDocumentor#1287 - when both PRs merge, the conflict
is trivially resolved by keeping one version.
CybotTM added a commit to CybotTM/guides that referenced this pull request Jun 2, 2026
Add SUPPORTED_SCHEMAS_LIST and isSupportedScheme() to ExternalReferenceResolver
for O(1) hash set lookup instead of regex matching against 371 IANA schemes.
This is ~6x faster than the 5600+ character regex pattern.

InlineLexer now uses ExternalReferenceResolver::isSupportedScheme() to
validate URI schemes during tokenization.

Note: This change is also in PR phpDocumentor#1287 - when both PRs merge, the conflict
is trivially resolved by keeping one version.
@CybotTM
CybotTM force-pushed the perf/caching-improvements branch from ba0d957 to daa1323 Compare June 2, 2026 14:56
CybotTM added a commit to CybotTM/guides that referenced this pull request Jun 19, 2026
Add SUPPORTED_SCHEMAS_LIST and isSupportedScheme() to ExternalReferenceResolver
for O(1) hash set lookup instead of regex matching against 371 IANA schemes.
This is ~6x faster than the 5600+ character regex pattern.

InlineLexer now uses ExternalReferenceResolver::isSupportedScheme() to
validate URI schemes during tokenization.

Note: This change is also in PR phpDocumentor#1287 - when both PRs merge, the conflict
is trivially resolved by keeping one version.
@CybotTM
CybotTM force-pushed the perf/caching-improvements branch from daa1323 to 64534f2 Compare June 19, 2026 16:50
CybotTM added a commit to CybotTM/guides that referenced this pull request Jun 24, 2026
Add SUPPORTED_SCHEMAS_LIST and isSupportedScheme() to ExternalReferenceResolver
for O(1) hash set lookup instead of regex matching against 371 IANA schemes.
This is ~6x faster than the 5600+ character regex pattern.

InlineLexer now uses ExternalReferenceResolver::isSupportedScheme() to
validate URI schemes during tokenization.

Note: This change is also in PR phpDocumentor#1287 - when both PRs merge, the conflict
is trivially resolved by keeping one version.
@CybotTM
CybotTM force-pushed the perf/caching-improvements branch from 64534f2 to 7b0c5c9 Compare June 24, 2026 15:26
CybotTM added a commit to CybotTM/guides that referenced this pull request Jul 1, 2026
Add SUPPORTED_SCHEMAS_LIST and isSupportedScheme() to ExternalReferenceResolver
for O(1) hash set lookup instead of regex matching against 371 IANA schemes.
This is ~6x faster than the 5600+ character regex pattern.

InlineLexer now uses ExternalReferenceResolver::isSupportedScheme() to
validate URI schemes during tokenization.

Note: This change is also in PR phpDocumentor#1287 - when both PRs merge, the conflict
is trivially resolved by keeping one version.
Add instance and result caching for hot paths in the rendering pipeline:

- ProjectNode: cache root document entry with O(1) lookup, add fast-path
  to getDocumentEntry(), lazy cache invalidation in setDocumentEntries()
- DocumentNode: add hasDocumentEntry() helper for safe checks
- RenderContext: use hash map for O(1) document lookup by file path,
  optimize withDocument() to clone directly instead of rebuilding
- DocumentNameResolver: cache URI analysis and resolved paths
- ExternalReferenceResolver: pre-compile schema regex pattern
- SluggerAnchorNormalizer: cache slugger instance and normalization results
- AbstractUrlGenerator: cache isRelativePath() checks
- RelativeUrlGenerator: cache computed relative paths
- PreNodeRendererFactory: cache renderer lookups by node class
- TwigEnvironmentBuilder: enable filesystem template caching
- TwigTemplateRenderer: cache global context to avoid redundant addGlobal()

See https://cybottm.github.io/render-guides/ for benchmark data.
Four corrections to the previous commit of this branch, three of them from
@jaapio's review, one from a later read.

Drop the Twig compile cache. It was unconditional and pointed at a fixed path in
the shared temp directory: on a shared runner the second user to build hits
"Unable to write in the cache directory", the load path `include_once`s PHP from
a world writable place, and a template whose mtime moves backwards — `rsync -t`,
a tarball, a fixed SOURCE_DATE_EPOCH — is served stale for good. And the point
@jaapio made stands on its own: a compile cache only pays off where the
filesystem survives between runs, which is not where this tool mostly runs.

Let a pre-renderer declare whether its `supports()` looks at the node class
alone, through the new `PreNodeRendererCachableSupports::cacheSupport()`, and
only decide once per class when every pre-renderer says so. `supports()` takes a
node instance and may inspect its state, so caching by class froze whatever the
first node of a class happened to yield — a third party pre-renderer that
answers for external links only would have been switched off for the whole build
by the first internal one. Both pre-renderers in this repository look at the
class alone and say so.

Hold the relative path cache for one document instead of keying it by output
file. The generator lives for the whole render and every document renders the
same site wide menu, so the old key made the table grow as documents times link
targets — millions of retained strings for a large manual. It is now dropped
when the next document starts, which keeps the hit rate within a document and
bounds the size.

Key the globals memo in `TwigTemplateRenderer` on the environment as well as the
context. `EnvironmentBuilder::setEnvironmentFactory()` is public API and its
replacement carries no globals, so the memo would have skipped re-applying them
and every template would have rendered against `env = null`.

Assisted-by: claude-code:claude-opus-5
Agent-Session: https://claude.ai/code/session_015QXXkquh2eQNBiTYA39Wss
Signed-off-by: Sebastian Mendel <sebastian.mendel@netresearch.de>
@CybotTM CybotTM changed the title perf: Add caching layer for rendering operations [TASK] Add caching layer for rendering operations Aug 15, 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.

3 participants