Skip to content

Skip file_exists() on the hot path: an OPcache-cached require needs no stat() #133

@koriym

Description

@koriym

Summary

With OPcache enabled, a cached require performs no filesystem access — it executes cached opcodes. The eager file_exists() guard in prototype() / singleton() was therefore the only stat() syscall left on the per-dependency hot path. Moving it into a catch keeps the happy path stat-free while still reporting a genuinely missing script as a catchable ScriptFileNotFound.

Change

src-function/prototype.php, src-function/singleton.php:

try {
    return require $file;            // happy path: no stat(), cached opcodes
} catch (Throwable $e) {
    if (! file_exists($file)) {      // stat() only on failure
        throw new ScriptFileNotFound($filePath, 0, $e);
    }
    throw $e;                        // file exists -> error came from inside the script
}

This relies on PHP 8 making a failed require a catchable Error/ErrorException (the project already requires PHP ^8.2). try/catch is zero-cost on the happy path, and putting file_exists() inside the catch distinguishes a missing file from an error thrown within the script without fragile message matching.

CompiledInjector::getInstance() keeps its file_exists() pre-check on purpose — it reports unbound interfaces as Unbound, is called once per top-level resolution (not per nested dependency), and the pre-check avoids a spurious warning on that path. Its redundant realpath($this->scriptDir) (the value is already canonicalised in the constructor) was removed.

Measurements

FakeCar graph (ctor + 5 setters + AOP + singleton mirrors), PHP 8.4, OPcache valid, steady-state per build:

compiled, per build
before (eager file_exists()) ~32.7 µs
after (file_exists() in catch) ~22.3 µs

~30% faster, and ~2.1× faster than the reflection / serialized injector (~46 µs).

Why this is easy to mis-benchmark

opcache.file_update_protection (default 2s) refuses to cache files younger than that. A benchmark that compiles and immediately measures re-parses every require and shows the compiled injector at ~178 µs — a pure re-parse artifact. A sleep() does not help on the CLI (OPcache's age check uses the request start time). Back-date the generated scripts (touch) or set opcache.file_update_protection=0, and verify the OPcache hit rate.

Docs / benchmark

  • docs/performance.md — the three strategies, the OPcache prerequisite, and benchmarking pitfalls.
  • benchmark/di_benchmark.php — a self-validating benchmark that prints the OPcache hit rate, so a (valid) run is distinguishable from a re-parse artifact.

All checks pass (phpunit, phpcs, psalm, phpstan).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions