Skip to content

Conversation

@CybotTM
Copy link
Contributor

@CybotTM CybotTM commented Jan 21, 2026

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 Independent
#1289 CLI container caching Independent
#1291 Symfony 8 compatibility ✅ Merged
#1293 ProjectNode O(1) document lookup Independent

All PRs can be merged independently in any order.

@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
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.
@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
Copy link
Member

@jaapio jaapio left a comment

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

$uri = BaseUri::from($url);
if ($uri->isAbsolute()) {
return $url;
$cacheKey = $basePath . '|' . $url;
Copy link
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
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
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
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
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

['debug' => true],
[
'debug' => true,
'cache' => new FilesystemCache(sys_get_temp_dir() . '/guides-twig-cache'),
Copy link
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
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
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.)

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

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