Skip to content

Make OPcache actually cache the phar in the restarted process - #6324

Merged
ondrejmirtes merged 3 commits into
2.2.xfrom
opcache-phar-caching
Sep 1, 2026
Merged

Make OPcache actually cache the phar in the restarted process#6324
ondrejmirtes merged 3 commits into
2.2.xfrom
opcache-phar-caching

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Sep 1, 2026

Copy link
Copy Markdown
Member

Follow-up to 47f7b00 (OPcache activation in the turbo restart), which made phar runs slower: slevomat went from 688s to 888s user CPU (+29%).

Root cause

OPcache was caching nothing of PHPStan itself. Every member of the distributed phar has mtime 0, and opcache_compile_file() refuses any file whose mtime it reads as 0 — which it reads whenever validate_timestamps or file_update_protection (default 2) is on, i.e. always by default. Being compiled under an active OPcache but never persisted is worse than no OPcache at all: the compiler's string interning is redirected into SHM and only returns strings already there, zend_alloc_ce_cache() needs an interned string, so PHPStan's type names never get a class-entry cache slot and every class-typed parameter/return/property check falls back to zend_fetch_class() (lowercased copy + class-table lookup + memcmp). That is exactly what the native profile showed: zend_string_tolower_ex, zend_lookup_class_ex, zend_hash_find dominating.

Source checkouts have real mtimes, which is why self-analysis sped up while phar-based projects slowed down.

Changes

  1. Make the restarted process actually cache the pharvalidate_timestamps=0, file_update_protection=0 (a private cache that dies with the process revalidates nothing), and buffers raised above the stock 128M/8M/10000: the stock 8M of interned strings run out during PHPStan's own boot already (~21M needed), and everything compiled after any buffer is exhausted lands in the same uncached, uninterned state.
  2. Stamp phar members with their commit date in phar.yml (resign.php with a second argument) so OPcache caches the phar under stock settings too, and so validate_timestamps=1 file caches can tell versions apart (a fixed date could not). The checksum build keeps its fixed 2017 date and stays reproducible; the workflow fails if any member is left at mtime 0.
  3. Keep the restarted process's OPcache private and adapt it to the php.ini — a web-tuned php.ini can turn the activation into something worse than a slowdown: opcache.file_cache would persist this run's opcodes and (validated by build id + mtime only) serve the previous PHPStan's opcodes after an update → blanked; save_comments=0 breaks annotation readers in bootstrapped project code → pinned on; max_file_size also triggers the mtime read → pinned to 0; file_cache_only (blanking is a fatal startup error) and preload (would run the app's preload script inside PHPStan, no CLI exemption, rejects an empty -d) → back off entirely; user-granted sizes are never lowered and the interned buffer stays below the memory it is carved from (fatal otherwise). The logic is a pure resolveOpcacheArgs() with unit tests for each case.

Measurements (fork mode, 10 workers, cold, same phar unless noted)

project OPcache off this branch
Lorem (13.8k files) 684s user · 717 MB main · 833 MB largest worker 613s (−10.5%) · 567 MB · 678 MB
Dolor (phar ab63c95 vs. this branch's phar) 1536s / 1504s user · 3118 MB max RSS 1345s / 1345s (−11.5%) · 3158 MB

Output byte-identical in every leg. SHM sizing sweep on ShipMonk: 256M/64M (shipped) 1345s; 512M 1337s (noise, +280 MB RSS); 1024M 1373s, sys 2.3×, +460 MB RSS — ShipMonk's workers include ~48k project scripts and persisting them all serializes on OPcache's global write lock, so bigger is worse; the shipped sizes are at the optimum for both projects.

Not changed (deliberately): ForkParallelChecker still declines fork when JIT is on — moot in the restart path since the restarter pins JIT off, kept as a last-line check for php.ini-loaded turbo / source checkouts.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ek2A83bWb3Yf4CRmtvBfi4

Enabling OPcache in the restarted process made phar runs slower, not
faster (slevomat: +29% user CPU). OPcache was caching nothing of
PHPStan itself: opcache_compile_file() refuses any file whose mtime it
reads as 0, and every member of the distributed phar has exactly that.
It reads the mtime whenever opcache.validate_timestamps *or*
opcache.file_update_protection (default 2) is on, so the default
configuration never caches the phar.

Compiled-under-OPcache-but-not-cached is worse than no OPcache: the
compiler's string interning is redirected into shared memory and only
returns strings already there, so type names of uncached code never get
a class-entry cache slot, and every class-typed parameter, return and
property check falls back to zend_fetch_class() — a lowercased copy plus
a class-table lookup, visible as zend_string_tolower_ex,
zend_lookup_class_ex and zend_hash_find dominating the profile.

Both timestamp checks are now off (a private cache that dies with the
process revalidates nothing), and the buffers are raised above the stock
128M/8M/10000 because exhausting any of them puts everything compiled
afterwards into the same uncached state; project bootstraps loaded by
extensions exhausted them on slevomat. The reservation stays moderate:
an SHM allocation failure is fatal in the restarted process.

slevomat, 10 forked workers, same phar: 684s user / 717 MB main /
833 MB worker with OPcache off; 608-611s / 567-575 MB / 632-634 MB with
these flags.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ek2A83bWb3Yf4CRmtvBfi4
Comment thread .github/workflows/phar.yml Fixed
ondrejmirtes and others added 2 commits September 1, 2026 10:00
Every member of the distributed phar had mtime 0, and OPcache refuses
to cache a file whose mtime it reads as 0 — which, with the stock
opcache.file_update_protection=2, it always reads. Anyone running the
phar with opcache.enable_cli=1 therefore got the slow
compiled-but-uncached mode for all of PHPStan's own code.

The distributed build now goes through resign.php like the checksum
build, but with the commit date instead of the fixed 2017 one: a file
cache (opcache.file_cache) is validated by mtime alone, so a date that
is identical across versions would keep serving the previous PHPStan's
opcodes after an update. The checksum build keeps the fixed date and
stays byte-reproducible. The workflow fails if any member is left at
mtime 0, so a Box or PHP upgrade cannot silently bring the slow mode
back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ek2A83bWb3Yf4CRmtvBfi4
A php.ini tuned for the web server can turn the OPcache activation into
something worse than a slowdown:

- opcache.file_cache would persist this run's opcodes and, being
  validated by build id and mtime only (and the mtime checks are off
  here), serve the previous PHPStan's opcodes after an update at the
  same phar path — it is blanked.
- opcache.save_comments=0 strips the doc comments annotation readers in
  the bootstrapped project code depend on — pinned on.
- opcache.max_file_size also triggers the mtime read that refuses
  zero-mtime phar members — pinned to 0.
- opcache.file_cache_only makes blanking the file cache a fatal startup
  error and usually means shared memory is unavailable on purpose;
  opcache.preload would run the application's preload script inside
  PHPStan (there is no CLI exemption) and rejects an empty -d value.
  Both leave the configuration untouched, as before this feature.
- Buffer sizes the php.ini already grants are never lowered, and the
  interned strings buffer stays below the memory it is carved out of
  (a fatal startup error otherwise).

The raised buffers are not tuning: with the stock 128M/8M the interned
strings run out during PHPStan's own boot, the lazily loaded classes
end up uncached and uninterned, and the whole gain is gone (slevomat
app/model: 24.8s user against 23.7s with OPcache off and 23.5s with the
buffers raised). Full slevomat with 256M/64M: 613s user, 678 MB largest
forked worker, against 684s / 833 MB with OPcache off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ek2A83bWb3Yf4CRmtvBfi4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants