[RFC] [Discussion] OPcache Static Cache

php.internals

zeriyoshi

108 days ago
Hi internals, I'd like to start the discussion for a new RFC, OPcache Static Cache. RFC: https://wiki.php.net/rfc/opcache_static_cache Implementation: https://github.com/php/php-src/pull/22052 The proposal adds an OPcache-managed shared-memory cache for explicit userland values and for selected PHP static state. It introduces explicit functions under the OPcache namespace (volatile_* and persistent_*) and two attributes, #[OPcache\VolatileStatic] and #[OPcache\PersistentStatic], that let selected static properties and method static variables survive across requests. The feature is disabled by default and only activates once memory is allocated through the new INI directives. The RFC covers the motivation, the deliberate split between the two backends, the trust model (one PHP runtime = one trust domain; this is not a tenant isolation boundary), and benchmarks against APCu on NTS php-fpm and ZTS FrankenPHP. The PR is the full implementation, with PHPT coverage summarized in the Validation section. One thing to flag on the implementation status: the Windows build is currently broken. I don't have a Windows development environment available yet — one is being arranged through work, and I'll get the Windows side fixed once that's in place. Feedback welcome. Best Regards, Go Kudo

Larry Garfield

107 days ago
On Sat, May 16, 2026, at 10:19 AM, Go Kudo wrote:
> Hi internals, > > I'd like to start the discussion for a new RFC, OPcache Static Cache. > > RFC: https://wiki.php.net/rfc/opcache_static_cache > Implementation: https://github.com/php/php-src/pull/22052 > > The proposal adds an OPcache-managed shared-memory cache for explicit > userland values and for selected PHP static state. It introduces > explicit functions under the OPcache namespace (volatile_* and > persistent_*) and two attributes, #[OPcache\VolatileStatic] and > #[OPcache\PersistentStatic], that let selected static properties and > method static variables survive across requests. The feature is > disabled by default and only activates once memory is allocated through > the new INI directives. > > The RFC covers the motivation, the deliberate split between the two > backends, the trust model (one PHP runtime = one trust domain; this is > not a tenant isolation boundary), and benchmarks against APCu on NTS > php-fpm and ZTS FrankenPHP. The PR is the full implementation, with > PHPT coverage summarized in the Validation section. > > One thing to flag on the implementation status: the Windows build is > currently broken. I don't have a Windows development environment > available yet — one is being arranged through work, and I'll get the > Windows side fixed once that's in place. > > Feedback welcome. > > Best Regards, > Go Kudo
Interesting! I can definitely see uses for it, and I appreciate the level of detail in the RFC. Some thoughts, though: - atomic-decrement throwing if a value doesn't exist sounds like a footgun. For something like an up/down voting widget, I could very easily see someone hitting the Down Vote button first, which would cause it to crash. Having to always check _exists() on decrement but not on increment is inconsistent and likely to confuse people. - Are either of these stores purged on reboot? - "persistent cache and returns void on success." - No, returns null. You can't return void. A function can have a void return type, whether it's successful or not. Not quite the same thing. - Having the locks automatically self-unlock sure sounds elegant at first, but the lack of symmetry in the API feels very error prone. People will want to use it like a transaction, but since it unlocks on the first write there's no way to make it one. It just silently unlocks if certain functions are called. But if they're not called, there's no way to unlock it. That's even more of an issue in a persistent-process use case, where you could easily not hit the process-end for minutes or hours, so the lock never automatically clears. Use case: You need to update some lookup table, so you lock the stored key, compute the new table, then write the new table. But if the compute step fails for some reason, you now have a locked value with no way to unlock it, but no new value to write to it. It's better in many cases to leave the stale data there rather than delete it, but this API doesn't offer a way to do that. - It's not made clear: Do objects have their __serialize() methods called when storing (and vice versa on load), or no? "They have to be serializable" is not something that can be otherwise determined. - Status API: Uh, what are the keys? No arrays here please. Please make it an object with defined readonly properties. Please. - You realize you're effectively adding a Memoize attribute to PHP by another name, right? Just making sure. :-) - A property using the volatile-tracking strategy, if run in a persistent process, seems like it would never get written. That feels like a problem. - The section on write times is rather abstract and academic, so a bit hard to follow. If I read correctly, though, it means that writing to a sub-property of an array/object on a cached property won't trigger a resave? That feels like another footgun waiting to happen. - It's not clear if there's a way to clear an attribute-cached value other than nuking the entire volatile/persistent cache. Is there not? It feels like there should be one... Especially for "persistent," I don't want to have to nuke my entire "persistent" cache from orbit because one value got corrupted somehow. - Defaulting off... I can see the argument for that, but that means it will be off for most users. That means I, as a library author of, say, a routing system or a DI container, cannot use it, because I have to assume most users won't have it. That kneecaps the usefulness of this feature dramatically. I would strongly recommend setting at least some default-on amount, even if it's only the minimum 8 MB, if we want this feature to actually be used. (And I can already think of a few places where I'd want to use it myself.) - Related, what happens if they're disabled but someone tries to use this functionality? Does it operate like every read is a cache miss, or does it error? If the latter, that means any code that uses the attributes REQUIRES that the ini directives be turned on. We generally try to avoid this kind of "your code may or may not work depending on ini settings" issues. (Hello, magic_quotes!) - What's the development experience with this? Frequently, in dev mode frameworks will disable caches. If the volatile cache just misses silently that would work there, but not for the persistent cache. How would I have a persistent route cache that is automatically rebuilt on every request during development while I'm messing with routes? - I understand the value of keeping it simple by making it single-tenant. However, I can very easily see different 3rd party libraries wanting to make use of the cache at the same time. That poses a risk of key-space collision, though that's resolvable by a convention to use a key prefix. What it does not resolve is cases where one library wants to wipe-and-rebuild a dynamic list of keys, but some other library isn't expecting a total purge. There's a high risk of libraries stepping on each other here. - Currently, this is just a basic key/value store. That's great for many things, but not very queryable. This is absolutely scope creep, but would there be some way to extend this (in a future RFC, I'm sure) to allow, say, a persistent memory-resident SQLite database? Currently you can write one to disk, but then you have to deal with disk permissions. A memory resident database now is request-specific, so not useful outside of testing. It would be lovely if there were some way to extend in that direction. --Larry Garfield

zeriyoshi

106 days ago
2026年5月17日(日) 5:14 Larry Garfield <larry@garfieldtech.com>:
> On Sat, May 16, 2026, at 10:19 AM, Go Kudo wrote: > > Hi internals, > > > > I'd like to start the discussion for a new RFC, OPcache Static Cache. > > > > RFC: https://wiki.php.net/rfc/opcache_static_cache > > Implementation: https://github.com/php/php-src/pull/22052 > > > > The proposal adds an OPcache-managed shared-memory cache for explicit > > userland values and for selected PHP static state. It introduces > > explicit functions under the OPcache namespace (volatile_* and > > persistent_*) and two attributes, #[OPcache\VolatileStatic] and > > #[OPcache\PersistentStatic], that let selected static properties and > > method static variables survive across requests. The feature is > > disabled by default and only activates once memory is allocated through > > the new INI directives. > > > > The RFC covers the motivation, the deliberate split between the two > > backends, the trust model (one PHP runtime = one trust domain; this is > > not a tenant isolation boundary), and benchmarks against APCu on NTS > > php-fpm and ZTS FrankenPHP. The PR is the full implementation, with > > PHPT coverage summarized in the Validation section. > > > > One thing to flag on the implementation status: the Windows build is > > currently broken. I don't have a Windows development environment > > available yet — one is being arranged through work, and I'll get the > > Windows side fixed once that's in place. > > > > Feedback welcome. > > > > Best Regards, > > Go Kudo > > Interesting! I can definitely see uses for it, and I appreciate the level > of detail in the RFC. > > Some thoughts, though: > > - atomic-decrement throwing if a value doesn't exist sounds like a > footgun. For something like an up/down voting widget, I could very easily > see someone hitting the Down Vote button first, which would cause it to > crash. Having to always check _exists() on decrement but not on increment > is inconsistent and likely to confuse people. > > - Are either of these stores purged on reboot? > > - "persistent cache and returns void on success." - No, returns null. You > can't return void. A function can have a void return type, whether it's > successful or not. Not quite the same thing. > > - Having the locks automatically self-unlock sure sounds elegant at first, > but the lack of symmetry in the API feels very error prone. People will > want to use it like a transaction, but since it unlocks on the first write > there's no way to make it one. It just silently unlocks if certain > functions are called. But if they're not called, there's no way to unlock > it. That's even more of an issue in a persistent-process use case, where > you could easily not hit the process-end for minutes or hours, so the lock > never automatically clears. > > Use case: You need to update some lookup table, so you lock the stored > key, compute the new table, then write the new table. But if the compute > step fails for some reason, you now have a locked value with no way to > unlock it, but no new value to write to it. It's better in many cases to > leave the stale data there rather than delete it, but this API doesn't > offer a way to do that. > > - It's not made clear: Do objects have their __serialize() methods called > when storing (and vice versa on load), or no? "They have to be > serializable" is not something that can be otherwise determined. > > - Status API: Uh, what are the keys? No arrays here please. Please make > it an object with defined readonly properties. Please. > > - You realize you're effectively adding a Memoize attribute to PHP by > another name, right? Just making sure. :-) > > - A property using the volatile-tracking strategy, if run in a persistent > process, seems like it would never get written. That feels like a problem. > > - The section on write times is rather abstract and academic, so a bit > hard to follow. If I read correctly, though, it means that writing to a > sub-property of an array/object on a cached property won't trigger a > resave? That feels like another footgun waiting to happen. > > - It's not clear if there's a way to clear an attribute-cached value other > than nuking the entire volatile/persistent cache. Is there not? It feels > like there should be one... Especially for "persistent," I don't want to > have to nuke my entire "persistent" cache from orbit because one value got > corrupted somehow. > > - Defaulting off... I can see the argument for that, but that means it > will be off for most users. That means I, as a library author of, say, a > routing system or a DI container, cannot use it, because I have to assume > most users won't have it. That kneecaps the usefulness of this feature > dramatically. I would strongly recommend setting at least some default-on > amount, even if it's only the minimum 8 MB, if we want this feature to > actually be used. (And I can already think of a few places where I'd want > to use it myself.) > > - Related, what happens if they're disabled but someone tries to use this > functionality? Does it operate like every read is a cache miss, or does it > error? If the latter, that means any code that uses the attributes > REQUIRES that the ini directives be turned on. We generally try to avoid > this kind of "your code may or may not work depending on ini settings" > issues. (Hello, magic_quotes!) > > - What's the development experience with this? Frequently, in dev mode > frameworks will disable caches. If the volatile cache just misses silently > that would work there, but not for the persistent cache. How would I have > a persistent route cache that is automatically rebuilt on every request > during development while I'm messing with routes? > > - I understand the value of keeping it simple by making it single-tenant. > However, I can very easily see different 3rd party libraries wanting to > make use of the cache at the same time. That poses a risk of key-space > collision, though that's resolvable by a convention to use a key prefix. > What it does not resolve is cases where one library wants to > wipe-and-rebuild a dynamic list of keys, but some other library isn't > expecting a total purge. There's a high risk of libraries stepping on each > other here. > > - Currently, this is just a basic key/value store. That's great for many > things, but not very queryable. This is absolutely scope creep, but would > there be some way to extend this (in a future RFC, I'm sure) to allow, say, > a persistent memory-resident SQLite database? Currently you can write one > to disk, but then you have to deal with disk permissions. A memory > resident database now is request-specific, so not useful outside of > testing. It would be lovely if there were some way to extend in that > direction. > > --Larry Garfield >
Hi Larry, Thank you for the detailed feedback. I updated the RFC and the implementation to address the concrete API and documentation issues you pointed out. https://wiki.php.net/rfc/opcache_static_cache The main changes are: - `persistent_atomic_decrement()` now creates a missing key with -$step, matching `persistent_atomic_increment()`. - `volatile_lock()` and `persistent_lock()` now have matching `*_unlock()` functions. - The lock APIs also accept an optional lease value, so abandoned builder reservations can expire even in persistent-worker environments. - The status APIs now return a read-only `OPcache\StaticCacheInfo` object instead of arrays. - The RFC now documents that both static-cache backends are scoped to the lifetime of the current OPcache static-cache shared-memory segment, and are not durable storage. - The wording around persistent_store() was corrected to describe the void return type rather than "returning void". - The RFC now describes when `__serialize()` and `__unserialize()` are called, and that userland serialization keeps those object graphs off the fastest direct/shared-graph path. - The publication rules for `VolatileStatic immediate` mode, `VolatileStatic tracking` mode, and `PersistentStatic` have been expanded. - `VolatileStatic tracking` is now documented as publishing at PHP request shutdown, not process shutdown, even under FPM/FrankenPHP/persistent workers. - Attribute-backed entries can now be deleted either by loaded class name or by documented exact static-property/method-static keys, without clearing the whole backend. - I also reran the benchmark matrix from clean NTS FPM, NTS/ZTS CLI, and ZTS FrankenPHP builds, and updated the RFC tables. Some of the broader points are open questions or design tradeoffs rather than direct fixes. On the default-off setting, I want to be more direct about where I actually stand. I would prefer this feature to be default-on. Administrator opt-in noticeably limits library adoption, and that significantly reduces the value of the API as a portable primitive that libraries can rely on being present. What is holding me back is the shared-hosting case. The cache is a single shared-memory trust domain, so on a host where multiple tenants share one OPcache segment, default-on without an isolation story could expose those tenants to each other through the static cache. I do not yet have a design I am confident makes default-on safe in that environment, and that is the only reason the RFC currently ships with the feature disabled. I would genuinely appreciate your input here. If you (or anyone on the list) see a viable path — per-pool / per-SAPI segments, a trust-domain or namespace mechanism enforced by the engine, a configuration model where the host opts in per vhost rather than per server, or something I have not considered — I would be very happy to rework the proposal around it. If we can land on a model that is safe for shared hosting, I would gladly flip the default in this RFC rather than defer it to a follow-up. For disabled backends, the explicit APIs fail rather than silently pretending to be a miss-only cache. Attribute-backed state falls back to ordinary request local static behavior when the corresponding backend is unavailable. I can add a short FAQ entry for this development-mode behavior if that would make the RFC clearer. For multi-library key collisions, the explicit cache remains a shared namespace and applications/libraries still need key prefixes, similar to APCu. The new attribute deletion and exact-key deletion support should reduce the need for whole-backend clears, but this RFC does not add a separate namespace mechanism. I left the broader trust-domain/namespace question as an Open Issue, and it is closely related to the shared-hosting question above. The memory-resident SQLite idea is interesting, but I think it is outside the scope of this RFC. This proposal is intentionally a small key/value and static state facility first. Thanks again. The feedback helped make several parts of the API much more explicit. Best Regards, Go Kudo

Benjamin Eberlei

106 days ago
Am 18.05.2026, 13:00:02 schrieb Go Kudo <zeriyoshi@gmail.com>:
> > > 2026年5月17日(日) 5:14 Larry Garfield <larry@garfieldtech.com>: > >> On Sat, May 16, 2026, at 10:19 AM, Go Kudo wrote: >> > Hi internals, >> > >> > I'd like to start the discussion for a new RFC, OPcache Static Cache. >> > >> > RFC: https://wiki.php.net/rfc/opcache_static_cache >> > Implementation: https://github.com/php/php-src/pull/22052 >> > >> > The proposal adds an OPcache-managed shared-memory cache for explicit >> > userland values and for selected PHP static state. It introduces >> > explicit functions under the OPcache namespace (volatile_* and >> > persistent_*) and two attributes, #[OPcache\VolatileStatic] and >> > #[OPcache\PersistentStatic], that let selected static properties and >> > method static variables survive across requests. The feature is >> > disabled by default and only activates once memory is allocated through >> > the new INI directives. >> > >> > The RFC covers the motivation, the deliberate split between the two >> > backends, the trust model (one PHP runtime = one trust domain; this is >> > not a tenant isolation boundary), and benchmarks against APCu on NTS >> > php-fpm and ZTS FrankenPHP. The PR is the full implementation, with >> > PHPT coverage summarized in the Validation section. >> > >> > One thing to flag on the implementation status: the Windows build is >> > currently broken. I don't have a Windows development environment >> > available yet — one is being arranged through work, and I'll get the >> > Windows side fixed once that's in place. >> > >> > Feedback welcome. >> > >> > Best Regards, >> > Go Kudo >> >> Interesting! I can definitely see uses for it, and I appreciate the >> level of detail in the RFC. >> >> Some thoughts, though: >> >> - atomic-decrement throwing if a value doesn't exist sounds like a >> footgun. For something like an up/down voting widget, I could very easily >> see someone hitting the Down Vote button first, which would cause it to >> crash. Having to always check _exists() on decrement but not on increment >> is inconsistent and likely to confuse people. >> >> - Are either of these stores purged on reboot? >> >> - "persistent cache and returns void on success." - No, returns null. >> You can't return void. A function can have a void return type, whether >> it's successful or not. Not quite the same thing. >> >> - Having the locks automatically self-unlock sure sounds elegant at >> first, but the lack of symmetry in the API feels very error prone. People >> will want to use it like a transaction, but since it unlocks on the first >> write there's no way to make it one. It just silently unlocks if certain >> functions are called. But if they're not called, there's no way to unlock >> it. That's even more of an issue in a persistent-process use case, where >> you could easily not hit the process-end for minutes or hours, so the lock >> never automatically clears. >> >> Use case: You need to update some lookup table, so you lock the stored >> key, compute the new table, then write the new table. But if the compute >> step fails for some reason, you now have a locked value with no way to >> unlock it, but no new value to write to it. It's better in many cases to >> leave the stale data there rather than delete it, but this API doesn't >> offer a way to do that. >> >> - It's not made clear: Do objects have their __serialize() methods called >> when storing (and vice versa on load), or no? "They have to be >> serializable" is not something that can be otherwise determined. >> >> - Status API: Uh, what are the keys? No arrays here please. Please make >> it an object with defined readonly properties. Please. >> >> - You realize you're effectively adding a Memoize attribute to PHP by >> another name, right? Just making sure. :-) >> >> - A property using the volatile-tracking strategy, if run in a persistent >> process, seems like it would never get written. That feels like a problem. >> >> - The section on write times is rather abstract and academic, so a bit >> hard to follow. If I read correctly, though, it means that writing to a >> sub-property of an array/object on a cached property won't trigger a >> resave? That feels like another footgun waiting to happen. >> >> - It's not clear if there's a way to clear an attribute-cached value >> other than nuking the entire volatile/persistent cache. Is there not? It >> feels like there should be one... Especially for "persistent," I don't >> want to have to nuke my entire "persistent" cache from orbit because one >> value got corrupted somehow. >> >> - Defaulting off... I can see the argument for that, but that means it >> will be off for most users. That means I, as a library author of, say, a >> routing system or a DI container, cannot use it, because I have to assume >> most users won't have it. That kneecaps the usefulness of this feature >> dramatically. I would strongly recommend setting at least some default-on >> amount, even if it's only the minimum 8 MB, if we want this feature to >> actually be used. (And I can already think of a few places where I'd want >> to use it myself.) >> >> - Related, what happens if they're disabled but someone tries to use this >> functionality? Does it operate like every read is a cache miss, or does it >> error? If the latter, that means any code that uses the attributes >> REQUIRES that the ini directives be turned on. We generally try to avoid >> this kind of "your code may or may not work depending on ini settings" >> issues. (Hello, magic_quotes!) >> >> - What's the development experience with this? Frequently, in dev mode >> frameworks will disable caches. If the volatile cache just misses silently >> that would work there, but not for the persistent cache. How would I have >> a persistent route cache that is automatically rebuilt on every request >> during development while I'm messing with routes? >> >> - I understand the value of keeping it simple by making it >> single-tenant. However, I can very easily see different 3rd party >> libraries wanting to make use of the cache at the same time. That poses a >> risk of key-space collision, though that's resolvable by a convention to >> use a key prefix. What it does not resolve is cases where one library >> wants to wipe-and-rebuild a dynamic list of keys, but some other library >> isn't expecting a total purge. There's a high risk of libraries stepping >> on each other here. >> >> - Currently, this is just a basic key/value store. That's great for many >> things, but not very queryable. This is absolutely scope creep, but would >> there be some way to extend this (in a future RFC, I'm sure) to allow, say, >> a persistent memory-resident SQLite database? Currently you can write one >> to disk, but then you have to deal with disk permissions. A memory >> resident database now is request-specific, so not useful outside of >> testing. It would be lovely if there were some way to extend in that >> direction. >> >> --Larry Garfield >> > > Hi Larry, > > Thank you for the detailed feedback. > > I updated the RFC and the implementation to address the concrete API and > documentation issues you pointed out. > > https://wiki.php.net/rfc/opcache_static_cache > > The main changes are: > > - `persistent_atomic_decrement()` now creates a missing key with -$step, > matching `persistent_atomic_increment()`. > - `volatile_lock()` and `persistent_lock()` now have matching `*_unlock()` > functions. > - The lock APIs also accept an optional lease value, so abandoned builder > reservations can expire even in persistent-worker environments. > - The status APIs now return a read-only `OPcache\StaticCacheInfo` object > instead of arrays. > - The RFC now documents that both static-cache backends are scoped to the > lifetime of the current OPcache static-cache shared-memory segment, and > are > not durable storage. > - The wording around persistent_store() was corrected to describe the void > return type rather than "returning void". > - The RFC now describes when `__serialize()` and `__unserialize()` are > called, and > that userland serialization keeps those object graphs off the fastest > direct/shared-graph path. > - The publication rules for `VolatileStatic immediate` mode, > `VolatileStatic > tracking` mode, and `PersistentStatic` have been expanded. > - `VolatileStatic tracking` is now documented as publishing at PHP request > shutdown, not process shutdown, even under FPM/FrankenPHP/persistent > workers. > - Attribute-backed entries can now be deleted either by loaded class name > or by > documented exact static-property/method-static keys, without clearing the > whole backend. > - I also reran the benchmark matrix from clean NTS FPM, NTS/ZTS CLI, and > ZTS > FrankenPHP builds, and updated the RFC tables. > > Some of the broader points are open questions or design tradeoffs rather > than > direct fixes. > > On the default-off setting, I want to be more direct about where I actually > stand. I would prefer this feature to be default-on. Administrator opt-in > noticeably limits library adoption, and that significantly reduces the > value > of the API as a portable primitive that libraries can rely on being > present. > > What is holding me back is the shared-hosting case. The cache is a single > shared-memory trust domain, so on a host where multiple tenants share one > OPcache segment, default-on without an isolation story could expose those > tenants to each other through the static cache. I do not yet have a design > I > am confident makes default-on safe in that environment, and that is the > only > reason the RFC currently ships with the feature disabled. >
> I would genuinely appreciate your input here. If you (or anyone on the > list) > see a viable path — per-pool / per-SAPI segments, a trust-domain or > namespace > mechanism enforced by the engine, a configuration model where the host opts > in per vhost rather than per server, or something I have not considered — I > would be very happy to rework the proposal around it. If we can land on a > model that is safe for shared hosting, I would gladly flip the default in > this RFC rather than defer it to a follow-up. >
There was a post on Reddit recently from someone modifying APCu to be FPM pool based. Maybe thats a helpful reference? https://www.reddit.com/r/PHP/comments/1sg9rln/clevel_apcu_key_isolation_based_on_fpm_pool_names/ https://github.com/Samer-Al-iraqi/apcu-fpm-pool-isolation

Larry Garfield

106 days ago
On Mon, May 18, 2026, at 6:00 AM, Go Kudo wrote:
> Hi Larry, > > Thank you for the detailed feedback. > > I updated the RFC and the implementation to address the concrete API and > documentation issues you pointed out. > > https://wiki.php.net/rfc/opcache_static_cache > > The main changes are: > > - `persistent_atomic_decrement()` now creates a missing key with -$step, > matching `persistent_atomic_increment()`.
+1
> - `volatile_lock()` and `persistent_lock()` now have matching > `*_unlock()` > functions.
I should note that this is an excellent example of where context managers and a `using` block would be helpful. :-)
> - The lock APIs also accept an optional lease value, so abandoned > builder > reservations can expire even in persistent-worker environments.
+1
> - The status APIs now return a read-only `OPcache\StaticCacheInfo` > object > instead of arrays.
+1 Though it's not entirely obvious to me which property I would need to check every single time I want to try storing to it. enabled? available? What's the fully safe read/write code pattern here?
> - The RFC now documents that both static-cache backends are scoped to > the > lifetime of the current OPcache static-cache shared-memory segment, > and are > not durable storage.
Ah, that's a big and important distinction! What does "lifetime of the current opcace static-cache shared memory segment" mean to developers who don't read this list? :-) Does it mean "persistent" also goes away if you restart FPM/Apache/whatever? Does that mean this is all basically useless for CLI? Those should be made very clear in non-internals-speak. So really, the distinction is more whether there's a TTL and eviction strategy, or if the eviction strategy is just "fall over and die." In that case, I'm not sure if "persistent" is even the right name for that part of the API, as it's not, well, persistent.
> - The RFC now describes when `__serialize()` and `__unserialize()` are > called, and > that userland serialization keeps those object graphs off the fastest > direct/shared-graph path.
I want to make sure I follow here. class Test { pubic string $a; public string $b; } $t = new Test(); persistent_store('t', $t); // Later $loadedT = persistent_fetch('t'); If Test does not have serialize/unserialize magic methods, then the object is stored "as is" and $t === $loadedT. If it does implement those methods, then it behaves like $loadedT = unserialize(serialize($t)); Is that correct? (Please clarify with examples in the RFC either way.)
> - The publication rules for `VolatileStatic immediate` mode, > `VolatileStatic > tracking` mode, and `PersistentStatic` have been expanded. > - `VolatileStatic tracking` is now documented as publishing at PHP > request > shutdown, not process shutdown, even under FPM/FrankenPHP/persistent > workers.
+1
> Some of the broader points are open questions or design tradeoffs rather than > direct fixes. > > On the default-off setting, I want to be more direct about where I actually > stand. I would prefer this feature to be default-on. Administrator opt-in > noticeably limits library adoption, and that significantly reduces the value > of the API as a portable primitive that libraries can rely on being present. > > What is holding me back is the shared-hosting case. The cache is a single > shared-memory trust domain, so on a host where multiple tenants share one > OPcache segment, default-on without an isolation story could expose those > tenants to each other through the static cache. I do not yet have a design I > am confident makes default-on safe in that environment, and that is the only > reason the RFC currently ships with the feature disabled. > > I would genuinely appreciate your input here. If you (or anyone on the list) > see a viable path — per-pool / per-SAPI segments, a trust-domain or namespace > mechanism enforced by the engine, a configuration model where the host opts > in per vhost rather than per server, or something I have not considered — I > would be very happy to rework the proposal around it. If we can land on a > model that is safe for shared hosting, I would gladly flip the default in > this RFC rather than defer it to a follow-up.
I suppose the question I'd ask here is how much of a factor is that these days? Shared hosting was a huge part of PHP's early years, but... I don't remember the last time I actually ran on a traditional shared hosting setup with shared opcache between different tenants. How much of the market even is that these days? (I have no idea.)
> For disabled backends, the explicit APIs fail rather than silently pretending > to be a miss-only cache. Attribute-backed state falls back to ordinary request > local static behavior when the corresponding backend is unavailable. I can add > a short FAQ entry for this development-mode behavior if that would make the RFC > clearer.
Yes please.
> For multi-library key collisions, the explicit cache remains a shared namespace > and applications/libraries still need key prefixes, similar to APCu. The new > attribute deletion and exact-key deletion support should reduce the need for > whole-backend clears, but this RFC does not add a separate namespace mechanism. > I left the broader trust-domain/namespace question as an Open Issue, and it is > closely related to the shared-hosting question above.
I don't have a good answer other than introducing "pools", which would likely make the API much more involved. That could be good, though, I'm not sure. (Esp. if it pushes the API toward OOP rather than global functions.)
> The memory-resident SQLite idea is interesting, but I think it is outside the > scope of this RFC. This proposal is intentionally a small key/value and static > state facility first.
Oh totally. It's not something that belongs in this RFC directly. I just want to see if there's a path to extend it from here in a future RFC to enable such functionality. (I have no idea what that would look like off hand.) --Larry Garfield

zeriyoshi

105 days ago
2026年5月19日(火) 2:14 Larry Garfield <larry@garfieldtech.com>:
> On Mon, May 18, 2026, at 6:00 AM, Go Kudo wrote: > > > Hi Larry, > > > > Thank you for the detailed feedback. > > > > I updated the RFC and the implementation to address the concrete API and > > documentation issues you pointed out. > > > > https://wiki.php.net/rfc/opcache_static_cache > > > > The main changes are: > > > > - `persistent_atomic_decrement()` now creates a missing key with -$step, > > matching `persistent_atomic_increment()`. > > +1 > > > - `volatile_lock()` and `persistent_lock()` now have matching > > `*_unlock()` > > functions. > > I should note that this is an excellent example of where context managers > and a `using` block would be helpful. :-) > > > - The lock APIs also accept an optional lease value, so abandoned > > builder > > reservations can expire even in persistent-worker environments. > > +1 > > > - The status APIs now return a read-only `OPcache\StaticCacheInfo` > > object > > instead of arrays. > > +1 > > Though it's not entirely obvious to me which property I would need to > check every single time I want to try storing to it. enabled? available? > What's the fully safe read/write code pattern here? > > > - The RFC now documents that both static-cache backends are scoped to > > the > > lifetime of the current OPcache static-cache shared-memory segment, > > and are > > not durable storage. > > Ah, that's a big and important distinction! What does "lifetime of the > current opcace static-cache shared memory segment" mean to developers who > don't read this list? :-) Does it mean "persistent" also goes away if you > restart FPM/Apache/whatever? Does that mean this is all basically useless > for CLI? Those should be made very clear in non-internals-speak. > > So really, the distinction is more whether there's a TTL and eviction > strategy, or if the eviction strategy is just "fall over and die." In that > case, I'm not sure if "persistent" is even the right name for that part of > the API, as it's not, well, persistent. > > > - The RFC now describes when `__serialize()` and `__unserialize()` are > > called, and > > that userland serialization keeps those object graphs off the fastest > > direct/shared-graph path. > > I want to make sure I follow here. > > class Test { > pubic string $a; > public string $b; > } > > $t = new Test(); > > persistent_store('t', $t); > > // Later > > $loadedT = persistent_fetch('t'); > > If Test does not have serialize/unserialize magic methods, then the object > is stored "as is" and $t === $loadedT. If it does implement those methods, > then it behaves like $loadedT = unserialize(serialize($t)); > > Is that correct? (Please clarify with examples in the RFC either way.) > > > - The publication rules for `VolatileStatic immediate` mode, > > `VolatileStatic > > tracking` mode, and `PersistentStatic` have been expanded. > > - `VolatileStatic tracking` is now documented as publishing at PHP > > request > > shutdown, not process shutdown, even under FPM/FrankenPHP/persistent > > workers. > > +1 > > > Some of the broader points are open questions or design tradeoffs rather > than > > direct fixes. > > > > On the default-off setting, I want to be more direct about where I > actually > > stand. I would prefer this feature to be default-on. Administrator opt-in > > noticeably limits library adoption, and that significantly reduces the > value > > of the API as a portable primitive that libraries can rely on being > present. > > > > What is holding me back is the shared-hosting case. The cache is a single > > shared-memory trust domain, so on a host where multiple tenants share one > > OPcache segment, default-on without an isolation story could expose those > > tenants to each other through the static cache. I do not yet have a > design I > > am confident makes default-on safe in that environment, and that is the > only > > reason the RFC currently ships with the feature disabled. > > > > I would genuinely appreciate your input here. If you (or anyone on the > list) > > see a viable path — per-pool / per-SAPI segments, a trust-domain or > namespace > > mechanism enforced by the engine, a configuration model where the host > opts > > in per vhost rather than per server, or something I have not considered > — I > > would be very happy to rework the proposal around it. If we can land on a > > model that is safe for shared hosting, I would gladly flip the default in > > this RFC rather than defer it to a follow-up. > > I suppose the question I'd ask here is how much of a factor is that these > days? Shared hosting was a huge part of PHP's early years, but... I don't > remember the last time I actually ran on a traditional shared hosting setup > with shared opcache between different tenants. How much of the market even > is that these days? (I have no idea.) > > > For disabled backends, the explicit APIs fail rather than silently > pretending > > to be a miss-only cache. Attribute-backed state falls back to ordinary > request > > local static behavior when the corresponding backend is unavailable. I > can add > > a short FAQ entry for this development-mode behavior if that would make > the RFC > > clearer. > > Yes please. > > > For multi-library key collisions, the explicit cache remains a shared > namespace > > and applications/libraries still need key prefixes, similar to APCu. The > new > > attribute deletion and exact-key deletion support should reduce the need > for > > whole-backend clears, but this RFC does not add a separate namespace > mechanism. > > I left the broader trust-domain/namespace question as an Open Issue, and > it is > > closely related to the shared-hosting question above. > > I don't have a good answer other than introducing "pools", which would > likely make the API much more involved. That could be good, though, I'm > not sure. (Esp. if it pushes the API toward OOP rather than global > functions.) > > > The memory-resident SQLite idea is interesting, but I think it is > outside the > > scope of this RFC. This proposal is intentionally a small key/value and > static > > state facility first. > > Oh totally. It's not something that belongs in this RFC directly. I just > want to see if there's a path to extend it from here in a future RFC to > enable such functionality. (I have no idea what that would look like off > hand.) > > --Larry Garfield >
Hi Larry, Benjamin, Thanks for the further round of comments. Benjamin, your Reddit pointer ended up being more useful than I first thought; I'll come back to it. I'll push RFC v1.2 shortly with the following changes. #### Renaming `PersistentStatic` → `PinnedStatic` (and the matching API and INI directive) You're right that "persistent" is misleading. The data is not durable in any disk-persistence sense; it lives only as long as the OPcache static-cache shared-memory segment, which is destroyed when its owner exits. "Pinned" captures the actual property: these entries are not evictable and have no TTL, but they only exist in memory. The new spellings are: - Attribute: `#[OPcache\PinnedStatic]` - API: `OPcache\pinned_store()`, `pinned_fetch()`, `pinned_lock()`, `pinned_unlock()`, `pinned_atomic_increment()`, `pinned_atomic_decrement()`, `pinned_cache_info()`, etc. - INI: `opcache.static_cache.pinned_size_mb` - Status object: `OPcache\StaticCacheInfo` for both backends (unchanged shape; only the accessor on `opcache_get_status()` is renamed) - Internal key prefixes: `pinned_static:` and `pinned_static_class:` - Exception: `OPcache\StaticCacheException` (unchanged) You're also correct that CLI use is mostly pointless for both backends. The pinned cache and the volatile cache both die at CLI process exit, the same way APCu and OPcache itself do today. The feature is aimed at long-lived SAPIs (FPM, FrankenPHP, PHP embed users, etc.) where the shared-memory owner outlives a single request, and the RFC now states that explicitly. #### Default INI values changed to 8 MiB each (was 0) After your default-off feedback and a closer look at how PHP is actually hosted today, I'm flipping the defaults. Both `opcache.static_cache.volatile_size_mb` and `opcache.static_cache.pinned_size_mb` will default to 8, which is the documented minimum. Administrators can still disable either backend by setting it to 0 explicitly. The shared-hosting concern that kept me on default-off turned out to be handled by the implementation, which I hadn't actually checked from that angle. The static-cache SHM is allocated through OPcache's existing `mmap(MAP_SHARED | MAP_ANONYMOUS)` handler in the FPM master's MINIT (or the equivalent SAPI startup point), before worker fork. The resulting mapping is anonymous, so unrelated processes cannot attach to it; only descendants of the FPM master that created it inherit access. So "one FPM master = one trust domain" is enforced by the kernel, not by the RFC. That lines up with how PHP is hosted today: - VPS, dedicated, and containerised single-tenant deployments are already one trust domain. - CloudLinux PHP Selector, the de facto standard for modern shared hosting, gives each user their own alt-php binary, so each user runs under their own master with their own SHM segment. - Per-pod / per-tenant Kubernetes or Docker deployments isolate the process tree and IPC, so there is no cross-tenant SHM. - Managed WordPress and similar managed-application platforms run each site under its own PHP process or container. The remaining edge case is the traditional cPanel default where multiple cPanel users share one ea-php-fpm master per PHP version. That configuration already has the same exposure for `opcache_get_status()`, which is why it is conventionally disabled there. The operational answer is the same: either set the static-cache backends to 0 in `php.ini`, or migrate to per-user PHP binaries via PHP Selector. I'll cover this in a migration-notes / shared-hosting section so admins running affected configurations have a clear instruction. Full disclosure: I haven't touched a multi-tenant shared host in nearly twenty years, so I'm not really in a position to judge the current landscape. I had assumed the legacy "all tenants share one PHP master" model was still common; it isn't, and PHP Selector has quietly become the standard while I wasn't paying attention. Thanks for pushing me to actually look it up. :-) Benjamin, your Reddit pointer to the APCu C-level pool-isolation patch was a useful reference point, even though the per-pool C-level hook approach itself is out of scope for this RFC. It also confirmed that the industry has settled on engine- or kernel-level isolation rather than userland prefix conventions, which lines up with the per-FPM-master story above. #### `StaticCacheInfo` "is this backend usable right now?" pattern Fair point. The current text doesn't make this obvious. v1.2 will include a polished `StaticCacheInfo` shape with proper documentation and the recommended check pattern. The field to test before any store/fetch attempt is `available`. It is `true` only when the backend is configured, started up successfully, and the SHM segment is initialised. `enabled` reports configured non-zero memory only and does not imply usability. A code example will accompany the property table. **`__serialize()` / `__unserialize()` behaviour** I'll add the concrete example you sketched to the Storable Values / serialization section. The summary in your reply matches the implementation: - A class with no userland serialization hooks goes through the fast shared-graph / direct-restore path. A successful round trip yields a freshly cloned but structurally equal graph, so the loaded value is `==`-equal to the stored one but, for object-bearing graphs, not `===`, because each fetch returns its own independent clone of the request-local prototype. - A class that defines `__serialize()` / `__unserialize()` is taken off the fast path. The semantics are equivalent to `unserialize(serialize($value))`: those hooks are called, but outside the cache read/write lock. ## Development-mode behaviour FAQ Will add. Disabled backends report `available = false` on the status object, so the recommended idiom (test `available` before storing) also covers the "framework wants to bypass cache in dev" case, since admins or dev-mode `.user.ini` overrides can set the size directive to 0 to disable a backend explicitly. Attribute-backed storage on a disabled backend falls back to ordinary request-local static behaviour, so code annotated with `#[OPcache\VolatileStatic]` or `#[OPcache\PinnedStatic]` keeps working on disabled-backend hosts; the attribute becomes a no-op. #### Multi-library key collisions I'll leave this in Open Issues for now. The class-name and exact-key deletion paths added in v1.1 already cover the most common "rebuild my library's keys" need without a full clear, but a true namespace/pool primitive is a larger design conversation than this RFC should take on. #### Memory-resident SQLite Agreed, that's well outside this RFC's scope. The static-cache backends use OPcache-managed SHM through the existing shared-memory handler abstraction, so in principle other engine-internal subsystems could one day reuse that infrastructure for a memory-resident SQLite or similar. That's a topic for a future RFC; I'll note it in Future Scope without committing to it. Best regards, Go Kudo

zeriyoshi

104 days ago
2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>:
> Hi internals, > > I'd like to start the discussion for a new RFC, OPcache Static Cache. > > RFC: https://wiki.php.net/rfc/opcache_static_cache > Implementation: https://github.com/php/php-src/pull/22052 > > The proposal adds an OPcache-managed shared-memory cache for explicit > userland values and for selected PHP static state. It introduces explicit > functions under the OPcache namespace (volatile_* and persistent_*) and two > attributes, #[OPcache\VolatileStatic] and #[OPcache\PersistentStatic], that > let selected static properties and method static variables survive across > requests. The feature is disabled by default and only activates once memory > is allocated through the new INI directives. > > The RFC covers the motivation, the deliberate split between the two > backends, the trust model (one PHP runtime = one trust domain; this is not > a tenant isolation boundary), and benchmarks against APCu on NTS php-fpm > and ZTS FrankenPHP. The PR is the full implementation, with PHPT coverage > summarized in the Validation section. > > One thing to flag on the implementation status: the Windows build is > currently broken. I don't have a Windows development environment available > yet — one is being arranged through work, and I'll get the Windows side > fixed once that's in place. > > Feedback welcome. > > Best Regards, > Go Kudo >
Hi internals, A couple of updates on this one. Most of Larry's feedback from earlier in the thread is now folded into v1.2.1: unlock/lease API, StaticCacheInfo as a readonly object, atomic_decrement creating missing keys, per-class and per-key attribute deletion, plus __serialize/__unserialize and reboot-purge documentation, among other things. Larry - if anything in there still feels off, I'd rather know now than later, so please say so. Two practical updates since v1.1 went out: The Windows build is now working (the original mail flagged it as broken; that's resolved). CI is green across the board. I'd also really appreciate a broader review at this point. The RFC is fairly large and touches OPcache internals, the VM, and JIT-adjacent paths, so independent eyes - on the API shape, the trust-model / default-off question, the static-attribute semantics, the implementation, anything at all - would be genuinely valuable. RFC: https://wiki.php.net/rfc/opcache_static_cache Implementation: https://github.com/php/php-src/pull/22052 Thanks! Best regards, Go Kudo

zeriyoshi

98 days ago
2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>:
> Hi internals, > > I'd like to start the discussion for a new RFC, OPcache Static Cache. > > RFC: https://wiki.php.net/rfc/opcache_static_cache > Implementation: https://github.com/php/php-src/pull/22052 > > The proposal adds an OPcache-managed shared-memory cache for explicit > userland values and for selected PHP static state. It introduces explicit > functions under the OPcache namespace (volatile_* and persistent_*) and two > attributes, #[OPcache\VolatileStatic] and #[OPcache\PersistentStatic], that > let selected static properties and method static variables survive across > requests. The feature is disabled by default and only activates once memory > is allocated through the new INI directives. > > The RFC covers the motivation, the deliberate split between the two > backends, the trust model (one PHP runtime = one trust domain; this is not > a tenant isolation boundary), and benchmarks against APCu on NTS php-fpm > and ZTS FrankenPHP. The PR is the full implementation, with PHPT coverage > summarized in the Validation section. > > One thing to flag on the implementation status: the Windows build is > currently broken. I don't have a Windows development environment available > yet — one is being arranged through work, and I'll get the Windows side > fixed once that's in place. > > Feedback welcome. > > Best Regards, > Go Kudo >
Hi internals, I made a minor clarification update to the OPcache Static Cache RFC. The RFC is now version 1.3.0. I removed the Open Issues section and folded the former pool/namespace item into the Security and Trust Model and Future Scope. The updated text clarifies that one OPcache static-cache shared-memory segment is one trust domain, and that OPcache Static Cache is not a tenant-isolation boundary. Deployments that run mutually untrusted tenants under a single PHP master should disable the static-cache backends for that master, or use separate PHP/FPM master processes, containers, virtual machines, or equivalent OS-level isolation. This does not change the proposed API, INI directives, default values, implementation semantics, or voting choices. Unless new relevant and substantive issues are raised during the remaining cooldown period, I intend to send an Intent to Vote and proceed to the voting phase once the cooldown has elapsed. RFC: https://wiki.php.net/rfc/opcache_static_cache Discussion thread: https://externals.io/message/130912 Best regards, Go Kudo

zeriyoshi

92 days ago
2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>:
> Hi internals, > > I'd like to start the discussion for a new RFC, OPcache Static Cache. > > RFC: https://wiki.php.net/rfc/opcache_static_cache > Implementation: https://github.com/php/php-src/pull/22052 > > The proposal adds an OPcache-managed shared-memory cache for explicit > userland values and for selected PHP static state. It introduces explicit > functions under the OPcache namespace (volatile_* and persistent_*) and two > attributes, #[OPcache\VolatileStatic] and #[OPcache\PersistentStatic], that > let selected static properties and method static variables survive across > requests. The feature is disabled by default and only activates once memory > is allocated through the new INI directives. > > The RFC covers the motivation, the deliberate split between the two > backends, the trust model (one PHP runtime = one trust domain; this is not > a tenant isolation boundary), and benchmarks against APCu on NTS php-fpm > and ZTS FrankenPHP. The PR is the full implementation, with PHPT coverage > summarized in the Validation section. > > One thing to flag on the implementation status: the Windows build is > currently broken. I don't have a Windows development environment available > yet — one is being arranged through work, and I'll get the Windows side > fixed once that's in place. > > Feedback welcome. > > Best Regards, > Go Kudo >
Hi internals, I plan to open voting on the OPcache Static Cache RFC no earlier than 2026-06-04 00:00 UTC, unless new relevant and substantive issues are raised before then. RFC: https://wiki.php.net/rfc/opcache_static_cache Discussion thread: https://externals.io/message/130912 The RFC is currently at version 1.3.0. Since the initial announcement, the main changes are: - the strict non-evictable backend has been renamed from "persistent" to "pinned", including the API, attribute, INI directive, and status names; - both backends now default to 8 MiB, with 0 as the explicit opt-out value; - explicit cache operation failures now return false by default, with $throw_on_error = true available to throw OPcache\StaticCacheException; - the lock APIs now have explicit unlock functions and optional leases; - status APIs now return OPcache\StaticCacheInfo objects, with StaticCacheInfo::$available documented as the recommended usability check; - attribute-backed entries can be deleted by loaded class name or by documented exact static-state keys; - serialization-hook behavior, development-mode behavior, CLI usefulness, and the shared-hosting / trust-domain model have been clarified. The proposed vote consists of four primary votes, each requiring a 2/3 majority: - Add the explicit volatile cache API, OPcache\volatile_*. - Add the explicit pinned cache API, OPcache\pinned_*. - Add the #[OPcache\VolatileStatic] attribute. - Add the #[OPcache\PinnedStatic] attribute. I would especially appreciate final review from people familiar with OPcache, the VM, and JIT-related code paths before voting opens. The areas where focused feedback would still be most useful are: - the shared-memory lifetime and trust-domain model; - request-local lookup/prototype behavior and shared-graph pinning; - value preparation and fetch reconstruction outside cache locks; - static-property and method-static restore/publication semantics; - tracked array/object mutation hooks; - the JIT static-property access changes. If there are any API, semantic, security, or implementation concerns that should prevent the vote from opening, please raise them in this thread before the planned voting time. Best regards, Go Kudo

Jakub Zelenka

92 days ago
Hi, On Mon, Jun 1, 2026 at 8:06 AM Go Kudo <zeriyoshi@gmail.com> wrote:
> > > 2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>: > >> Hi internals, >> >> I'd like to start the discussion for a new RFC, OPcache Static Cache. >> >> RFC: https://wiki.php.net/rfc/opcache_static_cache >> Implementation: https://github.com/php/php-src/pull/22052 >> > >
The FPM shared hosting part is a problem and I don't think this can be default and probably cannot even be optional. The reason is that we consider data leaks between pools as security issues so I don't think we can have some feature that is actually causing a security issue. It will be a bit tricky to decide what to do if this passes in the current form because we would probably need to apply security fix and disable it. If you really want to have it enabled, we would need to explicitly state in the policy and docs that pool boundary is no longer considered as a security boundary which would be quite problematic for some shared hosting that rely on it. Maybe the solution would be to allow it only if there is one pool enabled. Kind regards, Jakub

zeriyoshi

92 days ago
2026年6月1日(月) 16:30 Jakub Zelenka <bukka@php.net>:
> Hi, > > On Mon, Jun 1, 2026 at 8:06 AM Go Kudo <zeriyoshi@gmail.com> wrote: > >> >> >> 2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>: >> >>> Hi internals, >>> >>> I'd like to start the discussion for a new RFC, OPcache Static Cache. >>> >>> RFC: https://wiki.php.net/rfc/opcache_static_cache >>> Implementation: https://github.com/php/php-src/pull/22052 >>> >> >> > The FPM shared hosting part is a problem and I don't think this can be > default and probably cannot even be optional. The reason is that we > consider data leaks between pools as security issues so I don't think we > can have some feature that is actually causing a security issue. It will be > a bit tricky to decide what to do if this passes in the current form > because we would probably need to apply security fix and disable it. If you > really want to have it enabled, we would need to explicitly state in the > policy and docs that pool boundary is no longer considered as a security > boundary which would be quite problematic for some shared hosting that rely > on it. Maybe the solution would be to allow it only if there is one pool > enabled. > > Kind regards, > > Jakub >
Hi Jakub, Larry, internals, Thank you both for the feedback. Updated RFC and Implementation: - https://wiki.php.net/rfc/opcache_static_cache - https://github.com/php/php-src/pull/22052 I want to pause before moving to a vote and make sure we agree on the security/default model first. Jakub raised this after my Intent to Vote mail, and I think it is a substantive security concern rather than something to push through at the last minute. To summarize the two concerns as I understand them: Larry's concern is that if Static Cache is default-off, it becomes much less useful as a portable primitive for libraries. A router, DI container, metadata cache, or framework component cannot reasonably rely on a feature that is usually disabled by default. Jakub's concern is that, in FPM, pool boundaries are treated as security boundaries. If Static Cache creates one shared data channel across all pools in one FPM master, then it can turn a default-on feature into a cross-pool data leak. That is not acceptable as a PHP/FPM security model. I agree with both points. My previous model was "one OPcache Static Cache shared-memory segment is one trust domain". That was clear, but it did not solve the FPM case if the same FPM master hosts multiple mutually untrusted pools. It effectively required operators to disable the feature or run separate FPM masters, which is not a satisfying answer if the feature is default-enabled. I have therefore changed the implementation direction for FPM. The FPM master no longer creates one global Static Cache backend shared by all pools. Instead, it creates a separate Static Cache partition for each configured worker pool before children are forked. Each partition owns its own volatile and pinned backends, including the backend header, entry table, allocator state, mutation epoch, lookup/status surface, and lock file. During child initialization, the child activates the partition belonging to its FPM worker pool. The active partition is selected from the FPM worker pool that owns the child. It is not selected from request data, the Host header, SCRIPT_FILENAME, environment variables, cache keys, or userland input. As a result, in FPM: explicit Static Cache APIs operate on the active pool partition; pinned static state is stored and restored from the active pool partition; status reporting is pool-local; explicit Static Cache clearing is pool-local; request-shutdown publication is pool-local; script invalidation and the Static Cache part of reset handling operate on the active pool partition rather than on a global cross-pool backend. I also added an FPM PHPT that starts two pools and verifies that: a value stored with the volatile Static Cache API in pool alpha is not visible from pool beta; a #[OPcache\PinnedStatic] static value initialized in pool alpha is still at the default value in pool beta; after initializing both pools, returning to alpha still observes alpha's own values. I plan to extend the test coverage to status reporting and clear/reset-style operations, so the intended pool-local behavior is covered explicitly. I have also added a new INI directive: opcache.static_cache.allow_unsafe_runtime=0 The default is 0. When this directive is 0, Static Cache is disabled in persistent server runtimes where PHP cannot provide a safe storage partition comparable to the FPM per-pool partitioning described above. In other words, SAPIs where PHP cannot identify or enforce a comparable tenant boundary do not get Static Cache merely because the backend memory size defaults are non-zero. An administrator who intentionally treats such a runtime as a single trust domain can opt in explicitly by setting opcache.static_cache.allow_unsafe_runtime=1. With the default setting, Static Cache is available only for the SAPIs where I think PHP can either provide the required boundary or where the SAPI is not a traditional shared-hosting server runtime: fpm: available by default, because Static Cache is partitioned per FPM worker pool; cli: available by default, because it is not a persistent shared server runtime; phpdbg: available by default, for the same reason as CLI; embed: available by default, because the embedding application owns the runtime and trust boundary. This is important for modern embedded application-server runtimes such as FrankenPHP, where PHP is embedded into a host runtime rather than run as a traditional shared-hosting SAPI. In that model, making Static Cache unavailable by default would significantly reduce its usefulness for the same library-adoption reasons Larry described. For embed, the important caveat is that PHP itself cannot know the multi-tenant policy of the host application. If an embedding application intentionally hosts mutually untrusted tenants inside one persistent embedded PHP runtime, then that embedded runtime is one Static Cache trust domain. Such a host should disable Static Cache for those tenants, or explicitly accept the shared-runtime trust model. For other persistent SAPIs, such as apache2handler, litespeed, and generic cgi-fcgi under an external process manager, Static Cache is unavailable by default unless opcache.static_cache.allow_unsafe_runtime=1 is set. When Static Cache is disabled by this policy, the backend behaves as unavailable: StaticCacheInfo reports that the backend is not available, explicit APIs fail or throw according to the existing error mode, and attribute-backed statics retain normal request-local semantics. This changes the proposal in a meaningful way, so I will update the RFC to version 1.4.0 and treat it as a major RFC change. The previous Intent to Vote is canceled; I will not open voting until the new cooldown has elapsed and a new Intent to Vote has been sent. The remaining question is whether this default/security model is acceptable. I see three possible models: A. Default-on for all SAPIs, with FPM partitioned per pool. This is closest to Larry's library-adoption concern. FPM is the concrete case where PHP has a visible multi-pool boundary inside one persistent master, and it is explicitly isolated. Other SAPIs follow the existing OPcache runtime model: one persistent PHP runtime is one trust domain. B. Default-on only where PHP can provide a safe boundary, or where the SAPI is not a persistent shared-hosting server runtime; explicit administrator opt-in otherwise. This is the model now implemented by the new INI directive. FPM is default-on because PHP can enforce per-pool storage partitioning. CLI and phpdbg are not shared server runtimes. Embed is default-on because the embedding application owns the runtime boundary, and because this is important for embedded application-server runtimes such as FrankenPHP. For apache2handler, litespeed/lsphp, and generic cgi-fcgi under an external process manager, Static Cache is unavailable by default unless the administrator explicitly opts in with opcache.static_cache.allow_unsafe_runtime=1. C. Default-off everywhere, with explicit opt-in. This is the most conservative security model, but it also largely loses the portability benefit Larry is asking for. Libraries would still need to treat Static Cache as an optional acceleration path rather than a primitive they can normally rely on. I do not want to minimize legacy shared-hosting deployments. They still exist, and PHP should not break their security assumptions. At the same time, I think we should be careful not to optimize the default entirely around the most conservative legacy multi-tenant model if doing so makes the feature much less useful for the deployments and libraries that are likely to benefit from it. The hosting landscape has changed significantly. Many modern PHP applications are deployed on VPS, cloud instances, containers, managed application platforms, or per-application FPM pools, and increasingly on application server runtimes as well. In those cases, the relevant runtime is already a single application or a single trust domain. Traditional shared hosting and control-panel hosting are still important, but they are not the only default deployment model we should design around. That is why I would like to avoid C unless it is truly necessary from a security-policy perspective. It protects the most conservative shared-runtime case, but it also imposes a large cost on library and framework adoption. If a library cannot assume that Static Cache is normally available, then the feature becomes much closer to an optional site-specific optimization than to a portable runtime primitive. My current preference is B. It keeps Static Cache default-enabled where PHP can provide a safe storage boundary, especially FPM with per-pool partitions, and it requires an explicit administrator decision where PHP cannot enforce such a boundary. It also keeps Static Cache available by default for CLI/phpdbg and embed-based application server runtimes such as FrankenPHP, where disabling it by default would weaken the intended library-facing use case. This seems to address the concrete FPM shared-hosting issue without making the feature globally default-off. I am also open to A if Jakub and others are comfortable saying that, outside FPM, the persistent PHP runtime/process group is the trust domain, and that shared-hosting deployments that do not treat that runtime as trusted must disable the feature. Conversely, if the security position is that arbitrary Static Cache data must never be default-enabled unless PHP can enforce the tenant boundary, then I think B is the natural fallback rather than C. For documentation, I propose to make the trust model explicit: In FPM, Static Cache storage is separated per FPM worker pool. A Static Cache partition is one trust domain. Outside FPM, Static Cache is scoped to the persistent PHP runtime/process group provided by the SAPI. It is not automatically scoped to virtual hosts, document roots, or OS users unless those are already separated into distinct PHP runtimes by the SAPI or process manager. In persistent server SAPIs where PHP cannot identify or enforce a comparable partition boundary, Static Cache is disabled by default unless opcache.static_cache.allow_unsafe_runtime=1 is set. The embed SAPI is enabled by default because the embedding application owns the runtime boundary, and because this is important for embedded application-server runtimes such as FrankenPHP. If an embed host uses one persistent embedded PHP runtime for mutually untrusted tenants, that host must treat it as one Static Cache trust domain or disable Static Cache. opcache.restrict_api and disable_functions may restrict management APIs, but they are not the primary isolation boundary for attribute-backed Static Cache state. Shared-hosting operators must not enable Static Cache in a runtime shared by mutually untrusted tenants unless that runtime is intentionally treated as a shared trust domain. Jakub: With the FPM per-pool partitioning and the new default-off policy for unsafe persistent runtimes described above, would FPM default-on still be a security concern from your point of view? Also, does the SAPI allow-list (fpm, cli, phpdbg, and embed) seem like the right boundary for allow_unsafe_runtime=0, given the embed/FrankenPHP use case? Larry: Does this compromise preserve enough of the default-on behavior for the library-adoption use case? In particular, FPM remains default-on, CLI/phpdbg remain available, and embed remains available for application-server runtimes such as FrankenPHP, while SAPIs without a PHP-visible tenant boundary require an explicit administrator opt-in. If either of you sees a better model than A/B/C, I would rather adjust the RFC now than try to resolve this during or after voting. Best regards, Go Kudo

Nicolas Grekas

92 days ago
Hi, thanks for the reminder and for the RFC. Le lun. 1 juin 2026 à 09:32, Jakub Zelenka <bukka@php.net> a écrit :
> Hi, > > On Mon, Jun 1, 2026 at 8:06 AM Go Kudo <zeriyoshi@gmail.com> wrote: > >> >> >> 2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>: >> >>> Hi internals, >>> >>> I'd like to start the discussion for a new RFC, OPcache Static Cache. >>> >>> RFC: https://wiki.php.net/rfc/opcache_static_cache >>> Implementation: https://github.com/php/php-src/pull/22052 >>> >> >> > The FPM shared hosting part is a problem and I don't think this can be > default and probably cannot even be optional. The reason is that we > consider data leaks between pools as security issues so I don't think we > can have some feature that is actually causing a security issue. It will be > a bit tricky to decide what to do if this passes in the current form > because we would probably need to apply security fix and disable it. If you > really want to have it enabled, we would need to explicitly state in the > policy and docs that pool boundary is no longer considered as a security > boundary which would be quite problematic for some shared hosting that rely > on it. Maybe the solution would be to allow it only if there is one pool > enabled. >
I agree with Jakub on this one, this should be safe by default, which means at the minimum setting the default to 0. But that'd mean we couldn't reliably build on the expectation that people have this feature enabled, which would be a shame to me as a lib author :) I'd rather suggest we find a way to scope per-pool (and also ini-configure per-pool). APCu doesn't have this scope isolation, but APCu is opt-in so not really a concern there. Can't we have per-pool SHM segments? I also have concerns about other parts: **Attributes** I was wondering why some keys have to be reserved (FQCN and two prefixes). IIUC, this is for attributes to work. This looks like an abstraction leak to me. Then I dug the implementation a bit and it looks like a significant chunk of the complexity is for making attributes work (e.g. JIT stuff, new VM hooks, CacheStrategy::Tracking machinery). I feel like this belongs to a follow up RFC. The rest is significant enough to be discussed on its own. **Serialization / data representation** Part of why APCu is slow is that it serializes all values and puts the resulting strings in SHM, which defeats a lot of possible optimizations (interned string pointers, immutable arrays, etc). It's nice that you're proposing a new way to address this. After digging in though, my main suggestion is to restrict the storage to scalars and arrays of scalars only (enums being the one exception maybe), and to leave the data representation as a separate concern: no references, no objects, no resources. If anyone wants to put a more complex PHP value in there, it becomes their responsibility to serialize() it first, or to use something like the deepclone extension I introduced a few weeks ago [1], which provides the exact same semantics as serialize but returns pure arrays of scalars. This decouples the "data representation / serialization" topic from the storage itself (opcache here, something else in my use case). I'm proposing this because every issue I found in the object handling points back to it being a lot of surface for not much gain: - the fast path doesn't handle references (neither soft = two variables pointing at the same object, nor hard = `&`). It doesn't corrupt them, but it silently falls back to full serialization for the whole value as soon as one is present. So a single `&` or one shared object instance anywhere in a large value gets zero benefit and pays APCu-level unserialize cost on every fetch, invisibly. I'd rather reject hard refs explicitly (like resources) and represent shared object identity properly, but honestly scalars-only sidesteps the whole thing. - the engine already provides serialization hooks for internal objects. You add a new mechanism to clone them faster, with a fallback on the existing serialization infra. That's interesting, but it's yet another mechanism to maintain, while the serialization hooks themselves took many versions to get right on php-src (not a good signal with the state of the extensions ecosystem...). __serialize already returns a plain array that's easy to traverse, so it could fit properly without a parallel protocol. Scalars-only removes the need for any of this (and with it the SPL coupling). - I also don't like (in APCu too) that a call to store() can throw any kind of exception, since serialization methods can throw anything and the function just rethrows them as-is. It feels like an abstraction leak. With scalars-only there's no serialization in the storage layer, so this goes away by construction. So: would you consider restricting to scalars / arrays-of-scalars (not the deepclone part, just the type restriction)? It makes the storage do what it does well and keeps representation as a separate concern. It'd be best IMHO, and it deletes a large chunk of the complexity above in one move. [1] https://github.com/symfony/php-ext-deepclone **API** 27 functions is a lot, with many of them being variants of the same base API. Also, the $throw_on_error part is something we'd rather not have IMHO. What about an OOP API instead? Here is a quick draft: ```php namespace OPcache; // Values are scalars or arrays of scalars; callers serialize anything richer themselves. // // Error model for every method: // misses and lock contention are normal and never throw // get() miss -> $default ; has() miss -> false ; lock() contended -> false // real errors always throw CacheException (no per-call flag) // unstorable value, backend disabled/unavailable, pinned exhausted interface CacheInterface { public function get(string $key, mixed $default = null): mixed; public function getMultiple(iterable $keys, mixed $default = null): array; public function set(string $key, mixed $value): bool; public function setMultiple(iterable $values): bool; public function has(string $key): bool; public function delete(string $key): bool; public function deleteMultiple(iterable $keys): bool; public function clear(): bool; public function lock(string $key, int $lease = 0): bool; // lease 0 = until rshutdown public function unlock(string $key): bool; public function info(): CacheInfo; } // TTL only where it is meaningful class VolatileCache implements CacheInterface { public function set(string $key, mixed $value, int $ttl = 0): bool; public function setMultiple(iterable $values, int $ttl = 0): bool; } // atomics only where entries never expire class PinnedCache implements CacheInterface { public function increment(string $key, int $step = 1): int; public function decrement(string $key, int $step = 1): int; } final readonly class CacheInfo { /* [...] */ } class CacheException extends \Exception {} function volatile_cache(): VolatileCache {} // process-wide singleton per backend function pinned_cache(): PinnedCache {} ``` **API still** I read your arguments for the non-volatile API, yet I'm wondering if that makes sense at all. I understand the motivation, but is this really worth all the challenges it brings (see above: serialization, SHM management, pool scoping, ini settings, etc), when the alternative already exists and doesn't have any of these? By alternative I mean what we do today: generate PHP code that contains the pinned values, and rely on opcache to cache them. What we miss in the engine is the volatile API. A better APCu. But the pinned API we might not need one. The only thing is the increment/decrement part, and I'm not sure it's enough reason to keep it. Maybe another approach could provide this in a simpler way? Worth noting too: the per-pool / SHM / ini concerns from my first point apply entirely to pinned and only partly to volatile, so dropping pinned also shrinks the blocking security surface, not just the API. Overall, I'd really like a better APCu to be provided by default, so thanks for pushing for this! Cheers, Nicolas

zeriyoshi

92 days ago
2026年6月1日(月) 20:36 Nicolas Grekas <nicolas.grekas+php@gmail.com>:
> Hi, thanks for the reminder and for the RFC. > > Le lun. 1 juin 2026 à 09:32, Jakub Zelenka <bukka@php.net> a écrit : > >> Hi, >> >> On Mon, Jun 1, 2026 at 8:06 AM Go Kudo <zeriyoshi@gmail.com> wrote: >> >>> >>> >>> 2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>: >>> >>>> Hi internals, >>>> >>>> I'd like to start the discussion for a new RFC, OPcache Static Cache. >>>> >>>> RFC: https://wiki.php.net/rfc/opcache_static_cache >>>> Implementation: https://github.com/php/php-src/pull/22052 >>>> >>> >>> >> The FPM shared hosting part is a problem and I don't think this can be >> default and probably cannot even be optional. The reason is that we >> consider data leaks between pools as security issues so I don't think we >> can have some feature that is actually causing a security issue. It will be >> a bit tricky to decide what to do if this passes in the current form >> because we would probably need to apply security fix and disable it. If you >> really want to have it enabled, we would need to explicitly state in the >> policy and docs that pool boundary is no longer considered as a security >> boundary which would be quite problematic for some shared hosting that rely >> on it. Maybe the solution would be to allow it only if there is one pool >> enabled. >> > > I agree with Jakub on this one, this should be safe by default, which > means at the minimum setting the default to 0. But that'd mean we couldn't > reliably build on the expectation that people have this feature enabled, > which would be a shame to me as a lib author :) I'd rather suggest we find > a way to scope per-pool (and also ini-configure per-pool). APCu doesn't > have this scope isolation, but APCu is opt-in so not really a concern > there. Can't we have per-pool SHM segments? > > I also have concerns about other parts: > > **Attributes** > > I was wondering why some keys have to be reserved (FQCN and two prefixes). > IIUC, this is for attributes to work. This looks like an abstraction leak > to me. Then I dug the implementation a bit and it looks like a significant > chunk of the complexity is for making attributes work (e.g. JIT stuff, new > VM hooks, CacheStrategy::Tracking machinery). I feel like this belongs to a > follow up RFC. The rest is significant enough to be discussed on its own. > > **Serialization / data representation** > > Part of why APCu is slow is that it serializes all values and puts the > resulting strings in SHM, which defeats a lot of possible optimizations > (interned string pointers, immutable arrays, etc). It's nice that you're > proposing a new way to address this. > > After digging in though, my main suggestion is to restrict the storage to > scalars and arrays of scalars only (enums being the one exception maybe), > and to leave the data representation as a separate concern: no references, > no objects, no resources. If anyone wants to put a more complex PHP value > in there, it becomes their responsibility to serialize() it first, or to > use something like the deepclone extension I introduced a few weeks ago > [1], which provides the exact same semantics as serialize but returns pure > arrays of scalars. This decouples the "data representation / serialization" > topic from the storage itself (opcache here, something else in my use case). > > I'm proposing this because every issue I found in the object handling > points back to it being a lot of surface for not much gain: > > - the fast path doesn't handle references (neither soft = two variables > pointing at the same object, nor hard = `&`). It doesn't corrupt them, but > it silently falls back to full serialization for the whole value as soon as > one is present. So a single `&` or one shared object instance anywhere in a > large value gets zero benefit and pays APCu-level unserialize cost on every > fetch, invisibly. I'd rather reject hard refs explicitly (like resources) > and represent shared object identity properly, but honestly scalars-only > sidesteps the whole thing. > > - the engine already provides serialization hooks for internal objects. > You add a new mechanism to clone them faster, with a fallback on the > existing serialization infra. That's interesting, but it's yet another > mechanism to maintain, while the serialization hooks themselves took many > versions to get right on php-src (not a good signal with the state of the > extensions ecosystem...). __serialize already returns a plain array that's > easy to traverse, so it could fit properly without a parallel protocol. > Scalars-only removes the need for any of this (and with it the SPL > coupling). > > - I also don't like (in APCu too) that a call to store() can throw any > kind of exception, since serialization methods can throw anything and the > function just rethrows them as-is. It feels like an abstraction leak. With > scalars-only there's no serialization in the storage layer, so this goes > away by construction. > > So: would you consider restricting to scalars / arrays-of-scalars (not the > deepclone part, just the type restriction)? It makes the storage do what it > does well and keeps representation as a separate concern. It'd be best > IMHO, and it deletes a large chunk of the complexity above in one move. > > [1] https://github.com/symfony/php-ext-deepclone > > **API** > > 27 functions is a lot, with many of them being variants of the same base > API. Also, the $throw_on_error part is something we'd rather not have IMHO. > What about an OOP API instead? > > Here is a quick draft: > > ```php > namespace OPcache; > > // Values are scalars or arrays of scalars; callers serialize anything > richer themselves. > // > // Error model for every method: > // misses and lock contention are normal and never throw > // get() miss -> $default ; has() miss -> false ; lock() contended -> > false > // real errors always throw CacheException (no per-call flag) > // unstorable value, backend disabled/unavailable, pinned exhausted > interface CacheInterface { > public function get(string $key, mixed $default = null): mixed; > public function getMultiple(iterable $keys, mixed $default = null): > array; > public function set(string $key, mixed $value): bool; > public function setMultiple(iterable $values): bool; > public function has(string $key): bool; > public function delete(string $key): bool; > public function deleteMultiple(iterable $keys): bool; > public function clear(): bool; > public function lock(string $key, int $lease = 0): bool; // lease 0 > = until rshutdown > public function unlock(string $key): bool; > public function info(): CacheInfo; > } > > // TTL only where it is meaningful > class VolatileCache implements CacheInterface { > public function set(string $key, mixed $value, int $ttl = 0): bool; > public function setMultiple(iterable $values, int $ttl = 0): bool; > } > > // atomics only where entries never expire > class PinnedCache implements CacheInterface { > public function increment(string $key, int $step = 1): int; > public function decrement(string $key, int $step = 1): int; > } > > final readonly class CacheInfo { /* [...] */ } > class CacheException extends \Exception {} > > function volatile_cache(): VolatileCache {} // process-wide singleton > per backend > function pinned_cache(): PinnedCache {} > ``` > > **API still** > > I read your arguments for the non-volatile API, yet I'm wondering if that > makes sense at all. I understand the motivation, but is this really worth > all the challenges it brings (see above: serialization, SHM management, > pool scoping, ini settings, etc), when the alternative already exists and > doesn't have any of these? By alternative I mean what we do today: generate > PHP code that contains the pinned values, and rely on opcache to cache them. > > What we miss in the engine is the volatile API. A better APCu. But the > pinned API we might not need one. The only thing is the increment/decrement > part, and I'm not sure it's enough reason to keep it. Maybe another > approach could provide this in a simpler way? > > Worth noting too: the per-pool / SHM / ini concerns from my first point > apply entirely to pinned and only partly to volatile, so dropping pinned > also shrinks the blocking security surface, not just the API. > > Overall, I'd really like a better APCu to be provided by default, so > thanks for pushing for this! > > Cheers, > Nicolas > >
Hi Nicolas. Thanks again for the detailed read. A fair amount of this is now addressed in 1.4.0, and you asked for a concrete OOP shape, so let me start with those and then come back to the scalars/references discussion. **Per-pool scoping (your first point)** FPM is solved in 1.4.0. There's now one volatile and one pinned partition per worker pool, created before any worker forks (fpm_static_cache_init_main() walks fpm_worker_all_pools and calls partition_create(wp->config->name) for each pool), and each child activates its own pool's partition in fpm_child_init() before user code runs. A value stored in one pool isn't visible from another pool under the same master, which is the per-pool SHM segment you asked for. Where I'd like your view is the rest. FPM is the only SAPI where PHP has a tenant boundary it can pick before request handling, so it's the only one that gets real per-pool segments. apache2handler, LSAPI, cgi-fcgi and friends have no equivalent pre-request identity to key a partition on, so rather than invent one, 1.4.0 leaves the feature off there unless opcache.static_cache.allow_unsafe_runtime=1. My honest read is that we don't need to chase per-pool isolation for those SAPIs, for two reasons. The shared multi-tenant case under a non-FPM web runtime is off by default, so nobody is silently exposed. And with the 1.4.0 error model, an unavailable backend isn't a hazard for callers: a disabled backend returns false / the default instead of throwing, so libraries that call opportunistically just degrade rather than break. So the cost of *not* having universal per-pool is an admin choice (leave it off, or knowingly accept runtime-wide sharing), not a correctness or safety problem. Do you see a non-FPM deployment where default-off-plus-opt-in isn't enough and a real per-SAPI tenant boundary would actually be needed? If so I'd rather scope it as future work for that specific SAPI than block on it, but I want to know if you think it's load-bearing. **API shape (static classes, no $throw_on_error)** I went a slightly different way from your sketch: two classes with static methods, no instances and no shared interface. namespace OPcache; final class VolatileCache { public static function get(string $key, mixed $default = null): mixed {} public static function getMultiple(iterable $keys, mixed $default = null): array {} public static function set(string $key, mixed $value, int $ttl = 0): bool {} public static function setMultiple(iterable $values, int $ttl = 0): bool {} public static function has(string $key): bool {} public static function delete(string $key): bool {} // exact key, or a loaded class name to drop its attribute-backed state public static function deleteMultiple(iterable $keys): bool {} public static function clear(): bool {} public static function lock(string $key, int $lease = 0): bool {} // single-builder primitive; miss/contention -> false public static function unlock(string $key): bool {} public static function info(): StaticCacheInfo {} } final class PinnedCache { public static function get(string $key, mixed $default = null): mixed {} public static function getMultiple(iterable $keys, mixed $default = null): array {} public static function set(string $key, mixed $value): bool {} // no TTL public static function setMultiple(iterable $values): bool {} public static function has(string $key): bool {} public static function delete(string $key): bool {} public static function deleteMultiple(iterable $keys): bool {} public static function clear(): bool {} public static function lock(string $key, int $lease = 0): bool {} public static function unlock(string $key): bool {} public static function increment(string $key, int $step = 1): int|false {} public static function decrement(string $key, int $step = 1): int|false {} public static function info(): StaticCacheInfo {} } // StaticCacheInfo and StaticCacheException are the existing RFC types, reused as-is. Why static rather than instances: there's exactly one backend per partition and a handle would carry no per-instance state, volatile and pinned aren't interchangeable (different eviction semantics, TTL vs atomics), so there's nothing to gain from passing one around, and the differing set() arity a shared interface would impose (volatile has $ttl, pinned doesn't) is the exact awkwardness the interface would create. Static methods sidestep all of it. Grouping them as VolatileCache:: / PinnedCache:: also answers the "many variants of the same base API" part of your 27-functions point directly, while dropping the volatile_/pinned_ prefix noise. $throw_on_error is gone in this shape, which I agree is better. Misses and contention never throw (get returns the default, getMultiple fills per-key defaults, lock returns false); real backend failures return false / int|false; argument errors (empty key, reserved key, top-level Closure/resource, negative ttl) still raise TypeError/ValueError. StaticCacheException is then only the strict #[PinnedStatic] publication failure. The one thing the flag covered, treating a disabled backend as a hard config error, is a one-line StaticCacheInfo::available check at bootstrap, which the RFC already recommends. This replaces the volatile_*/pinned_* functions rather than adding to them. I could also add VolatileCache::remember($key, $compute, $ttl = 0) wrapping the safe lock -> build-outside-the-lock -> store sequence, since that's the pattern people reach for; happy to include or drop it. If you'd still rather have instances, tell me what they'd buy and I'll reconsider, I just couldn't find a concrete thing here. **Scalars + arrays-of-scalars only** This is the one place I'd push back, because the measurements point the other way. The whole point of the design is to avoid the serialize-on-store / unserialize-on-fetch round trip. If storage only takes scalars and arrays of scalars, anything richer has to be serialize()'d by the caller and unserialize()'d on every read, which is the APCu cost model. So scalars-only doesn't remove that cost, it moves it into userland and makes it mandatory for every object, including the ones the engine can already restore cheaply. Carbon is the clearest case because it defines __serialize/__unserialize, so under a scalars-only rule it's a forced round trip every time. From the 1.4.0 numbers on NTS php-fpm: APCu (serialize + unserialize per fetch): ~189 us VolatileCache::get via the Date/Time safe-direct handler: ~45 us #[VolatileStatic] property (restored once into the slot): ~1.5 us The ~45 us is the relevant number. Carbon keeps its own __serialize, but the Date/Time handler is registered with allows_custom_serializers = true, so a Carbon instance still takes the safe-direct copy path rather than php_var_serialize. Under scalars-only, that ~45 us goes back to the ~189 us round trip and the ~1.5 us attribute path disappears. The other object rows are the same shape: metadata object ~166 us vs ~35 us, SPL collections ~20 us vs ~5.6 us, small DateTime ~2.6 us vs ~1.1 us. So "a lot of surface for not much gain" is the reading I'd disagree with: the gain is the 4x to ~130x, and it exists specifically because the value isn't scalarised. **References and shared identity** You're right about the mechanics and I won't gloss over it. There are three store paths: 1. shared graph: built straight into SHM, fetched with no userland code 2. the OPcache serializer: SHM-safe binary encode, bytes copied under the lock and rebuilt after it's released, still no userland code 3. php_var_serialize fallback A circular array (enter_array sees the same HashTable twice) or a shared object identity (mark_object sees the same zend_object twice) makes paths 1 and 2 bail, and the value lands on path 3. A hard reference inside object state does the same. So a value carrying one of those shapes pays path-3 cost, and today that's silent. Two things on that. First, path 3 is APCu parity, not worse than APCu, so it's a floor rather than a regression. Second, the values that hit it are exactly the values scalars-only would push through serialize()/unserialize() unconditionally, so the current worst case is the normal case under your proposal. The "invisible" part is fair, though. I'd rather make it visible (surface the chosen path in info(), or in a debug build) than ban objects, since banning them gives up the common no-ref case that's the reason for the feature. And I have no real objection to rejecting top-level hard refs up front the way resources are rejected, if people think a silent cliff is worse than an explicit error. That's a small change. On "yet another mechanism vs __serialize": the serializer hooks are still the fallback, not a replacement. The safe-direct tables only cover a fixed, engine-vetted set (Date/Time and four SPL collections), they're registered in C by the owning extension, and nothing is exposed to userland, so it isn't a protocol the ecosystem has to implement or get right. __serialize keeps handling everything else. **Dropping pinned** The preload + generated-array pattern is good, and the RFC treats it as the existing workaround it's trying to formalise, not something to replace. But it has one hard limit: it only works for data you can express as a PHP literal that opcache can intern, i.e. scalars and arrays. As soon as the thing you want to keep across requests is an object graph, that route is back to a serialized string plus a per-request unserialize, which is the cost we started from. That's the gap pinned covers. PinnedStatic on the Carbon shape is ~1.5 us (restored once into the static slot) against ~189 us for the round trip, and there's no preload trick that reaches that number, because preload can't bake a live object graph into an opcode literal. I agree the increment/decrement part isn't enough to justify pinned on its own; the object case is the reason it's there. And with the per-pool partitions now in, pinned lives in the same isolated per-pool segment as volatile, so dropping it no longer shrinks the security surface the way it would have before 1.4.0. Thanks again for the detailed read. Best regards, Go Kudo

Nicolas Grekas

92 days ago
Le lun. 1 juin 2026 à 15:22, Go Kudo <zeriyoshi@gmail.com> a écrit :
> > > 2026年6月1日(月) 20:36 Nicolas Grekas <nicolas.grekas+php@gmail.com>: > >> Hi, thanks for the reminder and for the RFC. >> >> Le lun. 1 juin 2026 à 09:32, Jakub Zelenka <bukka@php.net> a écrit : >> >>> Hi, >>> >>> On Mon, Jun 1, 2026 at 8:06 AM Go Kudo <zeriyoshi@gmail.com> wrote: >>> >>>> >>>> >>>> 2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>: >>>> >>>>> Hi internals, >>>>> >>>>> I'd like to start the discussion for a new RFC, OPcache Static Cache. >>>>> >>>>> RFC: https://wiki.php.net/rfc/opcache_static_cache >>>>> Implementation: https://github.com/php/php-src/pull/22052 >>>>> >>>> >>>> >>> The FPM shared hosting part is a problem and I don't think this can be >>> default and probably cannot even be optional. The reason is that we >>> consider data leaks between pools as security issues so I don't think we >>> can have some feature that is actually causing a security issue. It will be >>> a bit tricky to decide what to do if this passes in the current form >>> because we would probably need to apply security fix and disable it. If you >>> really want to have it enabled, we would need to explicitly state in the >>> policy and docs that pool boundary is no longer considered as a security >>> boundary which would be quite problematic for some shared hosting that rely >>> on it. Maybe the solution would be to allow it only if there is one pool >>> enabled. >>> >> >> I agree with Jakub on this one, this should be safe by default, which >> means at the minimum setting the default to 0. But that'd mean we couldn't >> reliably build on the expectation that people have this feature enabled, >> which would be a shame to me as a lib author :) I'd rather suggest we find >> a way to scope per-pool (and also ini-configure per-pool). APCu doesn't >> have this scope isolation, but APCu is opt-in so not really a concern >> there. Can't we have per-pool SHM segments? >> >> I also have concerns about other parts: >> >> **Attributes** >> >> I was wondering why some keys have to be reserved (FQCN and two >> prefixes). IIUC, this is for attributes to work. This looks like an >> abstraction leak to me. Then I dug the implementation a bit and it looks >> like a significant chunk of the complexity is for making attributes work >> (e.g. JIT stuff, new VM hooks, CacheStrategy::Tracking machinery). I feel >> like this belongs to a follow up RFC. The rest is significant enough to be >> discussed on its own. >> >> **Serialization / data representation** >> >> Part of why APCu is slow is that it serializes all values and puts the >> resulting strings in SHM, which defeats a lot of possible optimizations >> (interned string pointers, immutable arrays, etc). It's nice that you're >> proposing a new way to address this. >> >> After digging in though, my main suggestion is to restrict the storage to >> scalars and arrays of scalars only (enums being the one exception maybe), >> and to leave the data representation as a separate concern: no references, >> no objects, no resources. If anyone wants to put a more complex PHP value >> in there, it becomes their responsibility to serialize() it first, or to >> use something like the deepclone extension I introduced a few weeks ago >> [1], which provides the exact same semantics as serialize but returns pure >> arrays of scalars. This decouples the "data representation / serialization" >> topic from the storage itself (opcache here, something else in my use case). >> >> I'm proposing this because every issue I found in the object handling >> points back to it being a lot of surface for not much gain: >> >> - the fast path doesn't handle references (neither soft = two variables >> pointing at the same object, nor hard = `&`). It doesn't corrupt them, but >> it silently falls back to full serialization for the whole value as soon as >> one is present. So a single `&` or one shared object instance anywhere in a >> large value gets zero benefit and pays APCu-level unserialize cost on every >> fetch, invisibly. I'd rather reject hard refs explicitly (like resources) >> and represent shared object identity properly, but honestly scalars-only >> sidesteps the whole thing. >> >> - the engine already provides serialization hooks for internal objects. >> You add a new mechanism to clone them faster, with a fallback on the >> existing serialization infra. That's interesting, but it's yet another >> mechanism to maintain, while the serialization hooks themselves took many >> versions to get right on php-src (not a good signal with the state of the >> extensions ecosystem...). __serialize already returns a plain array that's >> easy to traverse, so it could fit properly without a parallel protocol. >> Scalars-only removes the need for any of this (and with it the SPL >> coupling). >> >> - I also don't like (in APCu too) that a call to store() can throw any >> kind of exception, since serialization methods can throw anything and the >> function just rethrows them as-is. It feels like an abstraction leak. With >> scalars-only there's no serialization in the storage layer, so this goes >> away by construction. >> >> So: would you consider restricting to scalars / arrays-of-scalars (not >> the deepclone part, just the type restriction)? It makes the storage do >> what it does well and keeps representation as a separate concern. It'd be >> best IMHO, and it deletes a large chunk of the complexity above in one move. >> >> [1] https://github.com/symfony/php-ext-deepclone >> >> **API** >> >> 27 functions is a lot, with many of them being variants of the same base >> API. Also, the $throw_on_error part is something we'd rather not have IMHO. >> What about an OOP API instead? >> >> Here is a quick draft: >> >> ```php >> namespace OPcache; >> >> // Values are scalars or arrays of scalars; callers serialize anything >> richer themselves. >> // >> // Error model for every method: >> // misses and lock contention are normal and never throw >> // get() miss -> $default ; has() miss -> false ; lock() contended -> >> false >> // real errors always throw CacheException (no per-call flag) >> // unstorable value, backend disabled/unavailable, pinned exhausted >> interface CacheInterface { >> public function get(string $key, mixed $default = null): mixed; >> public function getMultiple(iterable $keys, mixed $default = null): >> array; >> public function set(string $key, mixed $value): bool; >> public function setMultiple(iterable $values): bool; >> public function has(string $key): bool; >> public function delete(string $key): bool; >> public function deleteMultiple(iterable $keys): bool; >> public function clear(): bool; >> public function lock(string $key, int $lease = 0): bool; // lease 0 >> = until rshutdown >> public function unlock(string $key): bool; >> public function info(): CacheInfo; >> } >> >> // TTL only where it is meaningful >> class VolatileCache implements CacheInterface { >> public function set(string $key, mixed $value, int $ttl = 0): bool; >> public function setMultiple(iterable $values, int $ttl = 0): bool; >> } >> >> // atomics only where entries never expire >> class PinnedCache implements CacheInterface { >> public function increment(string $key, int $step = 1): int; >> public function decrement(string $key, int $step = 1): int; >> } >> >> final readonly class CacheInfo { /* [...] */ } >> class CacheException extends \Exception {} >> >> function volatile_cache(): VolatileCache {} // process-wide singleton >> per backend >> function pinned_cache(): PinnedCache {} >> ``` >> >> **API still** >> >> I read your arguments for the non-volatile API, yet I'm wondering if that >> makes sense at all. I understand the motivation, but is this really worth >> all the challenges it brings (see above: serialization, SHM management, >> pool scoping, ini settings, etc), when the alternative already exists and >> doesn't have any of these? By alternative I mean what we do today: generate >> PHP code that contains the pinned values, and rely on opcache to cache them. >> >> What we miss in the engine is the volatile API. A better APCu. But the >> pinned API we might not need one. The only thing is the increment/decrement >> part, and I'm not sure it's enough reason to keep it. Maybe another >> approach could provide this in a simpler way? >> >> Worth noting too: the per-pool / SHM / ini concerns from my first point >> apply entirely to pinned and only partly to volatile, so dropping pinned >> also shrinks the blocking security surface, not just the API. >> >> Overall, I'd really like a better APCu to be provided by default, so >> thanks for pushing for this! >> >> Cheers, >> Nicolas >> >> > Hi Nicolas. > > Thanks again for the detailed read. A fair amount of this is now addressed > in 1.4.0, and you asked for a concrete OOP shape, so let me start with > those and then come back to the scalars/references discussion. > > **Per-pool scoping (your first point)** > > FPM is solved in 1.4.0. There's now one volatile and one pinned partition > per worker pool, created before any worker forks > (fpm_static_cache_init_main() walks fpm_worker_all_pools and calls > partition_create(wp->config->name) for each pool), and each child activates > its own pool's partition in fpm_child_init() before user code runs. A value > stored in one pool isn't visible from another pool under the same master, > which is the per-pool SHM segment you asked for. >
That's great thanks.
> Where I'd like your view is the rest. FPM is the only SAPI where PHP has a > tenant boundary it can pick before request handling, so it's the only one > that gets real per-pool segments. apache2handler, LSAPI, cgi-fcgi and > friends have no equivalent pre-request identity to key a partition on, so > rather than invent one, 1.4.0 leaves the feature off there unless > opcache.static_cache.allow_unsafe_runtime=1. >
IMHO "unsafe" wording is too strong: these are safe SAPIs, they just don't have a scoping concept built in. And disabling it by default for them brings back the very concern I raised, that this won't be a generally-available primitive authors can rely on. My take: enable it by default with a single default scope for those SAPIs, plus a clear internal API so a SAPI can define its own scoped segments. I know FrankenPHP would leverage it, and maybe others will find a way (e.g. apache2handler) to expose similar boundaries.
> **API shape (static classes, no $throw_on_error)** > > I went a slightly different way from your sketch: two classes with static > methods, no instances and no shared interface. >
We've been historically against static methods in php when plain functions provide the same.To me it's either instances xor functions.OOP brings abstraction which brings possible IoC, that's the benefit. I'm fine with functions also, but then the duplication is bloating the list of functions. Dunno if that's an issue for others. I proposed just dropping the pinned variants, which kills most of that :) And note: if we also drop object support and pinned (below), the whole thing collapses to a single volatile cache, at which point instances-vs-functions is a small call and either is fine by me.
> $throw_on_error is gone in this shape, which I agree is better. Misses and > contention never throw (get returns the default, getMultiple fills per-key > defaults, lock returns false); real backend failures return false / > int|false; argument errors (empty key, reserved key, top-level > Closure/resource, negative ttl) still raise TypeError/ValueError. > StaticCacheException is then only the strict #[PinnedStatic] publication > failure. The one thing the flag covered, treating a disabled backend as a > hard config error, is a one-line StaticCacheInfo::available check at > bootstrap, which the RFC already recommends. >
Thanks.
> I could also add VolatileCache::remember($key, $compute, $ttl = 0) > wrapping the safe lock -> build-outside-the-lock -> store sequence, since > that's the pattern people reach for; happy to include or drop it. If you'd > still rather have instances, tell me what they'd buy and I'll reconsider, I > just couldn't find a concrete thing here. >
Personally I like this kind of transactional API.
> **Scalars + arrays-of-scalars only** > > This is the one place I'd push back, because the measurements point the > other way. The whole point of the design is to avoid the serialize-on-store > / unserialize-on-fetch round trip. If storage only takes scalars and arrays > of scalars, anything richer has to be serialize()'d by the caller and > unserialize()'d on every read, which is the APCu cost model. So > scalars-only doesn't remove that cost, it moves it into userland and makes > it mandatory for every object, including the ones the engine can already > restore cheaply. > > Carbon is the clearest case because it defines __serialize/__unserialize, > so under a scalars-only rule it's a forced round trip every time. From the > 1.4.0 numbers on NTS php-fpm: > > APCu (serialize + unserialize per fetch): ~189 us > VolatileCache::get via the Date/Time safe-direct handler: ~45 us > #[VolatileStatic] property (restored once into the slot): ~1.5 us > > The ~45 us is the relevant number. Carbon keeps its own __serialize, but > the Date/Time handler is registered with allows_custom_serializers = true, > so a Carbon instance still takes the safe-direct copy path rather than > php_var_serialize. Under scalars-only, that ~45 us goes back to the ~189 us > round trip and the ~1.5 us attribute path disappears. The other object rows > are the same shape: metadata object ~166 us vs ~35 us, SPL collections ~20 > us vs ~5.6 us, small DateTime ~2.6 us vs ~1.1 us. > > So "a lot of surface for not much gain" is the reading I'd disagree with: > the gain is the 4x to ~130x, and it exists specifically because the value > isn't scalarised. >
I went and measured it. I built php-src from your branch (1.4.0) with ext/apcu and my ext/deepclone all compiled in, and timed a warm-cache fetch of the same value. A = APCu (serialize + unserialize). B = your native OPcache\volatile_fetch (warm, so the request-local prototype is built and it just clones). C = the array representation kept as a resident immutable value (what an opcache literal already gives you) + deepclone hydrate. Warm, NTS, us/op: fixture bytes | APCu | B native | C immut-array+hydrate plain object graph (5-deep) 1.7K | 6.66 | 2.52 | 1.79 big object graph (400 objs) 476K | 2358 | 618 | 382 big config array (4k entries) 480K | 1590 | 331 | 0.045 Three things this settles for me: - the "Nx faster than APCu" headline is size-dependent. APCu is 2-7 us for small objects and only reaches the hundreds-of-us range at ~half-a-MB payloads, so the big multiplier is a large-object effect, not the common case. - C (objects-as-arrays + userland hydrate) ties or beats your native path in every warm case I tried, which is the static cache's best case. The in-engine object machinery isn't buying speed over a plain array representation, it's slightly slower than it. - for array data, the dominant config/metadata case, an immutable array is essentially free (0.045 us): a zero-copy read with nothing to hydrate. That's ~7000x faster than the static cache's own array fetch, which pays an O(n) walk per read and so doesn't even deliver the immutable-array win that opcache literals already give. The preload/generated-code path wins this one decisively, without any of the new machinery. The other direction is telling too: in a fetch-once pattern (each key read a single time) the native path is *slower* than APCu, e.g. 38 us vs 7 us on the shared-identity object, because it builds a request-local prototype it never reuses. The prototype only pays off under repeated same-key fetches, which is exactly the in-request registry case I describe below. JIT was off, but the timed work is all C-side so it barely moves the numbers.
> **References and shared identity** > > You're right about the mechanics and I won't gloss over it. There are > three store paths: > > 1. shared graph: built straight into SHM, fetched with no userland code > 2. the OPcache serializer: SHM-safe binary encode, bytes copied under > the lock and rebuilt after it's released, still no userland code > 3. php_var_serialize fallback > > A circular array (enter_array sees the same HashTable twice) or a shared > object identity (mark_object sees the same zend_object twice) makes paths 1 > and 2 bail, and the value lands on path 3. A hard reference inside object > state does the same. So a value carrying one of those shapes pays path-3 > cost, and today that's silent. > > Two things on that. First, path 3 is APCu parity, not worse than APCu, so > it's a floor rather than a regression. Second, the values that hit it are > exactly the values scalars-only would push through > serialize()/unserialize() unconditionally, so the current worst case is the > normal case under your proposal. > > The "invisible" part is fair, though. I'd rather make it visible (surface > the chosen path in info(), or in a debug build) than ban objects, since > banning them gives up the common no-ref case that's the reason for the > feature. And I have no real objection to rejecting top-level hard refs up > front the way resources are rejected, if people think a silent cliff is > worse than an explicit error. That's a small change. >
"top-level hard ref" confuses me: it sounds like store($var) where $var is a reference, but the parameter isn't by-ref, so the engine doesn't pass the reference through anyway. A problematic hard ref is always nested, self-referencing a sub-part of the passed graph, which is exactly the shape you can't cheaply reject up front. But step back on what the fallback means: it triggers in cases that are hard to anticipate, so in practice this is APCu-level perf much of the time. The same object reachable from two places in a graph is not an exceptional shape.
> On "yet another mechanism vs __serialize": the serializer hooks are still > the fallback, not a replacement. The safe-direct tables only cover a fixed, > engine-vetted set (Date/Time and four SPL collections), they're registered > in C by the owning extension, and nothing is exposed to userland, so it > isn't a protocol the ecosystem has to implement or get right. __serialize > keeps handling everything else. > > **Dropping pinned** > > The preload + generated-array pattern is good, and the RFC treats it as > the existing workaround it's trying to formalise, not something to replace. > But it has one hard limit: it only works for data you can express as a PHP > literal that opcache can intern, i.e. scalars and arrays. As soon as the > thing you want to keep across requests is an object graph, that route is > back to a serialized string plus a per-request unserialize, which is the > cost we started from. > > That's the gap pinned covers. PinnedStatic on the Carbon shape is ~1.5 us > (restored once into the static slot) against ~189 us for the round trip, > and there's no preload trick that reaches that number, because preload > can't bake a live object graph into an opcode literal. I agree the > increment/decrement part isn't enough to justify pinned on its own; the > object case is the reason it's there. And with the per-pool partitions now > in, pinned lives in the same isolated per-pool segment as volatile, so > dropping it no longer shrinks the security surface the way it would have > before 1.4.0. >
All these items above are variants of the same need for a solution that'd allow passing objects through the API. I think this should be dropped. I get it can feel convenient to bring this, but not at all costs. All things discussed above come down to addressing this need, at the cost a significant abstraction leak (exceptions thrown by userland serialization hooks), duplicate functions for pinned/non-pinned, magic behavior that breaks the advertised perf benefits compared to existing solutions (the serialize fallback), etc.I worked a lot on this topic in the previous years, the symfony/var-exporter component was built for this need: conveying objects using arrays. This proves that yes, current immutable arrays are perfectly able to describe objects, provided one uses a conversion layer on top of them. (BTW you tie this to preloading, but that's not accurate: you don't need preload to get immutable arrays, opcache interns array literals from any cached file; preload only saves the recompile.) The mechanism described in the RFC brings something new to me: the per-request unserialize-once, copy-many mechanism. It's an optimization that prevents unserializing many times in the same request.This is nice, but I'm doubtful it justifies the added complexity on its own: in a single request, it's quite easy for libraries to wrap the cache backend and keep a live registry of unserialized objects for the duration of the request.That's already what most libs do, since that saves doing round trips to the cache backend in the same request. To me this is an already solved problem, with a better existing solution: a request registry returns the same instance with zero copy, where the engine hands back N independent clones. And for the read-only config/metadata that's the actual workload here, you want that shared instance, not isolated copies, so the isolation the copy buys you solves a problem this use case doesn't have. I remain unconvinced about this object-transmission machinery. Dunno what others think about it. So where I land, concretely: I'd happily vote yes on a focused "better APCu": a volatile backend, scalars and arrays of scalars, per-pool segments (with a default scope + an internal API for the other SAPIs), and the functions-or-instances API with a remember() helper. Objects left to a userland hydration layer; attributes and pinned to a later RFC if someone still wants them once this ships. That's a primitive I could build on, and it sidesteps every hard problem in this thread. Nicolas

Timo Tijhof

91 days ago
See also Tyson's php-immutable_cache: https://github.com/TysonAndre/immutable_cache-pecl Related disucssions: https://github.com/krakjoe/apcu/issues/175 https://github.com/krakjoe/apcu/issues/453 https://github.com/krakjoe/apcu/issues/323
-- Timo Tijhof, Wikimedia Foundation. https://timotijhof.net/

zeriyoshi

91 days ago
2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>:
> Hi internals, > > I'd like to start the discussion for a new RFC, OPcache Static Cache. > > RFC: https://wiki.php.net/rfc/opcache_static_cache > Implementation: https://github.com/php/php-src/pull/22052 > > The proposal adds an OPcache-managed shared-memory cache for explicit > userland values and for selected PHP static state. It introduces explicit > functions under the OPcache namespace (volatile_* and persistent_*) and two > attributes, #[OPcache\VolatileStatic] and #[OPcache\PersistentStatic], that > let selected static properties and method static variables survive across > requests. The feature is disabled by default and only activates once memory > is allocated through the new INI directives. > > The RFC covers the motivation, the deliberate split between the two > backends, the trust model (one PHP runtime = one trust domain; this is not > a tenant isolation boundary), and benchmarks against APCu on NTS php-fpm > and ZTS FrankenPHP. The PR is the full implementation, with PHPT coverage > summarized in the Validation section. > > One thing to flag on the implementation status: the Windows build is > currently broken. I don't have a Windows development environment available > yet — one is being arranged through work, and I'll get the Windows side > fixed once that's in place. > > Feedback welcome. > > Best Regards, > Go Kudo >
Hi Nicolas, Jakub, Timo, Larry I update RFC and Implementation: RFC: https://wiki.php.net/rfc/opcache_static_cache PR: https://github.com/php/php-src/pull/22052 I'm folding replies to all three of you into one message, since the threads overlap. Most of it answers Nicolas's measurements; further down there is a section for Jakub's FPM pool-isolation concern and a short note for Timo's pointer to prior art. Nicolas, thank you for building my branch and running your own A/B/C measurements. That moved the discussion onto concrete ground, and I appreciate it. Since your review I have pushed a revised branch and bumped the RFC to 2.0.0. The API changes discussed below are in it (the SAPI opt-in model, and `getCacheStoreType()` for storage-path visibility), and the object workloads you flagged are now substantially faster: native now beats the deepclone path on every nested case I tried. Details and numbers follow. I agree with most of your points. I'll go through them in order, concede the ones where you are right, and try to narrow what is left. I think it comes down to one question: whether a userland array-hydration layer is an acceptable replacement for engine-level object storage. Most of the rest I can give you. ## The resulting public API For reference, here is the shape the explicit API settled into, summarised from the stub: ```php namespace OPcache; // Explicit cache: two final classes, static methods only, no instances. final class VolatileCache { public static function get(string $key, null|bool|int|float|string|array|object $default = null): null|bool|int|float|string|array|object; public static function getMultiple(array $keys, ?array $default = null): array|false; public static function set(string $key, null|bool|int|float|string|array|object $value, int $ttl = 0): bool; public static function setMultiple(array $values, int $ttl = 0): bool; public static function has(string $key): bool; public static function delete(string $key_or_class): bool; public static function deleteMultiple(array $keys): bool; public static function clear(): bool; public static function lock(string $key, int $lease = 0): bool; public static function unlock(string $key): bool; public static function getCacheStoreType(string $key_or_property, ?string $class_name = null): CacheStoreType; public static function info(): StaticCacheInfo; } // PinnedCache is the same set, except set()/setMultiple() take no $ttl, // plus two atomic counters: final class PinnedCache { // get/getMultiple/set/setMultiple/has/delete/deleteMultiple/clear/ // lock/unlock/getCacheStoreType/info -- as above public static function increment(string $key, int $step = 1): int|false; public static function decrement(string $key, int $step = 1): int|false; } // getCacheStoreType() reports how a value is stored, without decoding it: enum CacheStoreType { case NotFound; // no entry for the key/property case Scalar; // stored inline case SharedGraph; // zero-copy graph laid out in SHM (the fast path) case OPcacheSerialized; // OPcache binary serializer (SHM-safe, no userland) case PHPSerialized; // php_var_serialize() last resort } // Declarative static state, over the same storage: #[Attribute] final class VolatileStatic { public function __construct(int $ttl = 0, CacheStrategy $strategy = CacheStrategy::Immediate); } #[Attribute] final class PinnedStatic {} enum CacheStrategy: int { case Immediate = 0; case Tracking = 1; } // Status object and the single exception type: final readonly class StaticCacheInfo { /* enabled, available, configured_memory, entry_count, ... */ } class StaticCacheException extends \Exception {} ``` Two final classes with static methods, no instances and no shared interface. Misses and contention return the default or `false`; genuine backend failures return `false` (or `int|false` for the atomic counters); `Closure` and resource values are rejected with a `TypeError`; and `StaticCacheException` is reserved for strict `#[OPcache\PinnedStatic]` publication. ## SAPI availability: the unsafe flag is gone, opt-in instead
> these are safe SAPIs, they just don't have a scoping concept built in > [...] enable it by default with a single default scope for those SAPIs, > plus a clear internal API so a SAPI can define its own scoped segments
I implemented it the way you suggested. There is no longer an `opcache.static_cache.allow_unsafe_runtime` directive and no SAPI-name allowlist in the engine. Availability is opt-in: a SAPI, or an embedder, calls a small internal C API, `zend_opcache_static_cache_opt_in()`, before request handling to enable Static Cache for its runtime. That call is the runtime declaring that a trust/storage boundary holds for the lifetime of the shared-memory owner. The bundled `fpm`, `cli`, `cli-server` and `phpdbg` SAPIs call it at startup, so they are available by default. The difference from before is the mechanism: instead of the engine guessing from the SAPI name and offering an "unsafe" override, each runtime states that it owns a boundary. A runtime with a real per-tenant boundary scopes it with the partition API (`zend_opcache_static_cache_partition_create` / `_activate`, which `fpm` already uses per pool). A runtime without one, such as a shared multi-tenant web SAPI with no pre-request identity, never opts in and stays unavailable, with nothing left to misconfigure. The `embed` SAPI does not auto-opt-in, on purpose. The embedding application owns the runtime and its trust boundary, so it opts in from its own startup code. That keeps the rule consistent for every embedder, including one that registers its own SAPI module instead of reusing the bundled `embed` one. FrankenPHP does exactly that, so it opts in with the same one-line call (or a scoped partition when it isolates per worker); there is no `embed` special-case that covers `php_embed` users but silently misses FrankenPHP. That is your internal-API point, and it removes the naming question by deleting the flag entirely. The full ext/opcache suite passes with the directive gone. ## API shape: remember()
> I could also add VolatileCache::remember($key, $compute, $ttl = 0) > wrapping the safe lock -> build-outside-the-lock -> store sequence
I would rather not add this one. `remember()` takes a callable, and to actually prevent a stampede it has to hold the entry lock across the call to `$compute()`. That means running arbitrary userland PHP while holding a cross-process SHM lock. The callable can run unbounded, throw, fork, or re-enter the cache, and a re-entrant `lock()` on the same key (or a key in the same lock stripe) while the lock is held is a deadlock. The lease bounds the duration, but not the re-entrancy and not the exception path. Not holding the lock while computing gives no stampede protection at all; it is then just sugar over `get()`-then-`set()` that looks atomic, which is worse than not having it. Since I already expose `lock()`/`unlock()` with a lease, userland can do the safe thing itself, with the compute step outside any engine lock: ```php if (!VolatileCache::lock($key, $lease)) { return VolatileCache::get($key, $default); } try { $value = $compute(); // runs outside the engine lock VolatileCache::set($key, $value, $ttl); return $value; } finally { VolatileCache::unlock($key); } ``` That keeps the closure's execution, its scope, and any exception it throws in userland, never inside the engine's critical section. I would rather document this recipe than move userland execution into the primitive. If you see a safe construction I have missed, I will reconsider. ## References and the silent fallback
> I'd rather make it visible (surface the chosen path in info(), or in a > debug build) than ban objects
Agreed, and that is implemented: visibility, not a ban. There is a new introspection method on both cache classes: ```php VolatileCache::getCacheStoreType(string $key_or_property, ?string $class_name = null): OPcache\CacheStoreType PinnedCache::getCacheStoreType(string $key_or_property, ?string $class_name = null): OPcache\CacheStoreType ``` It returns an `OPcache\CacheStoreType` enum (`NotFound`, `Scalar`, `SharedGraph`, `OPcacheSerialized`, `PHPSerialized`), so you can see per key which path a value took, without decoding it, in any build rather than only a debug one. Passing `$class_name` inspects the attribute-backed static-property storage for that class instead of an explicit key. A value that fell back to serialization is now one call away from being observable. The enum also pins down a correction. The first fallback off the shared graph is not `php_var_serialize` but the OPcache binary serializer, which is SHM-safe and runs no userland code. That is why `getCacheStoreType` reports `OPcacheSerialized` and `PHPSerialized` as separate cases; `php_var_serialize` is the last resort, not the first. So "bail == APCu parity" understates the middle tier, though your underlying point holds: even that tier is slower than the fast path and should be visible.
> no real objection to rejecting top-level hard refs up front [...] > "top-level hard ref" confuses me
You are right to be confused, and I will retract the phrase; it is a no-op. `store($key, $value)` takes `$value` by value, so the engine dereferences any top-level reference (`ZVAL_DEREF`) before storage ever sees it. A top-level hard ref cannot reach the storage layer as a reference. The case that matters is a nested reference, a `&` inside an array element or object property, and that cannot be rejected cheaply up front: detecting it requires walking the whole graph, which is the walk the shared-graph builder already does. So the honest answer for nested refs is the visibility above (the value reports the serialize path), not an up-front rejection. ## Scalars and arrays-of-scalars only This is where the discussion helped most. I argued before that scalars-only gave up a real win; you pushed back with measurements; so I built your setup and measured it properly, including the large nested workloads that are the actual case for a cache. You were right that native was losing. That sent me into the implementation, and I found the cause and fixed it. The path is worth setting out. Two of your framings I agree with up front: 1. For array-of-scalars config/metadata, an immutable interned array is essentially free, and the cache should not claim to beat it. 2. The "Nx faster than APCu" headline is size-dependent; APCu is only a few microseconds for small payloads. ### (a) The config array
> an immutable array is essentially free (0.045 us) [...] the static > cache's own array fetch, which pays an O(n) walk per read and so doesn't > even deliver the immutable-array win that opcache literals already give
You are structurally right, and I have fixed it. Two facts first. I could not reproduce 331 us: a pure-scalar 4k-entry array fetches in about 7 us, scaling at roughly 1.7 ns/entry, and the decode itself was already zero-copy (a scalar array is stored once as `IS_ARRAY_IMMUTABLE` and returned as `ZVAL_ARR()` straight into SHM). The O(n) you felt was one layer up: every warm fetch re-walked the array in `value_needs_request_local_clone()` to decide whether it needed a deep clone, when that answer is fixed at store time. I removed that walk for shared-graph values (the same change as in (c)); the 4k fetch is now about 0.64 us and flat in the entry count. It is still not the 0.014 us of a resident literal read, and I am not claiming it should be. For read-only scalar config the preload/literal path wins, and that is fine. It is a separate matter from objects. ### (b) Objects: I measured your A/B/C, found native losing, and chased why I built this branch with APCu master and your deepclone, all NTS, JIT off, timing warm fetches where C rebuilds the same isolated object graph B returns (resident dehydrated array plus `deepclone_from_array`). As you said, native lost, and worse as the graph grew. us/op: ``` array of nested ORM entities objects A apcu B native C hydrate 1000 1800 799 501 2000 4171 1903 1043 object tree 8191 1582 1736 498 9841 1928 1836 523 ``` Two things you were right about that I had wrong: `deepclone_to_array` / `deepclone_from_array` are generic (no per-class hydrator to charge for), and C hands back the same isolated objects B does. So this was a real loss, not a measurement artifact. The cause was structural, but not where I first guessed. The warm fetch kept a request-local prototype of the materialized graph and deep-cloned it on every repeat fetch, and for an object graph that clone is slower than decoding the compact SHM layout again. A shared graph never holds shared identity or cycles, so each decode is already an independent copy; the prototype was pure overhead. On top of that the decoder re-resolved the class (`zend_lookup_class`) for every object, and the builder stored a separate copy of each repeated class and property name. ### (c) The fix Three changes, all behind the existing API, with no visible behaviour or format change: - Skip the request-local prototype for shared-graph values and decode from SHM on each fetch. (This also removes the O(n) array walk in (a).) - Deduplicate equal strings within a payload at build time, so a class or property name repeated across thousands of objects is stored once. - Memoize the resolved class per (buffer, offset) during a decode, so a homogeneous graph resolves its class once, not once per node. Same A/B/C after the change, NTS, JIT off, us/op: ``` array of nested ORM entities objects A apcu B native C hydrate 1000 1781 357 492 2000 3868 721 1036 object tree 8191 1565 462 485 9841 1830 499 513 ``` Native now beats deepclone on every nested workload I tried: about 1.4x on the 2000-entity array, and the deep trees that lost 3.5x now win. The 400-object case went from 72 to 23 us. The full ext/opcache suite passes, plus new regression tests, on NTS and ZTS. To make this reproducible on your terms, I added a deepclone backend to my own HTTP benchmark harness (dehydrate with `deepclone_to_array()`, keep the array in the volatile cache, rehydrate with `deepclone_from_array()` on each fetch) and re-ran `vote_read_long` under the published conditions (php-fpm + nginx NTS and FrankenPHP ZTS, 20 iterations / 3 warmup / 3000 ops, JIT off). The APCu baselines match the published table within about 2%, so the runtimes are comparable. native vs deepclone, mean us/op (NTS): ``` workload APCu native deepclone route_table_read 161.2 0.90 0.91 (array: tie) large_array 90.9 0.88 0.88 (array: tie) metadata_object_read 185.3 1.12 1.32 (native) metadata_object_mutate 162.4 1.03 1.19 (native) safe_direct_object 2.5 1.22 3.03 (native; deepclone slower than APCu) carbon_datetime_object 185.4 46.0 166.3 (native, ~3.6x) spl_collection_object 21.0 5.48 1.89 (deepclone) ``` So under the RFC's own methodology native is faster than the deepclone path on every object workload except SPL collections, and ties on arrays. The SPL case is the one real win for deepclone, and it is specific: those classes go through the safe-direct serialized path, whose per-fetch copy handler is heavier than rebuilding from a flat array. I have noted it in the RFC as a concrete follow-up (a tighter SPL copy handler); it does not change the overall picture. The updated tables are in the RFC. Honest edges remain: for a tiny object deepclone's tight path is a hair faster (sub-microsecond), and for read-only scalar config a resident literal still wins outright, as in (a). But for the workload this feature is actually for, large nested object graphs from a database, in-engine storage is now the faster option. ### (d) Not just performance This does not rest on performance alone. Object support is also useful for being built in and generic (no third-party extension, nothing to pre-generate) and for being one primitive: the store side and the runtime cross-worker sharing live in the same place, instead of "cache the array" plus "hydrate in userland" wired together by every library. And the safe-direct registry is not a userland protocol: a plain user object with no magic and no cycles or refs takes the fast path automatically via `can_restore_direct()`, and the C-only registry only covers a few internal classes whose state the generic path cannot read. Keeping objects imposes nothing on the ecosystem. ## Dropping pinned (and the attributes)
> PinnedStatic on the Carbon shape is ~1.5 us [...] there's no preload > trick that reaches that number, because preload can't bake a live object > graph into an opcode literal
Pinned is the one place a live-object representation still wins clearly, for a reason the volatile numbers above do not capture. Pinned (and `#[PinnedStatic]`) materialize the graph once per worker; after that it is a plain static read on every subsequent request in that worker, near zero per request. The hydration approach pays its hydrate cost on every request instead. preload cannot reach this either: it can only intern scalar and array literals, not bake a live object graph into an opcode literal. The caveat is that this holds for read-only / immutable shared state, where keeping one live instance across requests is correct; a mutable shared instance would leak between requests. But that is a real and common case: a compiled DI container, a routing table, config value objects. Your request-registry counter rebuilds per request from the cache, so it does not reach the per-worker amortization, and for the read-only data where it would help, pinned already does it with less per-request cost. The attributes are the ergonomic surface over that same mechanism, so I would keep them in this RFC rather than split them out. They add no new storage model; they remove the explicit store/fetch boilerplate for the static-state case. ## Where this leaves us What is already done or committed: the SAPI opt-in model (the `allow_unsafe_runtime` flag and the SAPI allowlist are gone, replaced by the internal opt-in/partition API); the error model; storage-path visibility via `getCacheStoreType()`; dropping the "top-level ref" idea; the config-array fix (skipping the request-local prototype for shared graphs, which removes the per-fetch array walk so a warm scalar-array fetch is zero-copy); and the large-nested object path from (d), with numbers on this same A/B/C. I am declining `remember()`, for the lock-safety reason above. On the central question I went where the measurements led. You were right that native lost as shipped; I found why (a request-local prototype clone slower than re-decoding, plus per-object class lookups and duplicated strings), fixed all three, and native now beats your deepclone path on the nested object workloads, with the full opcache suite and new regression tests passing on NTS and ZTS. For tiny objects deepclone is still a hair ahead, and for read-only scalar config a resident literal still wins; I concede both. So I do think in-engine object storage earns its place now, on performance and on being a built-in, generic, single primitive (and on pinned's per-worker amortization for read-only state). But if the body still prefers a focused better-APCu plus a core hydration primitive, that is an outcome I can support; the capability matters to me more than where it sits, and the work above transfers either way. The revised branch is pushed and the harness is published, so you can check the numbers directly; I will also post the full before/after A/B/C here. If you have a methodology you would prefer, I will run that too. Thanks again. This got much sharper because you measured it, and it sent me to a fix I would not have found otherwise. ## Jakub: the FPM pool boundary is preserved
> The FPM shared hosting part is a problem [...] we consider data leaks > between pools as security issues [...] Maybe the solution would be to > allow it only if there is one pool enabled.
This is the concern I most wanted to get right, and I think the implementation answers it without the single-pool restriction. Static Cache is not one cache shared across pools. FPM creates a separate partition per worker pool in the master, before any worker forks; each partition owns its own volatile and pinned shared-memory backend, and each worker activates only its own pool's partition during child initialization, before user code runs. Every cache API, status call, clear, and the Static Cache part of `opcache_reset()` operates on the active pool's partition. There is no API path from one pool to another pool's data, so the pool boundary stays a security boundary and no policy change is needed. If a pool's partition fails to start it gets no Static Cache; it never falls back to a shared one. One honest caveat, for the record: the per-pool segments are anonymous shared mappings created in the master before fork, so a worker inherits every pool's segment in its address space even though it can only ever address its own pool's partition. That is the same exposure model as the main OPcache SHM, which is already shared across pools today; the Static Cache is in fact more isolated, because it is logically partitioned per pool where the script cache is not. The data-leak-through-the-feature case you raised, one pool reading another's cached values through the API, does not exist in this design. If on top of that we want address-space isolation, so a worker cannot even see another pool's bytes, that is a worthwhile hardening (per-pool named segments mapped only in that pool's children, or unmapping the others post-fork), and I am happy to do it as a follow-up if you consider it in scope. Your single-pool suggestion would also work, but per-pool partitions keep the feature usable for the multi-pool shared-hosting setups where a single-cache design would otherwise be unacceptable. ## Timo: thanks for the immutable_cache pointer
> See also Tyson's php-immutable_cache [...] related APCu discussions
Thank you. Tyson told me about `immutable_cache` himself a while ago, and it shaped my thinking here. I built an internal extension along the same lines, `colopl_cache`, an APCu-style drop-in for immutable values. What that work showed me is that the parts that matter most for this use case (OPcache compatibility, behaviour under a JIT-heavy workload, and the Zend VM intervention needed for static-state caching) are very hard to get right as an ordinary extension. That is why I brought this to OPcache as an RFC instead of shipping another extension: it needs cooperation from the engine, the VM, and a few internal classes that an extension cannot coordinate cleanly. So the prior art is genuinely appreciated; it is part of how I arrived here. Best regards, Go Kudo

Larry Garfield

91 days ago
On Tue, Jun 2, 2026, at 8:11 AM, Go Kudo wrote:
> 2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>: >> Hi internals, >> >> I'd like to start the discussion for a new RFC, OPcache Static Cache. >> >> RFC: https://wiki.php.net/rfc/opcache_static_cache >> Implementation: https://github.com/php/php-src/pull/22052 >> >> The proposal adds an OPcache-managed shared-memory cache for explicit userland values and for selected PHP static state. It introduces explicit functions under the OPcache namespace (volatile_* and persistent_*) and two attributes, #[OPcache\VolatileStatic] and #[OPcache\PersistentStatic], that let selected static properties and method static variables survive across requests. The feature is disabled by default and only activates once memory is allocated through the new INI directives. >> >> The RFC covers the motivation, the deliberate split between the two backends, the trust model (one PHP runtime = one trust domain; this is not a tenant isolation boundary), and benchmarks against APCu on NTS php-fpm and ZTS FrankenPHP. The PR is the full implementation, with PHPT coverage summarized in the Validation section. >> >> One thing to flag on the implementation status: the Windows build is currently broken. I don't have a Windows development environment available yet — one is being arranged through work, and I'll get the Windows side fixed once that's in place. >> >> Feedback welcome. >> >> Best Regards, >> Go Kudo > > Hi Nicolas, Jakub, Timo, Larry > > I update RFC and Implementation: > RFC: https://wiki.php.net/rfc/opcache_static_cache > PR: https://github.com/php/php-src/pull/22052
I'm only responding to bits here and there, because the LLM text here is just too much for me to bother reading. (Frankly, your non-LLM follow up message was perfectly readable to me. I don't think you need it.) It also seems like you're rewriting the RFC every time someone posts a comment. There are differences of opinion on the list, so it will be less work for you and everyone else to slow down and let more people comment before you start making radical changes.
> ## The resulting public API > > For reference, here is the shape the explicit API settled into, summarised > from the stub: > > ```php > namespace OPcache; > > // Explicit cache: two final classes, static methods only, no instances. > final class VolatileCache > { > public static function get(string $key, > null|bool|int|float|string|array|object $default = null): > null|bool|int|float|string|array|object; > public static function getMultiple(array $keys, ?array $default = > null): array|false; > public static function set(string $key, > null|bool|int|float|string|array|object $value, int $ttl = 0): bool; > public static function setMultiple(array $values, int $ttl = 0): > bool; > public static function has(string $key): bool; > public static function delete(string $key_or_class): bool; > public static function deleteMultiple(array $keys): bool; > public static function clear(): bool; > public static function lock(string $key, int $lease = 0): bool; > public static function unlock(string $key): bool; > public static function getCacheStoreType(string $key_or_property, > ?string $class_name = null): CacheStoreType; > public static function info(): StaticCacheInfo; > } > > // PinnedCache is the same set, except set()/setMultiple() take no $ttl, > // plus two atomic counters: > final class PinnedCache > { > // get/getMultiple/set/setMultiple/has/delete/deleteMultiple/clear/ > // lock/unlock/getCacheStoreType/info -- as above > public static function increment(string $key, int $step = 1): int|false; > public static function decrement(string $key, int $step = 1): int|false;
No int|false. That's an anti-pattern. If you must do "int or error", at the very least use null here.
> } > > // getCacheStoreType() reports how a value is stored, without decoding it: > enum CacheStoreType > { > case NotFound; // no entry for the key/property > case Scalar; // stored inline > case SharedGraph; // zero-copy graph laid out in SHM (the fast path) > case OPcacheSerialized; // OPcache binary serializer (SHM-safe, no userland) > case PHPSerialized; // php_var_serialize() last resort > } > > // Declarative static state, over the same storage: > #[Attribute] final class VolatileStatic { > public function __construct(int $ttl = 0, CacheStrategy $strategy = > CacheStrategy::Immediate); > } > #[Attribute] final class PinnedStatic {} > enum CacheStrategy: int { case Immediate = 0; case Tracking = 1; } > > // Status object and the single exception type: > final readonly class StaticCacheInfo { /* enabled, available, > configured_memory, entry_count, ... */ } > class StaticCacheException extends \Exception {} > ``` > > Two final classes with static methods, no instances and no shared > interface. Misses and contention return the default or `false`; genuine > backend failures return `false` (or `int|false` for the atomic counters); > `Closure` and resource values are rejected with a `TypeError`; and > `StaticCacheException` is reserved for strict `#[OPcache\PinnedStatic]` > publication.
I want to be clear on this: I will absolutely vote against this proposal if it ships with static methods as the API, no matter what else it contains. That is a horrible anti-pattern and it should not be brought anywhere close to PHP's stdlib. No. Absolutely not. Nicolas' original proposal was for regular objects, with a factory method. I also prefer a regular object, but I'd go a step further: $volatile = new VolatileCache('some_key'); $volatile->set('key', $val); Pass a "scoping key" (or namespace, or prefix, or whatever you want to call it) to the constructor of the cache objects. In most cases, frameworks (like Symfony or Laravel) already have an app-key value that is unique to the app instance, and that can be used probably directly. That provides a clear separation between different cache pools; even if you have a multi-tenant setup such as apache2, using different random strings for the scoping key will keep the values separate. That gives us a clear separation, and justification for shipping on-by-default. (This is what I meant earlier when talking about "pools." That's essentially what this is.) Also: I really don't like the name "pinned." The opposite of "Volatile" is usually "stable". That's less misleading than "persistent" (the original name), but also less confusing than "pinned", which means nothing here.
> ## References and the silent fallback
I honestly didn't follow this section. Probably because of the LLM.
> ## Scalars and arrays-of-scalars only > > This is where the discussion helped most. I argued before that scalars-only > gave up a real win; you pushed back with measurements; so I built your setup > and measured it properly, including the large nested workloads that are the > actual case for a cache. You were right that native was losing. That sent me > into the implementation, and I found the cause and fixed it. The path is > worth setting out. > > Two of your framings I agree with up front: > > 1. For array-of-scalars config/metadata, an immutable interned array is > essentially free, and the cache should not claim to beat it. > 2. The "Nx faster than APCu" headline is size-dependent; APCu is only a few > microseconds for small payloads.
I can see Nicolas' argument for scalar/arrays-only, but I also agree that does greatly limit its usefulness. You would need to spend a great deal of effort building an object facade for that data in many cases. That's going to eat up a large chunk of the benefit (in both dev time and run time) of this feature. Put another way, if I can just build up a data structure on a property, stick an attribute on it, and then always use it like: $data = self::$data ??= compute_data(); And move on with life, that's huge for DX, even if it may be slightly slower than taking the time to compile an array form of it, save it to disk (and worry about file permissions and writeability), reload it, and then rehydrate to objects, potentially. Frankly, I'd take that tradeoff more often than not.
> ### (d) Not just performance > > This does not rest on performance alone. Object support is also useful for > being built in and generic (no third-party extension, nothing to pre-generate) > and for being one primitive: the store side and the runtime cross-worker > sharing live in the same place, instead of "cache the array" plus "hydrate in > userland" wired together by every library. And the safe-direct registry is not > a userland protocol: a plain user object with no magic and no cycles or refs > takes the fast path automatically via `can_restore_direct()`, and the C-only > registry only covers a few internal classes whose state the generic path > cannot read. Keeping objects imposes nothing on the ecosystem.
Right, that. The simplicity of the userland code is the big win for me, even if it's single-digit-percent slower than manually materializing in some cases.
> ## Dropping pinned (and the attributes) > >> PinnedStatic on the Carbon shape is ~1.5 us [...] there's no preload >> trick that reaches that number, because preload can't bake a live object >> graph into an opcode literal > > Pinned is the one place a live-object representation still wins clearly, for a > reason the volatile numbers above do not capture. Pinned (and > `#[PinnedStatic]`) materialize the graph once per worker; after that it is a > plain static read on every subsequent request in that worker, near zero per > request. The hydration approach pays its hydrate cost on every request instead. > preload cannot reach this either: it can only intern scalar and array > literals, not bake a live object graph into an opcode literal. > > The caveat is that this holds for read-only / immutable shared state, where > keeping one live instance across requests is correct; a mutable shared instance > would leak between requests. But that is a real and common case: a compiled DI > container, a routing table, config value objects. Your request-registry counter > rebuilds per request from the cache, so it does not reach the per-worker > amortization, and for the read-only data where it would help, pinned already > does it with less per-request cost. > > The attributes are the ergonomic surface over that same mechanism, so I would > keep them in this RFC rather than split them out. They add no new storage > model; they remove the explicit store/fetch boilerplate for the static-state > case.
I would prefer to keep these in rather than remove them, but I wouldn't vote against the RFC if the consensus is eventually to remove them until later. --Larry Garfield

zeriyoshi

90 days ago
2026年6月3日(水) 1:57 Larry Garfield <larry@garfieldtech.com>:
> On Tue, Jun 2, 2026, at 8:11 AM, Go Kudo wrote: > > 2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>: > >> Hi internals, > >> > >> I'd like to start the discussion for a new RFC, OPcache Static Cache. > >> > >> RFC: https://wiki.php.net/rfc/opcache_static_cache > >> Implementation: https://github.com/php/php-src/pull/22052 > >> > >> The proposal adds an OPcache-managed shared-memory cache for explicit > userland values and for selected PHP static state. It introduces explicit > functions under the OPcache namespace (volatile_* and persistent_*) and two > attributes, #[OPcache\VolatileStatic] and #[OPcache\PersistentStatic], that > let selected static properties and method static variables survive across > requests. The feature is disabled by default and only activates once memory > is allocated through the new INI directives. > >> > >> The RFC covers the motivation, the deliberate split between the two > backends, the trust model (one PHP runtime = one trust domain; this is not > a tenant isolation boundary), and benchmarks against APCu on NTS php-fpm > and ZTS FrankenPHP. The PR is the full implementation, with PHPT coverage > summarized in the Validation section. > >> > >> One thing to flag on the implementation status: the Windows build is > currently broken. I don't have a Windows development environment available > yet — one is being arranged through work, and I'll get the Windows side > fixed once that's in place. > >> > >> Feedback welcome. > >> > >> Best Regards, > >> Go Kudo > > > > Hi Nicolas, Jakub, Timo, Larry > > > > I update RFC and Implementation: > > RFC: https://wiki.php.net/rfc/opcache_static_cache > > PR: https://github.com/php/php-src/pull/22052 > > I'm only responding to bits here and there, because the LLM text here is > just too much for me to bother reading. (Frankly, your non-LLM follow up > message was perfectly readable to me. I don't think you need it.) > > It also seems like you're rewriting the RFC every time someone posts a > comment. There are differences of opinion on the list, so it will be less > work for you and everyone else to slow down and let more people comment > before you start making radical changes. > > > ## The resulting public API > > > > For reference, here is the shape the explicit API settled into, > summarised > > from the stub: > > > > ```php > > namespace OPcache; > > > > // Explicit cache: two final classes, static methods only, no instances. > > final class VolatileCache > > { > > public static function get(string $key, > > null|bool|int|float|string|array|object $default = null): > > null|bool|int|float|string|array|object; > > public static function getMultiple(array $keys, ?array $default = > > null): array|false; > > public static function set(string $key, > > null|bool|int|float|string|array|object $value, int $ttl = 0): bool; > > public static function setMultiple(array $values, int $ttl = 0): > > bool; > > public static function has(string $key): bool; > > public static function delete(string $key_or_class): bool; > > public static function deleteMultiple(array $keys): bool; > > public static function clear(): bool; > > public static function lock(string $key, int $lease = 0): bool; > > public static function unlock(string $key): bool; > > public static function getCacheStoreType(string $key_or_property, > > ?string $class_name = null): CacheStoreType; > > public static function info(): StaticCacheInfo; > > } > > > > // PinnedCache is the same set, except set()/setMultiple() take no $ttl, > > // plus two atomic counters: > > final class PinnedCache > > { > > // get/getMultiple/set/setMultiple/has/delete/deleteMultiple/clear/ > > // lock/unlock/getCacheStoreType/info -- as above > > public static function increment(string $key, int $step = 1): > int|false; > > public static function decrement(string $key, int $step = 1): > int|false; > > No int|false. That's an anti-pattern. If you must do "int or error", at > the very least use null here. > > > } > > > > // getCacheStoreType() reports how a value is stored, without decoding > it: > > enum CacheStoreType > > { > > case NotFound; // no entry for the key/property > > case Scalar; // stored inline > > case SharedGraph; // zero-copy graph laid out in SHM (the fast > path) > > case OPcacheSerialized; // OPcache binary serializer (SHM-safe, no > userland) > > case PHPSerialized; // php_var_serialize() last resort > > } > > > > // Declarative static state, over the same storage: > > #[Attribute] final class VolatileStatic { > > public function __construct(int $ttl = 0, CacheStrategy $strategy = > > CacheStrategy::Immediate); > > } > > #[Attribute] final class PinnedStatic {} > > enum CacheStrategy: int { case Immediate = 0; case Tracking = 1; } > > > > // Status object and the single exception type: > > final readonly class StaticCacheInfo { /* enabled, available, > > configured_memory, entry_count, ... */ } > > class StaticCacheException extends \Exception {} > > ``` > > > > Two final classes with static methods, no instances and no shared > > interface. Misses and contention return the default or `false`; genuine > > backend failures return `false` (or `int|false` for the atomic counters); > > `Closure` and resource values are rejected with a `TypeError`; and > > `StaticCacheException` is reserved for strict `#[OPcache\PinnedStatic]` > > publication. > > I want to be clear on this: I will absolutely vote against this proposal > if it ships with static methods as the API, no matter what else it > contains. That is a horrible anti-pattern and it should not be brought > anywhere close to PHP's stdlib. No. Absolutely not. > > Nicolas' original proposal was for regular objects, with a factory > method. I also prefer a regular object, but I'd go a step further: > > $volatile = new VolatileCache('some_key'); > $volatile->set('key', $val); > > Pass a "scoping key" (or namespace, or prefix, or whatever you want to > call it) to the constructor of the cache objects. In most cases, > frameworks (like Symfony or Laravel) already have an app-key value that is > unique to the app instance, and that can be used probably directly. That > provides a clear separation between different cache pools; even if you have > a multi-tenant setup such as apache2, using different random strings for > the scoping key will keep the values separate. > > That gives us a clear separation, and justification for shipping > on-by-default. > > (This is what I meant earlier when talking about "pools." That's > essentially what this is.) > > Also: I really don't like the name "pinned." The opposite of "Volatile" > is usually "stable". That's less misleading than "persistent" (the > original name), but also less confusing than "pinned", which means nothing > here. > > > ## References and the silent fallback > > I honestly didn't follow this section. Probably because of the LLM. > > > ## Scalars and arrays-of-scalars only > > > > This is where the discussion helped most. I argued before that > scalars-only > > gave up a real win; you pushed back with measurements; so I built your > setup > > and measured it properly, including the large nested workloads that are > the > > actual case for a cache. You were right that native was losing. That > sent me > > into the implementation, and I found the cause and fixed it. The path is > > worth setting out. > > > > Two of your framings I agree with up front: > > > > 1. For array-of-scalars config/metadata, an immutable interned array is > > essentially free, and the cache should not claim to beat it. > > 2. The "Nx faster than APCu" headline is size-dependent; APCu is only a > few > > microseconds for small payloads. > > I can see Nicolas' argument for scalar/arrays-only, but I also agree that > does greatly limit its usefulness. You would need to spend a great deal of > effort building an object facade for that data in many cases. That's going > to eat up a large chunk of the benefit (in both dev time and run time) of > this feature. > > Put another way, if I can just build up a data structure on a property, > stick an attribute on it, and then always use it like: > > $data = self::$data ??= compute_data(); > > And move on with life, that's huge for DX, even if it may be slightly > slower than taking the time to compile an array form of it, save it to disk > (and worry about file permissions and writeability), reload it, and then > rehydrate to objects, potentially. Frankly, I'd take that tradeoff more > often than not. > > > ### (d) Not just performance > > > > This does not rest on performance alone. Object support is also useful > for > > being built in and generic (no third-party extension, nothing to > pre-generate) > > and for being one primitive: the store side and the runtime cross-worker > > sharing live in the same place, instead of "cache the array" plus > "hydrate in > > userland" wired together by every library. And the safe-direct registry > is not > > a userland protocol: a plain user object with no magic and no cycles or > refs > > takes the fast path automatically via `can_restore_direct()`, and the > C-only > > registry only covers a few internal classes whose state the generic path > > cannot read. Keeping objects imposes nothing on the ecosystem. > > Right, that. The simplicity of the userland code is the big win for me, > even if it's single-digit-percent slower than manually materializing in > some cases. > > > ## Dropping pinned (and the attributes) > > > >> PinnedStatic on the Carbon shape is ~1.5 us [...] there's no preload > >> trick that reaches that number, because preload can't bake a live object > >> graph into an opcode literal > > > > Pinned is the one place a live-object representation still wins clearly, > for a > > reason the volatile numbers above do not capture. Pinned (and > > `#[PinnedStatic]`) materialize the graph once per worker; after that it > is a > > plain static read on every subsequent request in that worker, near zero > per > > request. The hydration approach pays its hydrate cost on every request > instead. > > preload cannot reach this either: it can only intern scalar and array > > literals, not bake a live object graph into an opcode literal. > > > > The caveat is that this holds for read-only / immutable shared state, > where > > keeping one live instance across requests is correct; a mutable shared > instance > > would leak between requests. But that is a real and common case: a > compiled DI > > container, a routing table, config value objects. Your request-registry > counter > > rebuilds per request from the cache, so it does not reach the per-worker > > amortization, and for the read-only data where it would help, pinned > already > > does it with less per-request cost. > > > > The attributes are the ergonomic surface over that same mechanism, so I > would > > keep them in this RFC rather than split them out. They add no new storage > > model; they remove the explicit store/fetch boilerplate for the > static-state > > case. > > I would prefer to keep these in rather than remove them, but I wouldn't > vote against the RFC if the consensus is eventually to remove them until > later. > > --Larry Garfield >
Hi Larry, Nicolas, Thanks for your response. I'm trying to read my sent LLM-ish email, To be honest, it wasn't completely readable. Apologize to all internals ML members. I don't repeat it.
> I want to be clear on this: I will absolutely vote against this proposal
if it ships with static methods as the API, no matter what else it contains. That is a horrible anti-pattern and it should not be brought anywhere close to PHP's stdlib. No. Absolutely not. I understand Larray and Nicolas' API design. This is the most fluent and modern and clean pool-separation idea. If there are no objections from other members, I implement these solutions. (but, I will wait and see until other points are clarified) But, I think keeping the SAPI based opt-in mechanism, This is a security reason in a multi-tenant environment. `embed` SAPI has default non opt-in state, but FrankenPHP SAPI can enable this yourself. (I attached FrankenPHP patch to RFC)
> No int|false. That's an anti-pattern. If you must do "int or error", at
the very least use null here. Yes. I think `null` is the best option.
> I really don't like the name "pinned." The opposite of "Volatile" is
usually "stable". That's less misleading than "persistent" (the original name), but also less confusing than "pinned", which means nothing here. OK. I didn't have a naming opinion. these acceptable changes. I will explain in my own words the points that my LLM-like documentation didn't clarify. **Currently implementation is best performance in almost all workloads.** Nicolas's `ext-deepclone` has better performance in non Attribute-based cache pattern only, API cache pattern was not best performance, These facts written latest RFC benchmark results. (My English skill is too bad, I have absolutely no intention of criticizing Nicolas's implementation as inferior. Please do not misunderstand) I have one more question, is Attribute cache implementation the correct approach? I think this approach is the best performance effort and easy to integrate into existing mechanisms. (These text isn't use LLM, If you can't read mean, Please replying to me) Best regards, Go Kudo

Nicolas Grekas

90 days ago
Le mar. 2 juin 2026 à 15:12, Go Kudo <zeriyoshi@gmail.com> a écrit :
> > > 2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>: > >> Hi internals, >> >> I'd like to start the discussion for a new RFC, OPcache Static Cache. >> >> RFC: https://wiki.php.net/rfc/opcache_static_cache >> Implementation: https://github.com/php/php-src/pull/22052 >> >> The proposal adds an OPcache-managed shared-memory cache for explicit >> userland values and for selected PHP static state. It introduces explicit >> functions under the OPcache namespace (volatile_* and persistent_*) and two >> attributes, #[OPcache\VolatileStatic] and #[OPcache\PersistentStatic], that >> let selected static properties and method static variables survive across >> requests. The feature is disabled by default and only activates once memory >> is allocated through the new INI directives. >> >> The RFC covers the motivation, the deliberate split between the two >> backends, the trust model (one PHP runtime = one trust domain; this is not >> a tenant isolation boundary), and benchmarks against APCu on NTS php-fpm >> and ZTS FrankenPHP. The PR is the full implementation, with PHPT coverage >> summarized in the Validation section. >> >> One thing to flag on the implementation status: the Windows build is >> currently broken. I don't have a Windows development environment available >> yet — one is being arranged through work, and I'll get the Windows side >> fixed once that's in place. >> >> Feedback welcome. >> >> Best Regards, >> Go Kudo >> > > Hi Nicolas, Jakub, Timo, Larry > > I update RFC and Implementation: > RFC: https://wiki.php.net/rfc/opcache_static_cache > PR: https://github.com/php/php-src/pull/22052 > > I'm folding replies to all three of you into one message, since the > threads overlap. Most of it answers Nicolas's measurements; further down > there is a section for Jakub's FPM pool-isolation concern and a short note > for Timo's pointer to prior art. > > Nicolas, thank you for building my branch and running your own A/B/C > measurements. That moved the discussion onto concrete ground, and I > appreciate it. > > Since your review I have pushed a revised branch and bumped the RFC to > 2.0.0. The API changes discussed below are in it (the SAPI opt-in model, > and `getCacheStoreType()` for storage-path visibility), and the object > workloads you flagged are now substantially faster: native now beats the > deepclone path on every nested case I tried. Details and numbers follow. > > I agree with most of your points. I'll go through them in order, concede > the ones where you are right, and try to narrow what is left. I think it > comes down to one question: whether a userland array-hydration layer is an > acceptable replacement for engine-level object storage. Most of the rest I > can give you. > > ## The resulting public API > > For reference, here is the shape the explicit API settled into, summarised > from the stub: > > ```php > namespace OPcache; > > // Explicit cache: two final classes, static methods only, no instances. > final class VolatileCache > { > public static function get(string $key, > null|bool|int|float|string|array|object $default = null): > null|bool|int|float|string|array|object; > public static function getMultiple(array $keys, ?array $default = > null): array|false; > public static function set(string $key, > null|bool|int|float|string|array|object $value, int $ttl = 0): bool; > public static function setMultiple(array $values, int $ttl = 0): bool; > public static function has(string $key): bool; > public static function delete(string $key_or_class): bool; > public static function deleteMultiple(array $keys): bool; > public static function clear(): bool; > public static function lock(string $key, int $lease = 0): bool; > public static function unlock(string $key): bool; > public static function getCacheStoreType(string $key_or_property, > ?string $class_name = null): CacheStoreType; > public static function info(): StaticCacheInfo; > } > > // PinnedCache is the same set, except set()/setMultiple() take no $ttl, > // plus two atomic counters: > final class PinnedCache > { > // get/getMultiple/set/setMultiple/has/delete/deleteMultiple/clear/ > // lock/unlock/getCacheStoreType/info -- as above > public static function increment(string $key, int $step = 1): > int|false; > public static function decrement(string $key, int $step = 1): > int|false; > } > > // getCacheStoreType() reports how a value is stored, without decoding it: > enum CacheStoreType > { > case NotFound; // no entry for the key/property > case Scalar; // stored inline > case SharedGraph; // zero-copy graph laid out in SHM (the fast > path) > case OPcacheSerialized; // OPcache binary serializer (SHM-safe, no > userland) > case PHPSerialized; // php_var_serialize() last resort > } > > // Declarative static state, over the same storage: > #[Attribute] final class VolatileStatic { > public function __construct(int $ttl = 0, CacheStrategy $strategy = > CacheStrategy::Immediate); > } > #[Attribute] final class PinnedStatic {} > enum CacheStrategy: int { case Immediate = 0; case Tracking = 1; } > > // Status object and the single exception type: > final readonly class StaticCacheInfo { /* enabled, available, > configured_memory, entry_count, ... */ } > class StaticCacheException extends \Exception {} > ``` > > Two final classes with static methods, no instances and no shared > interface. Misses and contention return the default or `false`; genuine > backend failures return `false` (or `int|false` for the atomic counters); > `Closure` and resource values are rejected with a `TypeError`; and > `StaticCacheException` is reserved for strict `#[OPcache\PinnedStatic]` > publication. > > ## SAPI availability: the unsafe flag is gone, opt-in instead > > > these are safe SAPIs, they just don't have a scoping concept built in > > [...] enable it by default with a single default scope for those SAPIs, > > plus a clear internal API so a SAPI can define its own scoped segments > > I implemented it the way you suggested. There is no longer an > `opcache.static_cache.allow_unsafe_runtime` directive and no SAPI-name > allowlist in the engine. Availability is opt-in: a SAPI, or an embedder, > calls a small internal C API, `zend_opcache_static_cache_opt_in()`, before > request handling to enable Static Cache for its runtime. That call is the > runtime declaring that a trust/storage boundary holds for the lifetime of > the shared-memory owner. > > The bundled `fpm`, `cli`, `cli-server` and `phpdbg` SAPIs call it at > startup, so they are available by default. The difference from before is > the > mechanism: instead of the engine guessing from the SAPI name and offering > an > "unsafe" override, each runtime states that it owns a boundary. A runtime > with a real per-tenant boundary scopes it with the partition API > (`zend_opcache_static_cache_partition_create` / `_activate`, which `fpm` > already uses per pool). A runtime without one, such as a shared > multi-tenant > web SAPI with no pre-request identity, never opts in and stays unavailable, > with nothing left to misconfigure. > > The `embed` SAPI does not auto-opt-in, on purpose. The embedding > application > owns the runtime and its trust boundary, so it opts in from its own startup > code. That keeps the rule consistent for every embedder, including one that > registers its own SAPI module instead of reusing the bundled `embed` one. > FrankenPHP does exactly that, so it opts in with the same one-line call > (or a > scoped partition when it isolates per worker); there is no `embed` > special-case that covers `php_embed` users but silently misses FrankenPHP. > > That is your internal-API point, and it removes the naming question by > deleting the flag entirely. The full ext/opcache suite passes with the > directive gone. > > ## API shape: remember() > > > I could also add VolatileCache::remember($key, $compute, $ttl = 0) > > wrapping the safe lock -> build-outside-the-lock -> store sequence > > I would rather not add this one. `remember()` takes a callable, and to > actually prevent a stampede it has to hold the entry lock across the call > to > `$compute()`. That means running arbitrary userland PHP while holding a > cross-process SHM lock. The callable can run unbounded, throw, fork, or > re-enter the cache, and a re-entrant `lock()` on the same key (or a key in > the same lock stripe) while the lock is held is a deadlock. The lease > bounds > the duration, but not the re-entrancy and not the exception path. > > Not holding the lock while computing gives no stampede protection at all; > it > is then just sugar over `get()`-then-`set()` that looks atomic, which is > worse than not having it. > > Since I already expose `lock()`/`unlock()` with a lease, userland can do > the > safe thing itself, with the compute step outside any engine lock: > > ```php > if (!VolatileCache::lock($key, $lease)) { > return VolatileCache::get($key, $default); > } > try { > $value = $compute(); // runs outside the engine lock > VolatileCache::set($key, $value, $ttl); > return $value; > } finally { > VolatileCache::unlock($key); > } > ``` > > That keeps the closure's execution, its scope, and any exception it throws > in > userland, never inside the engine's critical section. I would rather > document > this recipe than move userland execution into the primitive. If you see a > safe construction I have missed, I will reconsider. > > ## References and the silent fallback > > > I'd rather make it visible (surface the chosen path in info(), or in a > > debug build) than ban objects > > Agreed, and that is implemented: visibility, not a ban. There is a new > introspection method on both cache classes: > > ```php > VolatileCache::getCacheStoreType(string $key_or_property, ?string > $class_name = null): OPcache\CacheStoreType > PinnedCache::getCacheStoreType(string $key_or_property, ?string > $class_name = null): OPcache\CacheStoreType > ``` > > It returns an `OPcache\CacheStoreType` enum (`NotFound`, `Scalar`, > `SharedGraph`, `OPcacheSerialized`, `PHPSerialized`), so you can see per > key > which path a value took, without decoding it, in any build rather than > only a > debug one. Passing `$class_name` inspects the attribute-backed > static-property storage for that class instead of an explicit key. A value > that fell back to serialization is now one call away from being observable. > > The enum also pins down a correction. The first fallback off the shared > graph > is not `php_var_serialize` but the OPcache binary serializer, which is > SHM-safe and runs no userland code. That is why `getCacheStoreType` reports > `OPcacheSerialized` and `PHPSerialized` as separate cases; > `php_var_serialize` > is the last resort, not the first. So "bail == APCu parity" understates the > middle tier, though your underlying point holds: even that tier is slower > than > the fast path and should be visible. > > > no real objection to rejecting top-level hard refs up front [...] > > "top-level hard ref" confuses me > > You are right to be confused, and I will retract the phrase; it is a no-op. > `store($key, $value)` takes `$value` by value, so the engine dereferences > any > top-level reference (`ZVAL_DEREF`) before storage ever sees it. A top-level > hard ref cannot reach the storage layer as a reference. The case that > matters > is a nested reference, a `&` inside an array element or object property, > and > that cannot be rejected cheaply up front: detecting it requires walking the > whole graph, which is the walk the shared-graph builder already does. So > the > honest answer for nested refs is the visibility above (the value reports > the > serialize path), not an up-front rejection. > > ## Scalars and arrays-of-scalars only > > This is where the discussion helped most. I argued before that scalars-only > gave up a real win; you pushed back with measurements; so I built your > setup > and measured it properly, including the large nested workloads that are the > actual case for a cache. You were right that native was losing. That sent > me > into the implementation, and I found the cause and fixed it. The path is > worth setting out. > > Two of your framings I agree with up front: > > 1. For array-of-scalars config/metadata, an immutable interned array is > essentially free, and the cache should not claim to beat it. > 2. The "Nx faster than APCu" headline is size-dependent; APCu is only a few > microseconds for small payloads. > > ### (a) The config array > > > an immutable array is essentially free (0.045 us) [...] the static > > cache's own array fetch, which pays an O(n) walk per read and so doesn't > > even deliver the immutable-array win that opcache literals already give > > You are structurally right, and I have fixed it. Two facts first. I could > not > reproduce 331 us: a pure-scalar 4k-entry array fetches in about 7 us, > scaling > at roughly 1.7 ns/entry, and the decode itself was already zero-copy (a > scalar array is stored once as `IS_ARRAY_IMMUTABLE` and returned as > `ZVAL_ARR()` straight into SHM). The O(n) you felt was one layer up: every > warm fetch re-walked the array in `value_needs_request_local_clone()` to > decide whether it needed a deep clone, when that answer is fixed at store > time. I removed that walk for shared-graph values (the same change as in > (c)); the 4k fetch is now about 0.64 us and flat in the entry count. > > It is still not the 0.014 us of a resident literal read, and I am not > claiming it should be. For read-only scalar config the preload/literal path > wins, and that is fine. It is a separate matter from objects. > > ### (b) Objects: I measured your A/B/C, found native losing, and chased why > > I built this branch with APCu master and your deepclone, all NTS, JIT off, > timing warm fetches where C rebuilds the same isolated object graph B > returns > (resident dehydrated array plus `deepclone_from_array`). As you said, > native > lost, and worse as the graph grew. us/op: > > ``` > array of nested ORM entities objects A apcu B native C hydrate > 1000 1800 799 501 > 2000 4171 1903 1043 > object tree 8191 1582 1736 498 > 9841 1928 1836 523 > ``` > > Two things you were right about that I had wrong: `deepclone_to_array` / > `deepclone_from_array` are generic (no per-class hydrator to charge for), > and > C hands back the same isolated objects B does. So this was a real loss, > not a > measurement artifact. > > The cause was structural, but not where I first guessed. The warm fetch > kept > a request-local prototype of the materialized graph and deep-cloned it on > every repeat fetch, and for an object graph that clone is slower than > decoding > the compact SHM layout again. A shared graph never holds shared identity or > cycles, so each decode is already an independent copy; the prototype was > pure > overhead. On top of that the decoder re-resolved the class > (`zend_lookup_class`) for every object, and the builder stored a separate > copy > of each repeated class and property name. > > ### (c) The fix > > Three changes, all behind the existing API, with no visible behaviour or > format change: > > - Skip the request-local prototype for shared-graph values and decode from > SHM on each fetch. (This also removes the O(n) array walk in (a).) > - Deduplicate equal strings within a payload at build time, so a class or > property name repeated across thousands of objects is stored once. > - Memoize the resolved class per (buffer, offset) during a decode, so a > homogeneous graph resolves its class once, not once per node. > > Same A/B/C after the change, NTS, JIT off, us/op: > > ``` > array of nested ORM entities objects A apcu B native C hydrate > 1000 1781 357 492 > 2000 3868 721 1036 > object tree 8191 1565 462 485 > 9841 1830 499 513 > ``` > > Native now beats deepclone on every nested workload I tried: about 1.4x on > the 2000-entity array, and the deep trees that lost 3.5x now win. The > 400-object case went from 72 to 23 us. The full ext/opcache suite passes, > plus new regression tests, on NTS and ZTS. > > To make this reproducible on your terms, I added a deepclone backend to my > own > HTTP benchmark harness (dehydrate with `deepclone_to_array()`, keep the > array > in the volatile cache, rehydrate with `deepclone_from_array()` on each > fetch) > and re-ran `vote_read_long` under the published conditions (php-fpm + nginx > NTS and FrankenPHP ZTS, 20 iterations / 3 warmup / 3000 ops, JIT off). The > APCu baselines match the published table within about 2%, so the runtimes > are > comparable. native vs deepclone, mean us/op (NTS): > > ``` > workload APCu native deepclone > route_table_read 161.2 0.90 0.91 (array: tie) > large_array 90.9 0.88 0.88 (array: tie) > metadata_object_read 185.3 1.12 1.32 (native) > metadata_object_mutate 162.4 1.03 1.19 (native) > safe_direct_object 2.5 1.22 3.03 (native; deepclone > slower than APCu) > carbon_datetime_object 185.4 46.0 166.3 (native, ~3.6x) > spl_collection_object 21.0 5.48 1.89 (deepclone) > ``` > > So under the RFC's own methodology native is faster than the deepclone > path on > every object workload except SPL collections, and ties on arrays. The SPL > case > is the one real win for deepclone, and it is specific: those classes go > through > the safe-direct serialized path, whose per-fetch copy handler is heavier > than > rebuilding from a flat array. I have noted it in the RFC as a concrete > follow-up (a tighter SPL copy handler); it does not change the overall > picture. > The updated tables are in the RFC. > > Honest edges remain: for a tiny object deepclone's tight path is a hair > faster > (sub-microsecond), and for read-only scalar config a resident literal still > wins outright, as in (a). But for the workload this feature is actually > for, > large nested object graphs from a database, in-engine storage is now the > faster option. > > ### (d) Not just performance > > This does not rest on performance alone. Object support is also useful for > being built in and generic (no third-party extension, nothing to > pre-generate) > and for being one primitive: the store side and the runtime cross-worker > sharing live in the same place, instead of "cache the array" plus "hydrate > in > userland" wired together by every library. And the safe-direct registry is > not > a userland protocol: a plain user object with no magic and no cycles or > refs > takes the fast path automatically via `can_restore_direct()`, and the > C-only > registry only covers a few internal classes whose state the generic path > cannot read. Keeping objects imposes nothing on the ecosystem. > > ## Dropping pinned (and the attributes) > > > PinnedStatic on the Carbon shape is ~1.5 us [...] there's no preload > > trick that reaches that number, because preload can't bake a live object > > graph into an opcode literal > > Pinned is the one place a live-object representation still wins clearly, > for a > reason the volatile numbers above do not capture. Pinned (and > `#[PinnedStatic]`) materialize the graph once per worker; after that it is > a > plain static read on every subsequent request in that worker, near zero per > request. The hydration approach pays its hydrate cost on every request > instead. > preload cannot reach this either: it can only intern scalar and array > literals, not bake a live object graph into an opcode literal. > > The caveat is that this holds for read-only / immutable shared state, where > keeping one live instance across requests is correct; a mutable shared > instance > would leak between requests. But that is a real and common case: a > compiled DI > container, a routing table, config value objects. Your request-registry > counter > rebuilds per request from the cache, so it does not reach the per-worker > amortization, and for the read-only data where it would help, pinned > already > does it with less per-request cost. > > The attributes are the ergonomic surface over that same mechanism, so I > would > keep them in this RFC rather than split them out. They add no new storage > model; they remove the explicit store/fetch boilerplate for the > static-state > case. > > ## Where this leaves us > > What is already done or committed: the SAPI opt-in model (the > `allow_unsafe_runtime` flag and the SAPI allowlist are gone, replaced by > the > internal opt-in/partition API); the error model; storage-path visibility > via > `getCacheStoreType()`; dropping the "top-level ref" idea; the config-array > fix > (skipping the request-local prototype for shared graphs, which removes the > per-fetch array walk so a warm scalar-array fetch is zero-copy); and the > large-nested object path from (d), with numbers on this same A/B/C. I am > declining `remember()`, for the lock-safety reason above. > > On the central question I went where the measurements led. You were right > that > native lost as shipped; I found why (a request-local prototype clone slower > than re-decoding, plus per-object class lookups and duplicated strings), > fixed > all three, and native now beats your deepclone path on the nested object > workloads, with the full opcache suite and new regression tests passing on > NTS > and ZTS. For tiny objects deepclone is still a hair ahead, and for > read-only > scalar config a resident literal still wins; I concede both. > > So I do think in-engine object storage earns its place now, on performance > and > on being a built-in, generic, single primitive (and on pinned's per-worker > amortization for read-only state). But if the body still prefers a focused > better-APCu plus a core hydration primitive, that is an outcome I can > support; > the capability matters to me more than where it sits, and the work above > transfers either way. > > The revised branch is pushed and the harness is published, so you can check > the numbers directly; I will also post the full before/after A/B/C here. > If you > have a methodology you would prefer, I will run that too. > > Thanks again. This got much sharper because you measured it, and it sent > me to > a fix I would not have found otherwise. > > ## Jakub: the FPM pool boundary is preserved > > > The FPM shared hosting part is a problem [...] we consider data leaks > > between pools as security issues [...] Maybe the solution would be to > > allow it only if there is one pool enabled. > > This is the concern I most wanted to get right, and I think the > implementation > answers it without the single-pool restriction. Static Cache is not one > cache > shared across pools. FPM creates a separate partition per worker pool in > the > master, before any worker forks; each partition owns its own volatile and > pinned shared-memory backend, and each worker activates only its own pool's > partition during child initialization, before user code runs. Every cache > API, > status call, clear, and the Static Cache part of `opcache_reset()` > operates on > the active pool's partition. There is no API path from one pool to another > pool's data, so the pool boundary stays a security boundary and no policy > change is needed. If a pool's partition fails to start it gets no Static > Cache; > it never falls back to a shared one. > > One honest caveat, for the record: the per-pool segments are anonymous > shared > mappings created in the master before fork, so a worker inherits every > pool's > segment in its address space even though it can only ever address its own > pool's partition. That is the same exposure model as the main OPcache SHM, > which is already shared across pools today; the Static Cache is in fact > more > isolated, because it is logically partitioned per pool where the script > cache > is not. The data-leak-through-the-feature case you raised, one pool reading > another's cached values through the API, does not exist in this design. If > on > top of that we want address-space isolation, so a worker cannot even see > another pool's bytes, that is a worthwhile hardening (per-pool named > segments > mapped only in that pool's children, or unmapping the others post-fork), > and I > am happy to do it as a follow-up if you consider it in scope. > > Your single-pool suggestion would also work, but per-pool partitions keep > the > feature usable for the multi-pool shared-hosting setups where a > single-cache > design would otherwise be unacceptable. > > ## Timo: thanks for the immutable_cache pointer > > > See also Tyson's php-immutable_cache [...] related APCu discussions > > Thank you. Tyson told me about `immutable_cache` himself a while ago, and > it > shaped my thinking here. I built an internal extension along the same > lines, > `colopl_cache`, an APCu-style drop-in for immutable values. What that work > showed me is that the parts that matter most for this use case (OPcache > compatibility, behaviour under a JIT-heavy workload, and the Zend VM > intervention needed for static-state caching) are very hard to get right > as an > ordinary extension. That is why I brought this to OPcache as an RFC > instead of > shipping another extension: it needs cooperation from the engine, the VM, > and a > few internal classes that an extension cannot coordinate cleanly. So the > prior > art is genuinely appreciated; it is part of how I arrived here. > > Best regards, > Go Kudo >
Thanks, Larry's right, I'm just one reviewer among others. I'm raising the points I make not as rebuttals but as food for thought for everybody interested also. And I'm interested in reading what others think about the points I make, especially the object-serialization part. I take no criticism of deepclone at all when you improve your implementation. I submitted a PR on your fork [1] with a significant cleanup of the implementation, basically removing the serialize fallback when copying into SHM. That drops entirely the need for getCacheStoreType() and its enum, which exposed internal concerns anyway. The result is both faster, a win for all. What remains to me (in no specific order): 1. Reserved keys and the FQCN rejection. set()/get() still reject keys starting with the reserved prefixes and reject loaded class names, and delete() still takes $key_or_class, because of the attributes. That's leaky to me. 2. Accepting objects at all - aka implicit serialize on set(), which I argued as something leaky also to me. I appreciate that you showed that doing this in one go provides some perf benefits over my two steps approach. My abstraction-related arguments still stand and I'd be happy to see what others think about this. 3. static methods - see Larry's reply 4. Attributes. You kept them as "ergonomic surface, no new storage model", but that does not answer the cost I raised: the JIT paths, the VM hooks, and the CacheStrategy::Tracking machinery exist only for the attribute case, and the reserved-key leak in #1 is their concrete footprint on the explicit API. None of that is needed by the explicit cache. That is exactly why I would split them into a follow-up: the explicit cache can land and be reviewed on its own, while the attribute semantics (cross-request shared mutable state, mutation tracking) get the separate scrutiny they deserve. Bundled, they couple the review of a simple primitive to the riskiest part of the patch. 5. Pinned/non-volatile I remain unconvinced. You proved that "materialize once per worker, then a near-zero static read per request" is something per-request hydrate cannot match. That is real but it is narrow. If one cares about perf that much, then moving to a worker-based runtime model (aka FrankenPHP workers) provides way more evident perf improvement and doesn't need pinning at all since there, static properties are live for a worker-long duration. I feel like it could be easier for everybody to agree on a tighter RFC and that's what I'm trying to help figure out - the MVP of your proposal. Again, I'd be happy to read more feedback from others now that I laid my current stand. Thanks, Nicolas [1] https://github.com/colopl/php-src/pull/4

Larry Garfield

84 days ago
On Wed, Jun 3, 2026, at 5:53 PM, Nicolas Grekas wrote:
> 4. Attributes. You kept them as "ergonomic surface, no new storage > model", but that does not answer the cost I raised: the JIT paths, the > VM hooks, and the CacheStrategy::Tracking machinery exist only for the > attribute case, and the reserved-key leak in #1 is their concrete > footprint on the explicit API. None of that is needed by the explicit > cache. That is exactly why I would split them into a follow-up: the > explicit cache can land and be reviewed on its own, while the attribute > semantics (cross-request shared mutable state, mutation tracking) get > the separate scrutiny they deserve. Bundled, they couple the review of > a simple primitive to the riskiest part of the patch. > > 5. Pinned/non-volatile I remain unconvinced. You proved that > "materialize once per worker, then a near-zero static read per request" > is something per-request hydrate cannot match. That is real but it is > narrow. If one cares about perf that much, then moving to a > worker-based runtime model (aka FrankenPHP workers) provides way more > evident perf improvement and doesn't need pinning at all since there, > static properties are live for a worker-long duration.
FrankenPHP has been sitting in the back of my head for this whole discussion. :-) The problem is that we are looking at three different levels of potential caching. This RFC - relatively easy to use, may or may not need to re-materialize objects, fairly fast. Compile to disk - hard to implement well, need to re-materialize objects, even faster. Persistent process (Franken, Swoole, etc.) - Super simple to use, fastest option available. I like the idea of this RFC, but part of me wonders if we should just say "use Franken, really." But then the fallback if you aren't running Fraken is the hardest to implement option. So why would I do that, when I have an easier to use option? So, potentially, would that mean the options are "use this RFC or Franken, and the compiled option just kinda fades away?" (At least for data; for generated classes we'd still need it.) I'm not sure. Just me thinking aloud... --Larry Garfield

zeriyoshi

83 days ago
2026年6月9日(火) 18:58 Larry Garfield <larry@garfieldtech.com>:
> On Wed, Jun 3, 2026, at 5:53 PM, Nicolas Grekas wrote: > > > 4. Attributes. You kept them as "ergonomic surface, no new storage > > model", but that does not answer the cost I raised: the JIT paths, the > > VM hooks, and the CacheStrategy::Tracking machinery exist only for the > > attribute case, and the reserved-key leak in #1 is their concrete > > footprint on the explicit API. None of that is needed by the explicit > > cache. That is exactly why I would split them into a follow-up: the > > explicit cache can land and be reviewed on its own, while the attribute > > semantics (cross-request shared mutable state, mutation tracking) get > > the separate scrutiny they deserve. Bundled, they couple the review of > > a simple primitive to the riskiest part of the patch. > > > > 5. Pinned/non-volatile I remain unconvinced. You proved that > > "materialize once per worker, then a near-zero static read per request" > > is something per-request hydrate cannot match. That is real but it is > > narrow. If one cares about perf that much, then moving to a > > worker-based runtime model (aka FrankenPHP workers) provides way more > > evident perf improvement and doesn't need pinning at all since there, > > static properties are live for a worker-long duration. > > FrankenPHP has been sitting in the back of my head for this whole > discussion. :-) The problem is that we are looking at three different > levels of potential caching. > > This RFC - relatively easy to use, may or may not need to re-materialize > objects, fairly fast. > Compile to disk - hard to implement well, need to re-materialize objects, > even faster. > Persistent process (Franken, Swoole, etc.) - Super simple to use, fastest > option available. > > I like the idea of this RFC, but part of me wonders if we should just say > "use Franken, really." But then the fallback if you aren't running Fraken > is the hardest to implement option. So why would I do that, when I have an > easier to use option? > > So, potentially, would that mean the options are "use this RFC or Franken, > and the compiled option just kinda fades away?" (At least for data; for > generated classes we'd still need it.) I'm not sure. > > Just me thinking aloud... > > --Larry Garfield >
Hi Larry
> So, potentially, would that mean the options are "use this RFC or
Franken, and the compiled option just kinda fades away?" (At least for data; for generated classes we'd still need it.) I'm not sure. Yes, that's essentially what I have in mind. However, adapting existing applications to support FrankenPHP (and other memory-resident runners) requires a considerable amount of effort. What I want to achieve with this RFC is to reach that intermediate point— to make a memory-resident cache available on an opt-in basis, even in the traditional execution model, where needed. Of course, I'm aware that there are "workarounds" using preload today. However, those only work with scalar types. Applying such optimizations to existing applications at a "truly effective level" would likely require about the same amount of effort as migrating to FrankenPHP. This RFC fixes this problem at its root. It is entirely opt-in, so users can adopt the feature only after verifying its safety and reliability. Furthermore, I believe that implementing this via Attributes offers the best cost-benefit ratio. An Attribute-based implementation is the only approach that can rival a true memory-resident one. To achieve this, I think an explanation of this complex background and consensus building are necessary. I considered postponing the Attribute-based implementation, but perhaps I should have shared this premise first. I hope this helps move the discussion forward. Best regards, Go Kudo

zeriyoshi

89 days ago
Hi internals and All OPcache Static Cache RFC discussion members. I've made significant changes to the API to bring it to a form that most people will find acceptable. Currently the implementation stub is here https://github.com/colopl/php-src/blob/982a5f4d6bffd033f9c5a02586327c86fcef1cda/ext/opcache/opcache.stub.php And thank you Nicolas, Hi's more optimization and simplification bring better performance and simplism. This means you ever have to worry about how values are stored anymore. Serializer is completely gone. https://github.com/colopl/php-src/pull/4 I sent this email because I wanted to hear thoughts on whether I should decide to **remove the proposed implementation of Attribute based cache from this RFC.** Attribute-based caching is highly efficient and consistently delivers the best performance. But, it also requires the OPcache JIT to intervene. This requires a review by Derick, the developer of JIT, and must be handled with caution. Furthermore, the argument that FrankenPHP should be used if that level of performance is required certainly makes sense. Originally, I drafted this RFC with the goal of “improving performance as an extension of the existing PHP SAPI,” so this would deviate from my initial objective. However, now that I realize there is a demand for a “more faster integrated APCu,” I feel that this goal might need to be deprioritized. This RFC also sparked a discussion about how security boundaries should be handled. I’ve chosen to implement it in a way that SAPI isn’t enabled unless explicitly opted into Do you think this meets the requirements? (I’d especially like to ask Jakub about this.) To explain the process step by step, here is how I would like to proceed: 1. Narrow the scope of this RFC, reach consensus and hold a vote as soon as possible, and ensure it can be incorporated into 8.6. 2. Create a new RFC on Attribute-based Caching to initiate a new discussion (though this may not be ready in time for 8.6.) Please share your opinions. Best regards, Go Kudo 2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>:

Jakub Zelenka

89 days ago
Hi, On Thu, Jun 4, 2026 at 6:49 PM Go Kudo <zeriyoshi@gmail.com> wrote:
> Hi internals and All OPcache Static Cache RFC discussion members. > > I've made significant changes to the API to bring it to a form that most > people will find acceptable. > Currently the implementation stub is here > > https://github.com/colopl/php-src/blob/982a5f4d6bffd033f9c5a02586327c86fcef1cda/ext/opcache/opcache.stub.php > > And thank you Nicolas, Hi's more optimization and simplification bring > better performance and simplism. > This means you ever have to worry about how values are stored anymore. > Serializer is completely gone. > https://github.com/colopl/php-src/pull/4 > > I sent this email because I wanted to hear thoughts on whether > I should decide to **remove the proposed implementation of Attribute based > cache from this RFC.** > > Attribute-based caching is highly efficient and consistently delivers the > best performance. > But, it also requires the OPcache JIT to intervene. > > This requires a review by Derick, the developer of JIT, and must be > handled with caution. >
I think you mean Dmitry.
> Furthermore, the argument that FrankenPHP should be used if that level of > performance is required certainly makes sense. > > Originally, I drafted this RFC with the goal of “improving performance as > an extension of the existing PHP SAPI,” > so this would deviate from my initial objective. However, now that I > realize there is a demand for a “more faster integrated APCu,” > I feel that this goal might need to be deprioritized. > > This RFC also sparked a discussion about how security boundaries should be > handled. > I’ve chosen to implement it in a way that SAPI isn’t enabled unless > explicitly opted into > Do you think this meets the requirements? (I’d especially like to ask > Jakub about this.) > >
Unfortunately I don't currently have much time as I need to spend all my time on stream (we have got quite tight deadline for it). The soonest I can properly spend time on this would be October.
> To explain the process step by step, here is how I would like to proceed: > 1. Narrow the scope of this RFC, reach consensus and hold a vote as soon > as possible, and ensure it can be incorporated into 8.6. > 2. Create a new RFC on Attribute-based Caching to initiate a new > discussion (though this may not be ready in time for 8.6.) > >
It depends on other reviewers as well but I think 8.6 might be a bit too optimistic for this considering the implementation size a potentially security impact if it doesn't work correctly. Kind regards, Jakub

Tim Düsterhus

85 days ago
Konnichiwa Am 2026-05-16 17:19, schrieb Go Kudo:
> RFC: https://wiki.php.net/rfc/opcache_static_cache > Implementation: https://github.com/php/php-src/pull/22052
Thank you for your RFC. I am seeing there already was discussion and changes to the RFC. This seems to have resulted in some inconsistencies. The "Proposal" section still mentions "the volatile cache, used by OPcache\volatile_* […]", but these functions have been renamed. The same applies to the next bullet point for the pinned cache and to the "Why two cache backends" section. Maybe there is even more places where the old function names are referenced. I also noticed some problems with the "Coding Standards and Naming Policy" (https://github.com/php/policies/blob/main/coding-standards-and-naming.rst). Namely: 1. The namespace should be called `Opcache` for proper PascalCase. 2. The `CacheStoreType` cases should follow PascalCase, so `OpcacheSerialized` and `PhpSerialized`. 3. There must be a `OpcacheException` base exception: `class OpcacheException extends Exception { }`. 4. `StaticCacheException` must extend from the `OpcacheException`. 5. The policy does not say anything about properties, but it is generally accepted that properties should also use camelCase instead of underscores. So it needs to be `$startupFailed` instead of `$startup_failed` and similar. I'm still working through the full RFC and the discussion. From what I see the points above are still valid in the latest version of the RFC and the discussion. That is why I am already sending them now. Best regards Tim Düsterhus

zeriyoshi

85 days ago
2026年6月8日(月) 20:09 Tim Düsterhus <tim@bastelstu.be>:
> Konnichiwa > > Am 2026-05-16 17:19, schrieb Go Kudo: > > RFC: https://wiki.php.net/rfc/opcache_static_cache > > Implementation: https://github.com/php/php-src/pull/22052 > > Thank you for your RFC. I am seeing there already was discussion and > changes to the RFC. This seems to have resulted in some inconsistencies. > > The "Proposal" section still mentions "the volatile cache, used by > OPcache\volatile_* […]", but these functions have been renamed. The same > applies to the next bullet point for the pinned cache and to the "Why > two cache backends" section. Maybe there is even more places where the > old function names are referenced. > > I also noticed some problems with the "Coding Standards and Naming > Policy" > (https://github.com/php/policies/blob/main/coding-standards-and-naming.rst). > > Namely: > > 1. The namespace should be called `Opcache` for proper PascalCase. > 2. The `CacheStoreType` cases should follow PascalCase, so > `OpcacheSerialized` and `PhpSerialized`. > 3. There must be a `OpcacheException` base exception: `class > OpcacheException extends Exception { }`. > 4. `StaticCacheException` must extend from the `OpcacheException`. > 5. The policy does not say anything about properties, but it is > generally accepted that properties should also use camelCase instead of > underscores. So it needs to be `$startupFailed` instead of > `$startup_failed` and similar. > > I'm still working through the full RFC and the discussion. From what I > see the points above are still valid in the latest version of the RFC > and the discussion. That is why I am already sending them now. > > Best regards > Tim Düsterhus >
こんにちは Tim, お世話になっています I have recovered my health thanks to the medication. :) Currently implementation has 3.0.0, but RFC document version is 2.0.0, Document is stale status. I'm appealing to the Remove Attribute function currently, Attribute implementation requires the change of JIT implementations. But currently do not respond to ML members. I stopped updating RFC documents and implementations. (Sorry for my bad English, I didn't say this with the intention of criticizing the ML members.) I've implemented this as a pure PHP extension for use at my company. but performance is too bad compared to this RFC implementation. I understand there are security concerns associated with SHM sharing, and I believe I addressed these through an opt-in process on the SAPI side. I would appreciate your feedback on whether this is a sufficient solution. If I were to implement Attribute, this RFC would provide a very fast and efficient cache, but do you think we could get Dmitry's approval? (Derick, I apologize for getting your name wrong,) What is the best option I can offer right now? For now, I want to implement this RFC in a way that everyone desires. Best regards, Go Kudo

Bob Weinand

79 days ago
Hey Go, On 16.5.2026 17:19:31, Go Kudo wrote:
> Hi internals, > > I'd like to start the discussion for a new RFC, OPcache Static Cache. > > RFC: https://wiki.php.net/rfc/opcache_static_cache > Implementation: https://github.com/php/php-src/pull/22052 > > The proposal adds an OPcache-managed shared-memory cache for explicit > userland values and for selected PHP static state. It introduces > explicit functions under the OPcache namespace (volatile_* and > persistent_*) and two attributes, #[OPcache\VolatileStatic] and > #[OPcache\PersistentStatic], that let selected static properties and > method static variables survive across requests. The feature is > disabled by default and only activates once memory is allocated > through the new INI directives. > > The RFC covers the motivation, the deliberate split between the two > backends, the trust model (one PHP runtime = one trust domain; this is > not a tenant isolation boundary), and benchmarks against APCu on NTS > php-fpm and ZTS FrankenPHP. The PR is the full implementation, with > PHPT coverage summarized in the Validation section. > > > Feedback welcome. > > Best Regards, > Go Kudo
I've been trying to digest the RFC and it's quite long-winded. The one thing I don't grasp is the "Security and Trust Model". Sure, if you have the worker pools, like in fpm, it absolutely makes sense to use it. But why is it fundamentally required to have this sort of separation? What's the point? It just means that the whole webserver is a single boundary rather than having the ability to split more precisely. Which in the end is a configuration / system administration setup issue, rather than a fundamental flaw. To me it's a non-starter to exclude apache2handler SAPI from this feature. Regarding the API: I think it might make sense to make the caches non-static classes, with a constructor accepting an optional arbitrary namespace; further split the overloaded behaviour of delete() and getCacheStoreType() - let's not mix classes and arbitrary keys: abstract class Cache {     public abstract function get(string $key, null|bool|int|float|string|array|object $default = null): null|bool|int|float|string|array|object;     public abstract function getMultiple(array $keys, ?array $default = null): array|false;     public abstract function set(string $key, null|bool|int|float|string|array|object $value): bool;     public abstract function setMultiple(array $values): bool;     public abstract function has(string $key): bool;     public abstract function delete(string $key): bool;     public abstract function deleteMultiple(array $keys): bool;     public abstract function clear(): bool;     public abstract function lock(string $key, int $lease = 0): bool;     public abstract function unlock(string $key): bool;     public static function clearClass(string $class_name): bool;     public static function getCacheStoreType(string $key): CacheStoreType;     public static function getPropertyCacheStoreType(string $class_name, string $property): CacheStoreType;     public abstract static function clearAll(): bool;     public abstract static function info(): StaticCacheInfo; } class VolatileCache extends Cache {     public function __construct(string $namespace = "");     // Note that set() and setMultiple() are redeclared with optional `int $ttl = 0` parameters     public function set(string $key, null|bool|int|float|string|array|object $value, int $ttl = 0): bool;     public function setMultiple(array $values, int $ttl = 0): bool;     public static function clearAll(): bool;     public static function info(): StaticCacheInfo; } class PinnedCache extends Cache {     public function __construct(string $namespace = "");     public function increment(string $key, int $step = 1): int|false;     public function decrement(string $key, int $step = 1): int|false;     public static function clearAll(): bool;     public static function info(): StaticCacheInfo; } Given that the API is nearly identical, we can simplify it for both cache types and any library which actually requires $ttl can explicitly require VolatileCache instead of PinnedCache - with just $ttl being different. An *application* author who does not want to carry around an instance of the caches, can trivially write define('VOLATILE', new VolatileCache); once and write VOLATILE->get("mykey") everywhere, getting the same usability than Volatile::get("mykey"), essentially. This will allow you to inject well-scoped caches (instead of relying on the libraries to prefix their keys), declare new cache impls (e.g. "class LocalCache extends Cache", which would just do request-local caching rather than actually storing it) and make it easy to select between PinnedCache and VolatileCache as a library user. I'm pretty confident this is what a lot of people here want to actually have, API wise. Regarding atomic increment/decrement: does it actually matter that volatile does not guarantee continuity? The behaviour of missing key is well-defined. I consider it much better to provide it than have users create their own poor-mans counter of get() + set() combination. Also, a counter on a VolatileCache may actually be useful - e.g. the counter dropping back to zero is an indicator that eviction started. I would not assume this useless. Less useful than a counter of a PinnedCache, yes, but omitting it from VolatileCache is too opinionated. Regarding Storable values: why are Closures not storable? The RFC says: "Closure objects are request-local executable state and cannot be represented as stable shared cache values" But that's not quite true - all Closures, except for those from non-file inclusions, like eval(), are effectively available in opcaches shared memory. And those which are not, could technically be stored too - might be a bit more expensive, but it's seldom the case. Would need some custom serialization though. Not supporting them is a choice, but the reason ("cannot") is the wrong one. Regarding Attributes: Ah, the big contentious topic? I'm not sure what to make of them. I get the appeal of a nice attribute that makes it just work, but it's limited, in sort of subtle ways: - Refreshing the values ... is impossible? The RFC text says manually going through the Volatile/PinnedCache APIs with the 'volatile_static_class:' prefix and such is disallowed? - Do we actually need to reserve these (*_static[_class]:) prefixes? Can't we keep this an implementation detail and make sure that there are dedicated API methods to properly handle it? - Why do we need dedicated prefixes? (volatile_ and pinned_, that is) Can't we look the specific class / property up and derive the required cache internally? - The tracking implementation works by adding a tracking callback to the hot path of every array modification. Minor, but noticeable cost if nothing uses these attributes. Significant cost if attributes are used. Even more significant if writes are alternating (i.e. subsequent writes access different arrays). That's a total non-starter. Maybe you could do some hackery with overloading RW access to static properties specifically to assume dirtied. But the current implementation is a no-no. I get that this also affects stored objects and such. But, as said, too invasive/expensive.   I really wonder whether you sat down at any point here and asked yourself "should I really do this?", given all its complexity here. - Scalar values are also not auto-refreshed, which probably makes for surprising behavior when trying to increment some counter, and other requests increment in parallel. I'm not too happy about the developer experience around this. Overall, the attributes probably need quite a bit more discussion and refinement. I think it might be worth splitting it fully off into its own RFC. And the implementation will be probably also downsized by more than half, making it a bit more manageable to review and assess. The general integration of APCu-like capabilities with separate pinned and volatile caches make a lot of sense to me. With some minor refinements, I think I'd like to see this functionality in PHP! Thanks for your effort on this RFC, Bob