Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

[RFC] OPcache Static Cache Implementation#22052

Open
zeriyoshi wants to merge 40 commits into
php:masterphp/php-src:masterfrom
colopl:opcache_static_cache_implcolopl/php-src:opcache_static_cache_implCopy head branch name to clipboard
Open

[RFC] OPcache Static Cache Implementation#22052
zeriyoshi wants to merge 40 commits into
php:masterphp/php-src:masterfrom
colopl:opcache_static_cache_implcolopl/php-src:opcache_static_cache_implCopy head branch name to clipboard

Conversation

@zeriyoshi
Copy link
Copy Markdown
Contributor

@zeriyoshi zeriyoshi commented May 15, 2026

RFC: https://wiki.php.net/rfc/opcache_static_cache

OPcache Static Cache

Summary

This PR adds OPcache Static Cache, an OPcache-managed shared-memory cache facility with two separately configured backends:

  • A volatile backend, enabled by opcache.static_cache.volatile_size_mb, for recoverable process-shared cache entries and #[OPcache\VolatileStatic].
  • A pinned backend, enabled by opcache.static_cache.pinned_size_mb, for strict process-shared entries and #[OPcache\PinnedStatic].

Both backends default to 8 MiB, the documented minimum non-zero size. A zero size disables the corresponding backend; any non-zero size reserves a dedicated OPcache SHM segment for that backend. The storage header and entry table are initialized eagerly, while payload pages are touched lazily on first allocation to avoid paying startup cost proportional to the full configured cache size.

For FPM, Static Cache storage is separated per worker pool. The FPM master creates one volatile and one pinned backend per configured pool before workers are forked, and each child activates the partition for its pool during child initialization. This preserves the FPM pool boundary for explicit Static Cache keys and attribute-backed static state while keeping the feature enabled by default.

Availability is opt-in per SAPI rather than gated by an INI directive. A SAPI or embedder calls the internal zend_opcache_static_cache_opt_in() (or registers a scoped partition) before request handling to enable Static Cache for its runtime; that call is the runtime's own declaration that a trust/storage boundary holds for the lifetime of its shared-memory owner. The bundled FPM, CLI, CLI server, and phpdbg SAPIs opt in during startup, so they are available by default. The embed SAPI does not auto-opt-in: an embedder owns its runtime and trust boundary and opts in from its own startup code (this also covers embedders that register their own SAPI, such as FrankenPHP). Runtimes that cannot establish a boundary before request handling — shared multi-tenant web SAPIs with no pre-request identity, such as apache2handler, LiteSpeed/LSAPI, and cgi-fcgi — simply never opt in and stay unavailable, with no INI override.

For the RFC and more detailed design notes for this implementation, please see:

The API naming, status-object surface, explicit unlock methods, attribute deletion semantics, and trust-domain documentation reflect the discussion on internals:

At a high level, this change includes:

  • New public APIs in the OPcache\ namespace.
  • Static-property and method-static integration through attributes.
  • OPcache SHM storage, lookup, allocation, compaction, expiration, and status reporting.
  • VM hooks for tracked array/object mutation.
  • JIT updates so static-property fast paths stay consistent with the VM hooks.
  • A serializer and shared-graph representation for values that can be restored without running userland code under cache locks.
  • FPM pool-local Static Cache partitions for shared-hosting pool isolation.
  • PHPT coverage for the public API, storage behavior, static attributes, FPM, fork handling, ZTS helper programs, JIT, preload, and mutation hook safety.

User-visible API

The explicit cache API is exposed as two final classes with static methods only, OPcache\VolatileCache and OPcache\PinnedCache (no instances, no shared interface).

OPcache\VolatileCache:

  • get(string $key, null|bool|int|float|string|array|object $default = null): null|bool|int|float|string|array|object
  • getMultiple(array $keys, ?array $default = null): array|false
  • set(string $key, null|bool|int|float|string|array|object $value, int $ttl = 0): bool
  • setMultiple(array $values, int $ttl = 0): bool
  • has(string $key): bool
  • delete(string $key_or_class): bool
  • deleteMultiple(array $keys): bool
  • clear(): bool
  • lock(string $key, int $lease = 0): bool
  • unlock(string $key): bool
  • getCacheStoreType(string $key_or_property, ?string $class_name = null): OPcache\CacheStoreType
  • info(): OPcache\StaticCacheInfo

OPcache\PinnedCache has the same methods (with set()/setMultiple() taking no TTL) plus:

  • increment(string $key, int $step = 1): int|false
  • decrement(string $key, int $step = 1): int|false

The attribute and supporting type API is:

  • #[OPcache\VolatileStatic(ttl: 0, strategy: OPcache\CacheStrategy::Immediate)]
  • #[OPcache\VolatileStatic(strategy: OPcache\CacheStrategy::Tracking)]
  • #[OPcache\PinnedStatic]
  • OPcache\CacheStrategy
  • OPcache\CacheStoreType
  • OPcache\StaticCacheInfo
  • OPcache\StaticCacheException

getCacheStoreType() returns an OPcache\CacheStoreType enum case — NotFound, Scalar, SharedGraph, OPcacheSerialized, or PHPSerialized — reporting how a value is stored without decoding it. With $class_name it inspects the attribute-backed static-property storage for $class_name::$key_or_property; otherwise it inspects the explicit cache key. This makes the chosen storage strategy, including the otherwise silent serializer fallbacks, observable per key in any build.

There is no userland-visible safe-direct marker class or attribute. Direct restore is enabled only for internal classes whose owning extension registers OPcache safe-direct handlers through the C API.

API Contracts

Single-key APIs require non-empty string keys. Batch fetch/delete APIs accept arrays containing only non-empty strings or integers, and integer keys are converted without invoking userland code. Batch store APIs require non-empty string array keys. Invalid or empty keys throw ValueError.

Stored values may be null, bool, int, float, string, array, or object. Resources and Closure instances are rejected during API validation or store preparation, including when they are reached through arrays, object properties, __serialize() result arrays, __sleep() selected properties, values published by setMultiple(), or static attributes.

Explicit cache methods return false (or int|false for the pinned atomic methods) for static-cache operation failures; they never raise OPcache\StaticCacheException. Invalid arguments use the normal TypeError/ValueError paths. Fetch methods return the provided default on miss, so callers can use the usual sentinel/default idiom. PinnedStatic publications remain strict and throw OPcache\StaticCacheException, because assignment and mutation sites are durable-in-this-segment pinned state publication points; that exception is reserved for those attribute-backed publication failures.

Single-key get() calls keep request-local fetch state keyed by cache context, cache key, and mutation epoch. Successful fetches attempt to memoize a prototype zval slot reconstructed from the stored payload, and same-request hits copy from that slot when the value is supported by the request-local clone path. Object-free arrays keep PHP's ordinary copy-on-write behavior and avoid repeated PHP value graph reconstruction. Object-bearing values return a fresh object graph cloned from the request-local prototype by an internal path that does not invoke userland __clone, so object handles are not shared with values returned by earlier or later fetches. Ordinary PHP objects use OPcache's std-object clone helper, and safe-direct internal objects use per-class copy handlers registered by their owning extension. Mutating a fetched object graph therefore does not mutate another fetched value, the request-local prototype, or the stored cache entry.

The attribute API is intentionally more than syntactic sugar over get(). Explicit key/value fetches must return an independent PHP value for each object-bearing fetch, so repeated object reads either reconstruct the PHP value graph from storage or clone from a request-local prototype using OPcache-controlled ordinary-object and safe-direct copy handlers. Attribute-backed static properties and method statics restore into the request's static slot once and ordinary reads use that slot directly. This means safe-direct internal state pays either the restore or prototype-copy cost at explicit-fetch time, but only the static-slot initialization cost for attribute-backed static reads.

Storage Model

Each backend owns a separate storage context with its own SHM segment, lock file, entry table, allocator state, lookup cache, and status surface. 0 disables the backend. Non-zero sizes are validated as system INI settings and cannot be changed after OPcache startup. The mmap backend uses a dedicated anonymous shared mapping for static-cache storage, separate from OPcache's main shared-memory allocator setup.

Availability is gated by the per-SAPI opt-in described above rather than by an INI directive. The bundled CLI, CLI server, and phpdbg SAPIs opt in during startup and use the configured backends directly; CLI-style execution does not create a PHP-hosted shared-hosting tenant boundary. The embed SAPI does not auto-opt-in, so an embedder (including a runtime that registers its own SAPI) enables Static Cache by calling the opt-in from its own startup code, scoping the backend to its runtime/process sharing model. Non-FPM shared web runtimes that cannot be partitioned before request handling never opt in and stay unavailable. In FPM, the storage-context model is applied per worker pool: the master creates one volatile and one pinned partition per pool before workers fork, each child activates its pool's partition before request handling, and a pool has its own volatile and pinned contexts, status surface, mutation epochs, clear/reset handling, lookup caches, and request-shutdown publication. The configured memory budget is therefore per enabled FPM pool.

Entries are stored in an open-addressed table. Payload storage uses a compact SHM allocator with free-list reuse, tail trimming, and compaction. The allocator can relocate key, string, serialized payload, and unreferenced shared-graph payload blocks. Shared graphs that are pinned by an active request, or that have been retired while a request still holds a reference, remain immovable anchors until the last request reference is released. When an unreferenced shared graph is moved, OPcache rebases the graph's internal direct-array pointers and updates entry offsets under the backend write lock.

The volatile backend performs proactive fragmentation recovery before the tail allocation area is exhausted: if the remaining tail space is below 3 MiB, or the pending allocation would reduce it below 3 MiB, allocation may compact movable blocks when free-list fragmentation exists and compaction can actually move data. Store-failure recovery still expunges expired volatile entries before the final compact-to-fit attempt.

A 64-bit mutation epoch is bumped by operations that can invalidate request-local observations: store, delete, clear, invalidation, compaction, and expiration cleanup. Mutation epochs are stored as uint64_t. Epoch 0 is the initial state and is also used as the sentinel for uninitialized request-local lookup-cache entries. If incrementing the counter would wrap it back to 0, OPcache advances it to 1 instead, so a freshly bumped epoch cannot be confused with the uninitialized state. Request-local lookup-cache entries and single-key fetch prototype slots are only reused while their epoch matches the current SHM header epoch.

Locking and Fork Safety

The default process lock on Unix is a byte-range fcntl() lock over a cache-specific lock file. The implementation uses blocking F_SETLKW for read/write cache locks and F_SETLK/F_SETLKW for entry reservation stripes where non-blocking behavior is required.

In ZTS builds, process-local heap locks are layered on top of process locks so threads in the same worker serialize correctly without placing pthread mutex state in the shared mapping. The entry-lock state records the owning PID. After fork(), a child drops inherited request-local reservation state, reinitializes the process-local ZTS entry locks, and does not release the parent's reservations during child shutdown.

OPcache\VolatileCache::lock($key) and OPcache\PinnedCache::lock($key) provide a request-retained reservation lock for single-builder patterns. Public store and pinned atomic mutators wait on the matching reservation before committing changes. Delete, clear, and opcache_reset() bypass reservation locks to avoid stripe deadlocks, so they are not barriers against later publishes by already-reserved builders. Key reservations are also not a sufficient safety boundary for shared-graph compaction, because materialized request values can keep payload references after the visible key has changed. Referenced shared-graph payloads are retired and freed only after the last request releases them.

Store and Fetch Safety

Store operations separate value preparation from the final SHM publish step. Snapshotting, shared-graph construction, and serialization preparation happen outside the cache write lock. The write lock is held only while the prepared payload is committed to SHM and the entry table is updated.

Repeated explicit stores of the same clean array/object graph in one request may reuse a request-local prepared shared-graph buffer. Mutation hooks dirty that prepare memo when reachable source arrays or objects change, and safe-direct/internal objects are excluded from that memo path. Offset-backed payload commits may allocate combined value+key blocks to reduce allocator churn.

Fetch operations avoid PHP value graph reconstruction while holding the cache read lock:

  • Serialized payload bytes are copied while locked, then unserialized after unlocking.
  • OPcache-serialized payload bytes are copied while locked, then restored after unlocking.
  • Shared-graph payloads are pinned while locked, restored after unlocking, and released at request shutdown.

Retired shared graphs are not freed while any request still holds a pin. A graph whose entry is overwritten or deleted is retired first, and the underlying SHM block is released only after the last request reference is gone.

Request shutdown releases request-held shared-graph pins and frees newly eligible retired payloads under the normal write lock. It does not run a whole-storage compaction pass, so the steady request-shutdown path avoids moving unrelated live payloads.

Static State Integration

#[OPcache\VolatileStatic] and #[OPcache\PinnedStatic] can be applied to classes, methods, and properties. The implementation installs hooks for class static initialization, function static initialization, class static access, and class static update so attribute-backed static slots can be restored and published at the same points the engine creates or accesses the corresponding static storage.

VolatileStatic supports two strategies:

  • Immediate publishes the static root value when the root is assigned.
  • Tracking tracks reachable arrays/objects and publishes the final dirty state at request shutdown.

PinnedStatic uses the pinned backend and strict failure semantics. Capacity, encoding, and unsupported-value failures throw OPcache\StaticCacheException at the assignment, mutation, or publication site. For array roots, the engine mutation hook observes mutations before copy-on-write separation and the static-cache code rechecks root identity before publishing, so mutations to local copies do not publish unrelated values.

Class-level attributes use a class-blob path so static properties and dynamic method statics can be restored together when the class is accessed. Script invalidation and opcache_reset() invalidate the associated static-cache keys in the active Static Cache partition.

VM and JIT Integration

The VM gains a guarded mutation-hook mechanism:

  • EG(tracked_mutation_hooks_active) keeps the normal VM path cheap when no tracking is active.
  • EG(static_cache_class_access_active) guards class-static and function-static initialization hooks so hook pointers are not called outside the OPcache static-cache request lifecycle.
  • Array mutation hooks run before SEPARATE_ARRAY() where the original root identity is still observable.
  • Object mutation hooks run after property or dimension mutation and skip error zvals.
  • Reference update hooks keep tracked roots aligned when a static slot is rebound through a reference.

The JIT static-property fast path calls zend_jit_static_prop_access_helper() even when the runtime cache already resolved the property slot. This keeps class-blob state refreshes consistent with zend_fetch_static_property_address(). The JIT path checks for exceptions after the helper call before continuing with the inlined static-property access.

Serialization and Direct Restore

The OPcache serializer encodes scalar, array, object, and shared-reference structures into SHM-safe payloads. Decode paths copy header/state data into aligned local storage before reading fields, avoiding unaligned access to SHM bytes.

Some internal classes can be restored through direct OPcache-controlled handlers instead of userland serialization. This is limited to engine-vetted internal classes registered in OPcache's C-level safe-direct handler table. ext-date registers handlers for Date/Time classes, and ext-spl registers handlers for supported SPL collection classes during their own module initialization; the copy, unstorable-state detection, state serialization, and state unserialization callbacks remain private to the owning extension. This representation is tied to the current PHP build and is not an external persistence or interchange format.

For ordinary objects, a user-defined __serialize() moves the value to PHP serialization fallback, and __serialize()/__unserialize() run outside cache locks. A registered safe-direct base may keep the direct path when its handler policy allows custom serializers; the Date/Time handlers allow this and encode normal object properties as part of the stored state. Changed __sleep()/__wakeup() handlers, or a safe-direct handler policy that disallows custom serializers, force fallback serialization.

Test Coverage

