[TASK] Add caching layer for rendering operations - #1287
Conversation
d2f2cfa to
0fd7a3e
Compare
ee3b8de to
55158bb
Compare
55158bb to
44a9023
Compare
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.
e2bd863 to
44a9023
Compare
44a9023 to
c49d97f
Compare
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.
There was a problem hiding this comment.
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.
| * | ||
| * 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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
| // O(1) lookup using hash map instead of O(n) iteration | ||
| $file = $entryNode->getFile(); | ||
| if (isset($this->documentsByFile[$file])) { | ||
| return $this->documentsByFile[$file]; |
There was a problem hiding this comment.
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. 👍
| if (isset($this->relativeUrlCache[$url])) { | ||
| return $this->relativeUrlCache[$url]; | ||
| } | ||
|
|
||
| return $this->relativeUrlCache[$url] = BaseUri::from($url)->isRelativePath(); |
There was a problem hiding this comment.
I do not see the value of this extra cache. I think the extra memory consumption is not worth the less cpu cycles.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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'), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
(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.)
There was a problem hiding this comment.
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.
|
|
||
| // 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()); | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
| if (self::$schemaHashSet === null) { | ||
| self::$schemaHashSet = array_fill_keys(self::SUPPORTED_SCHEMAS_LIST, true); | ||
| } | ||
|
|
||
| return isset(self::$schemaHashSet[$scheme]); |
There was a problem hiding this comment.
Would a simple array_flip not be faster?
There was a problem hiding this comment.
This is called only once during a whole documentation rendering process. And both methods are equal ~1.3µs.
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.
c49d97f to
24285c2
Compare
24285c2 to
ba0d957
Compare
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 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.
ba0d957 to
daa1323
Compare
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.
daa1323 to
64534f2
Compare
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.
64534f2 to
7b0c5c9
Compare
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.
7b0c5c9 to
e37d7fa
Compare
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>
Summary
Adds caching layers for frequently accessed rendering operations to improve performance.
Changes
Performance Impact
See Performance Analysis Report for detailed benchmarks.
Related PRs
All PRs can be merged independently in any order.
Update: reworked after review
The Twig compile cache is gone, and
EnvironmentBuilderis byte-identical tomainagain. @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 pathinclude_onces PHP from a world writable place, and a template whose mtime moves backwards is served stale indefinitely.PreNodeRendererFactoryfollows @jaapio's suggestion. There is a newPreNodeRendererCachableSupportsinterface withcacheSupport(): 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
TwigTemplateRendereris 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 againstenv = 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