-
-
Notifications
You must be signed in to change notification settings - Fork 16
perf: Add caching layer for rendering operations #1287
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
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
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.
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.
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.
| * | ||
| * 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.
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
| // 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.
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. 👍
| if (isset($this->relativeUrlCache[$url])) { | ||
| return $this->relativeUrlCache[$url]; | ||
| } | ||
|
|
||
| return $this->relativeUrlCache[$url] = BaseUri::from($url)->isRelativePath(); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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'), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.)
|
|
||
| // 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.
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.
There was a problem hiding this comment.
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 |
| 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.
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?
There was a problem hiding this comment.
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.
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.