Skip to content

[RFC] OPcache Static Cache Implementation#22052

Open
zeriyoshi wants to merge 28 commits into
php:masterfrom
colopl:opcache_static_cache_impl
Open

[RFC] OPcache Static Cache Implementation#22052
zeriyoshi wants to merge 28 commits into
php:masterfrom
colopl:opcache_static_cache_impl

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 the RFC and more detailed design notes for this implementation, please see:

The API naming, status-object surface, explicit unlock functions, 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.
  • 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 volatile API is:

  • OPcache\volatile_store()
  • OPcache\volatile_store_array()
  • OPcache\volatile_fetch()
  • OPcache\volatile_fetch_array()
  • OPcache\volatile_exists()
  • OPcache\volatile_delete()
  • OPcache\volatile_delete_array()
  • OPcache\volatile_clear()
  • OPcache\volatile_lock()
  • OPcache\volatile_unlock()
  • OPcache\volatile_cache_info()

The explicit pinned API is:

  • OPcache\pinned_store()
  • OPcache\pinned_store_array()
  • OPcache\pinned_fetch()
  • OPcache\pinned_fetch_array()
  • OPcache\pinned_exists()
  • OPcache\pinned_delete()
  • OPcache\pinned_delete_array()
  • OPcache\pinned_clear()
  • OPcache\pinned_lock()
  • OPcache\pinned_unlock()
  • OPcache\pinned_atomic_increment()
  • OPcache\pinned_atomic_decrement()
  • OPcache\pinned_cache_info()

The attribute API is:

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

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 *_store_array(), or static attributes.

Explicit cache APIs return false for static-cache operation failures by default, and accept bool $throw_on_error = false so callers can opt into OPcache\StaticCacheException. Invalid arguments still use the normal TypeError/ValueError paths. Fetch APIs 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.

Single-key *_fetch() 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 *_fetch(). 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.

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\*_lock($key) provides 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.

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 and shared-worker behavior.
  • 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.
  • 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.

@zeriyoshi zeriyoshi force-pushed the opcache_static_cache_impl branch from 5e987e3 to 0f6078a Compare May 19, 2026 10:14
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

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