The commit adds PHPT coverage for:

  • Volatile and pinned public APIs, signatures, key validation, value validation, TTLs, delete/clear, status, overflow, and pinned atomic counters.
  • Request-local slots, same-request object-copy isolation for ordinary objects with userland __clone, registered-handler copying for safe-direct internal objects, hidden safe-direct marker behavior, lookup-cache behavior, mutation epochs, allocator reuse, fragmentation, relocation, low-memory compaction, and compact-to-fit recovery.
  • Store/fetch safety when userland serialization or PHP value graph reconstruction is involved.
  • Shared-graph restoration, copy-on-write behavior, request pins, retired graph cleanup, movable unreferenced shared graphs, and immovable referenced shared-graph anchors.
  • Reservation locks across request shutdown, public mutators, clear(), opcache_reset(), fork, and ZTS helper programs.
  • VolatileStatic immediate/tracking behavior, PinnedStatic, PinnedStatic failure exceptions, class-level blobs, method statics, inherited attributes, readonly properties, preload, JIT, default non-zero startup with tracing JIT/protect_memory, request-guarded static init hooks, and script invalidation.
  • FPM worker persistence, shared-worker behavior, multi-pool separation, and per-SAPI opt-in/default-disabled behavior.
  • getCacheStoreType() reporting for explicit keys and attribute-backed static properties across the NotFound/Scalar/SharedGraph/OPcacheSerialized storage strategies.
  • Engine mutation hook ordering and safety in Zend/tests.

The test suite also includes C helper programs under ext/opcache/tests/helpers/ for fork/ZTS scenarios that are difficult to cover from a single PHPT process alone.

Areas Where Focused Review Would Be Especially Helpful

I would especially appreciate review of the following correctness boundaries:

  • No userland key conversion is performed while cache locks are held.
  • Userland serialization and PHP value graph reconstruction are moved outside cache locks.
  • Shared-graph payload lifetime is protected by request pins and retired-payload cleanup; compaction must treat referenced graphs as anchors and rebase moved unreferenced graphs correctly.
  • Request-local cached observations are guarded by the SHM mutation epoch.
  • The prototype clone path deliberately skips userland __clone, while safe-direct internal objects use registered extension-owned copy handlers for their internal state.
  • ZTS locks are process-local, while cross-process coordination uses byte-range process locks.
  • Fork children are prevented from releasing parent-owned reservation state.
  • FPM pool activation happens before user code can access Static Cache state, and no FPM path falls back to a cross-pool global backend after per-pool startup failure.
  • FPM, CLI, CLI server, and phpdbg opt in during startup; the embed SAPI and any runtime that registers its own SAPI opt in from their own startup code; other non-FPM web shared-hosting style runtimes never opt in and stay unavailable, with no INI override. The opt-in must run before php_module_startup.
  • JIT has a few targeted changes in this PR, so please confirm that the JIT-side updates look sound.
  • JIT static-property access is kept in sync with the VM static-property hook path and checks exceptions after the helper call.

Comment thread Zend/zend_atomic.h
@@ -104,7 +104,7 @@ static zend_always_inline int zend_atomic_int_exchange_ex(zend_atomic_int *obj,
}

static zend_always_inline bool zend_atomic_bool_compare_exchange_ex(zend_atomic_bool *obj, bool *expected, bool desired) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

nits: These arguments are reversed

@zeriyoshi zeriyoshi force-pushed the opcache_static_cache_impl branch from 93ebc45 to 27e359b Compare May 20, 2026 07:26
@zeriyoshi zeriyoshi force-pushed the opcache_static_cache_impl branch from 27e359b to af253a3 Compare May 28, 2026 08:32
@zeriyoshi
Copy link
Copy Markdown
Contributor Author

Hello @dstogov

I’ve been working on this implementation to allow shared-memory persistence for static properties/variables within OPcache.

Benchmarks show a significant performance leap for complex objects/arrays:

  • APCu: ~86μs - 160μs (serialization cost)
  • OPcache Static Cache: ~0.3μs (zero-copy access)

I’m particularly concerned about the interaction with the Zend Engine’s VM state and potential side effects on future JIT optimizations. Could you please take a look at the memory allocation strategy in ext/opcache/static_cache.c?

For more architectural context and community feedback, I have the full discussion thread on Internals ML here: https://externals.io/message/130912

Thanks,
Go

@zeriyoshi zeriyoshi force-pushed the opcache_static_cache_impl branch from b9275a7 to 3f0ebc4 Compare June 3, 2026 15:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

Morty Proxy This is a proxified and sanitized view of the page, visit original site.