From b3c7ee9bd3ef66b234e2a8144f7351a3cf60bb7a Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Fri, 4 Sep 2026 18:54:13 +0800 Subject: [PATCH] feat(compiler): add static cache optimization and late static call improvements - Implement exact-class fast path for zero-argument late-static calls with fallback mechanism - Add static property fetch resolution using typephp_get_static_property symbol - Enhance isset operations on static array properties with direct typephp_array_isset calls - Update class reference detection to return Type::STR for class constant fetch expressions - Add benchmark suite for static cache performance measurement including late static dispatch - Modify compiler symbols to use typephp variants for called class and static property access - Create comprehensive tests for static array single offset presence and late static call exact guards - Update unit tests to reflect new symbol names and variable --- benchmark/README.md | 1 + benchmark/static-cache/.gitignore | 3 + benchmark/static-cache/README.md | 16 +++ benchmark/static-cache/benchmark.php | 75 +++++++++++ benchmark/static-cache/project.yml | 8 ++ benchmark/static-cache/run.php | 124 ++++++++++++++++++ phpunit/src/LocalVariableInitializerTest.php | 8 +- phpunit/src/LoopControlTest.php | 4 +- phpunit/src/NativePropertyTest.php | 2 +- phpunit/src/SymbolTest.php | 9 +- src/CompilerBase.php | 26 ++++ src/Generator/Symbol.php | 9 +- src/Parser/MethodCallTrait.php | 51 +++++++ src/Parser/PropertyAccessTrait.php | 2 +- .../static/late-static-call-exact-guard.phpt | 42 ++++++ .../static-array-single-offset-presence.phpt | 46 +++++++ 16 files changed, 414 insertions(+), 12 deletions(-) create mode 100644 benchmark/static-cache/.gitignore create mode 100644 benchmark/static-cache/README.md create mode 100644 benchmark/static-cache/benchmark.php create mode 100644 benchmark/static-cache/project.yml create mode 100644 benchmark/static-cache/run.php create mode 100644 tests/compiler/static/late-static-call-exact-guard.phpt create mode 100644 tests/compiler/static/static-array-single-offset-presence.phpt diff --git a/benchmark/README.md b/benchmark/README.md index 27ffd30a..2c210a2e 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -13,6 +13,7 @@ machine instead of committing absolute timing expectations. call-site cache changes can be evaluated independently from direct AOT calls. - `property-access/` builds and compares dynamic/static property access under Zend PHP and TypePHP. +- `static-cache/` measures static-array caches and late-static method dispatch. Run the property benchmark from the repository root: diff --git a/benchmark/static-cache/.gitignore b/benchmark/static-cache/.gitignore new file mode 100644 index 00000000..c4b5c93a --- /dev/null +++ b/benchmark/static-cache/.gitignore @@ -0,0 +1,3 @@ +/build/ +/static_cache +/static_cache.exe diff --git a/benchmark/static-cache/README.md b/benchmark/static-cache/README.md new file mode 100644 index 00000000..f9890376 --- /dev/null +++ b/benchmark/static-cache/README.md @@ -0,0 +1,16 @@ +# Static class cache benchmark + +This benchmark covers a common metadata-cache pattern: a static array keyed by +`static::class`, guarded by `isset()`, plus a wrapper method using +`static::method()`. It measures static-property lookup, array lookup, strict +return checks, and late-static dispatch together. + +Run it from the repository root against matching Release PHP and PHPX builds: + +```bash +PHPX_HOME=../phpx PHP_BIN=/opt/php-8.5-nts/bin/php php benchmark/static-cache/run.php +``` + +The TypePHP binary is built with `-O3` and LTO. Results use the best of five +measured rounds after two warm-up rounds; checksums must match before ratios are +reported. Use `--skip-build` to reuse the existing binary. diff --git a/benchmark/static-cache/benchmark.php b/benchmark/static-cache/benchmark.php new file mode 100644 index 00000000..bcd1fefa --- /dev/null +++ b/benchmark/static-cache/benchmark.php @@ -0,0 +1,75 @@ + 'users']; + } + return self::$cache[$class]; + } + + public static function getTable(): string + { + return static::getData()['table']; + } +} + +function measureStaticCacheGetData(): array +{ + $best = PHP_FLOAT_MAX; + $checksum = 0; + for ($round = 0; $round < STATIC_CACHE_WARMUPS + STATIC_CACHE_ROUNDS; $round++) { + $start = hrtime(true); + for ($i = 0; $i < STATIC_CACHE_ITERATIONS; $i++) { + StaticCacheData::getData(); + } + $elapsed = hrtime(true) - $start; + if ($round >= STATIC_CACHE_WARMUPS && $elapsed < $best) { + $best = $elapsed; + } + $checksum += count(StaticCacheData::getData()); + } + return [$best / STATIC_CACHE_ITERATIONS, $checksum]; +} + +function measureStaticCacheGetTable(): array +{ + $best = PHP_FLOAT_MAX; + $checksum = 0; + for ($round = 0; $round < STATIC_CACHE_WARMUPS + STATIC_CACHE_ROUNDS; $round++) { + $start = hrtime(true); + for ($i = 0; $i < STATIC_CACHE_ITERATIONS; $i++) { + StaticCacheData::getTable(); + } + $elapsed = hrtime(true) - $start; + if ($round >= STATIC_CACHE_WARMUPS && $elapsed < $best) { + $best = $elapsed; + } + $checksum += strlen(StaticCacheData::getTable()); + } + return [$best / STATIC_CACHE_ITERATIONS, $checksum]; +} + +function main(): void +{ + StaticCacheData::getData(); + + [$getData, $getDataChecksum] = measureStaticCacheGetData(); + [$getTable, $getTableChecksum] = measureStaticCacheGetTable(); + + echo "get_data_ns={$getData}\n"; + echo "get_table_ns={$getTable}\n"; + echo "checksum_get_data={$getDataChecksum}\n"; + echo "checksum_get_table={$getTableChecksum}\n"; +} diff --git a/benchmark/static-cache/project.yml b/benchmark/static-cache/project.yml new file mode 100644 index 00000000..a68de8ba --- /dev/null +++ b/benchmark/static-cache/project.yml @@ -0,0 +1,8 @@ +name: static_cache_benchmark +mode: bin +optimize: 3 +lto: true +build-dir: build +output: static_cache +sources: + - benchmark.php diff --git a/benchmark/static-cache/run.php b/benchmark/static-cache/run.php new file mode 100644 index 00000000..4db01631 --- /dev/null +++ b/benchmark/static-cache/run.php @@ -0,0 +1,124 @@ + $command */ +function runStaticCacheCommand(array $command, string $cwd, bool $capture, ?array $environment = null): string +{ + $process = proc_open( + $command, + [STDIN, $capture ? ['pipe', 'w'] : STDOUT, $capture ? ['pipe', 'w'] : STDERR], + $pipes, + $cwd, + $environment, + ['bypass_shell' => true], + ); + if (!is_resource($process)) { + throw new RuntimeException('Failed to start: ' . implode(' ', $command)); + } + + $output = $capture ? stream_get_contents($pipes[1]) : ''; + $error = $capture ? stream_get_contents($pipes[2]) : ''; + if ($capture) { + fclose($pipes[1]); + fclose($pipes[2]); + } + $status = proc_close($process); + if ($status !== 0) { + throw new RuntimeException(implode(' ', $command) . " failed ({$status})\n{$output}{$error}"); + } + return $output; +} + +/** @return array */ +function parseStaticCacheResult(string $output): array +{ + $result = []; + foreach (explode("\n", trim($output)) as $line) { + if (preg_match('/^([a-z_]+)=([0-9.]+)$/', $line, $matches)) { + $result[$matches[1]] = str_starts_with($matches[1], 'checksum_') + ? $matches[2] + : (float) $matches[2]; + } + } + return $result; +} + +$php = getenv('PHP_BIN') ?: PHP_BINARY; +$compilerPhp = getenv('TPC_PHP_BIN') ?: $php; +$probe = static fn (string $executable): string => trim(runStaticCacheCommand([ + $executable, + '-n', + '-r', + 'printf("%s;%d;%d;%d", PHP_VERSION, PHP_ZTS, PHP_DEBUG, PHP_INT_SIZE);', +], $root, true)); +$phpRuntime = $probe($php); +$compilerRuntime = $probe($compilerPhp); +if ($phpRuntime !== $compilerRuntime) { + throw new RuntimeException("PHP ABI mismatch: PHP_BIN={$phpRuntime}; TPC_PHP_BIN={$compilerRuntime}"); +} + +if (!$skipBuild) { + runStaticCacheCommand([ + $compilerPhp, + '-n', + $root . '/bin/tpc.php', + $project, + '-j', + '8', + '--no-color', + '--no-progress', + ], $root, false); +} +if (!is_file($binary)) { + throw new RuntimeException('Benchmark binary does not exist: ' . $binary); +} + +$phpResult = parseStaticCacheResult(runStaticCacheCommand([ + $php, + '-n', + '-d', + 'opcache.enable_cli=0', + '-d', + 'opcache.jit=0', + '-r', + 'require ' . var_export($source, true) . '; main();', +], $root, true)); + +$environment = getenv(); +if (PHP_OS_FAMILY !== 'Windows') { + $phpxHome = getenv('PHPX_HOME') ?: dirname($root) . '/phpx'; + $phpHome = dirname(dirname(realpath($compilerPhp) ?: $compilerPhp)); + $loader = PHP_OS_FAMILY === 'Darwin' ? 'DYLD_LIBRARY_PATH' : 'LD_LIBRARY_PATH'; + $existing = $environment[$loader] ?? ''; + $environment[$loader] = $phpxHome . '/lib' . PATH_SEPARATOR . $phpHome . '/lib' + . ($existing === '' ? '' : PATH_SEPARATOR . $existing); +} +$typephpResult = parseStaticCacheResult(runStaticCacheCommand([$binary], $root, true, $environment)); + +echo "Runtime: {$phpRuntime}\n"; +echo "Metric PHP ns/op TypePHP ns/op TypePHP/PHP\n"; +echo "--------------------------------------------------\n"; +foreach (['get_data', 'get_table'] as $case) { + $metric = $case . '_ns'; + $checksum = 'checksum_' . $case; + if (!isset($phpResult[$metric], $typephpResult[$metric])) { + throw new RuntimeException("Missing benchmark metric: {$case}"); + } + if (($phpResult[$checksum] ?? null) !== ($typephpResult[$checksum] ?? null)) { + throw new RuntimeException("Checksum mismatch: {$case}"); + } + printf( + "%-12s %10.2f %14.2f %12.2fx\n", + $case, + $phpResult[$metric], + $typephpResult[$metric], + $typephpResult[$metric] / $phpResult[$metric], + ); +} diff --git a/phpunit/src/LocalVariableInitializerTest.php b/phpunit/src/LocalVariableInitializerTest.php index 843fa7c4..ff37f41b 100644 --- a/phpunit/src/LocalVariableInitializerTest.php +++ b/phpunit/src/LocalVariableInitializerTest.php @@ -106,12 +106,12 @@ final class LocalVariableInitializerTest extends \BaseTest self::assertStringContainsString('php::Var selfValue = get_str(', $code); self::assertStringContainsString('php::Var parentValue = 128L;', $code); self::assertStringContainsString('php::Var concreteValue = get_str(', $code); - self::assertStringContainsString('php::Var selfClass = get_str(', $code); - self::assertStringContainsString('php::Var parentClass = get_str(', $code); - self::assertStringContainsString('php::Var unknownClass = get_str(', $code); + self::assertStringContainsString('php::Str selfClass = get_str(', $code); + self::assertStringContainsString('php::Str parentClass = get_str(', $code); + self::assertStringContainsString('php::Str unknownClass = get_str(', $code); self::assertStringContainsString('php::Var lateStatic;', $code); - self::assertStringContainsString('lateStatic = php::constant(php::getCalledCe(this_)', $code); + self::assertStringContainsString('lateStatic = php::constant(typephp_get_called_ce(this_)', $code); self::assertStringContainsString('php::Var external = "', $code); self::assertStringNotContainsString("php::Var external;\n", $code); self::assertStringContainsString('php::Var runtimeClassConstant;', $code); diff --git a/phpunit/src/LoopControlTest.php b/phpunit/src/LoopControlTest.php index 48acac18..051b4ea5 100644 --- a/phpunit/src/LoopControlTest.php +++ b/phpunit/src/LoopControlTest.php @@ -54,8 +54,8 @@ class LoopControlTest extends \BaseTest $this->assertMatchesRegularExpression('/\.attr\([^)]+\)[^;]*--/', $cpp); // static-property postfix must NOT be rewritten - $this->assertMatchesRegularExpression('/getStaticProperty\([^)]+\)[^;]*\+\+/', $cpp); - $this->assertMatchesRegularExpression('/getStaticProperty\([^)]+\)[^;]*--/', $cpp); + $this->assertMatchesRegularExpression('/typephp_get_static_property\([^)]+\)[^;]*\+\+/', $cpp); + $this->assertMatchesRegularExpression('/typephp_get_static_property\([^)]+\)[^;]*--/', $cpp); // array-element postfix must NOT be rewritten $this->assertMatchesRegularExpression('/\.item\([^)]+\)[^;]*\+\+/', $cpp); diff --git a/phpunit/src/NativePropertyTest.php b/phpunit/src/NativePropertyTest.php index f0453955..76925685 100644 --- a/phpunit/src/NativePropertyTest.php +++ b/phpunit/src/NativePropertyTest.php @@ -38,7 +38,7 @@ class NativePropertyTest extends \BaseTest } $code = file_get_contents($outputFile); - $this->assertStringContainsString('tmp_var_0 = php::getCalledClass(this_);', $code); + $this->assertStringContainsString('tmp_var_0 = typephp_get_called_class(this_);', $code); $this->assertStringContainsString('php::getStaticProperty((tmp_var_0.isObject()', $code); $this->assertStringContainsString('php::getStaticProperty((tmp_var_0.isObject() ? php::fn::get_class(tmp_var_0)', $code); $this->assertStringContainsString('= php::toInt(value);', $code); diff --git a/phpunit/src/SymbolTest.php b/phpunit/src/SymbolTest.php index 1ffcb6df..f853c410 100644 --- a/phpunit/src/SymbolTest.php +++ b/phpunit/src/SymbolTest.php @@ -12,6 +12,11 @@ class SymbolTest extends TestCase $this->assertEquals('php::getStaticProperty', Symbol::getStaticProperty()); } + public function testGetResolvedStaticProperty(): void + { + $this->assertEquals('typephp_get_static_property', Symbol::getResolvedStaticProperty()); + } + public function testSetStaticProperty(): void { $this->assertEquals('php::setStaticProperty', Symbol::setStaticProperty()); @@ -44,12 +49,12 @@ class SymbolTest extends TestCase public function testGetCalledCe(): void { - $this->assertSame('php::getCalledCe(this_)', Symbol::getCalledCe()); + $this->assertSame('typephp_get_called_ce(this_)', Symbol::getCalledCe()); } public function testGetCalledClass(): void { - $this->assertSame('php::getCalledClass(this_)', Symbol::getCalledClass()); + $this->assertSame('typephp_get_called_class(this_)', Symbol::getCalledClass()); } public function testSafeIndex(): void diff --git a/src/CompilerBase.php b/src/CompilerBase.php index a42b301d..b18aef37 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -3260,6 +3260,13 @@ class CompilerBase implements PropertyAccessContext } } break; + case 'Expr_ClassConstFetch': + if ($this->isIdExpr($expr->name) + && strtolower($this->parseIdentifier($expr->name)) === 'class' + ) { + return Type::STR; + } + break; case 'Expr_ArrayDimFetch': if ($this->isStdArrayExpr($expr)) { if (!$expr->hasAttribute('stdArrayDimFetch')) { @@ -4379,6 +4386,25 @@ class CompilerBase implements PropertyAccessContext return $nativePresence; } } + if ($op === self::OP_ISSET + && $node instanceof Expr\ArrayDimFetch + && $node->dim !== null + && $node->var instanceof Expr\StaticPropertyFetch + && $this->detectTypeOfExpr($node->var) === Type::ARRAY + ) { + // A single offset on a statically known array property needs no + // materialized operation chain. The TypePHP array helper reads the + // element directly and still applies isset's null semantics. Keep + // the general walker for deeper/dynamic chains. + $array = $this->parseStaticPropertyFetch($node->var); + $key = $this->parseIdentifier($node->dim); + if ($getValue) { + $result = $this->addTmpVar(Type::VAR); + $node->setAttribute('chainOpResult', $result); + return 'typephp_array_isset(' . $array . ', ' . $key . ', &' . $result . ')'; + } + return 'typephp_array_isset(' . $array . ', ' . $key . ')'; + } // The TypePHP compiler disallows operating on undefined variables; // in PHP, isset($var) may be used with an undefined $var. $this->checkVarMustExist($node, $this->parseIdentifier($node)); diff --git a/src/Generator/Symbol.php b/src/Generator/Symbol.php index 02d4b9e2..0a935ee2 100644 --- a/src/Generator/Symbol.php +++ b/src/Generator/Symbol.php @@ -15,6 +15,11 @@ class Symbol return 'php::getStaticProperty'; } + public static function getResolvedStaticProperty(): string + { + return 'typephp_get_static_property'; + } + public static function getStaticPropertyRef(): string { return 'php::getStaticPropertyRef'; @@ -37,12 +42,12 @@ class Symbol public static function getCalledCe(): string { - return 'php::getCalledCe(this_)'; + return 'typephp_get_called_ce(this_)'; } public static function getCalledClass(): string { - return 'php::getCalledClass(this_)'; + return 'typephp_get_called_class(this_)'; } public static function constant(): string diff --git a/src/Parser/MethodCallTrait.php b/src/Parser/MethodCallTrait.php index 64a43b76..09bc18aa 100644 --- a/src/Parser/MethodCallTrait.php +++ b/src/Parser/MethodCallTrait.php @@ -954,6 +954,53 @@ trait MethodCallTrait return '(' . $classVar . '.isObject() ? php::fn::get_class(' . $classVar . ') : php::toString(' . $classVar . '))'; } + /** + * Fast-path a zero-argument late-static call when the runtime called class + * is exactly the lexical TypePHP class. + * + * `static::method()` cannot normally be devirtualized because a subclass + * may override the method. The exact-class guard makes the direct branch + * provably safe, while inherited/subclass calls retain normal Zend + * dispatch. Calls with arguments and special return representations stay + * on the general path until they can share one materialized argument list. + */ + private function parseExactLateStaticCall( + Expr\StaticCall $expr, + string $method, + string $methodPtr, + ): ?string { + if ($expr->args !== [] || !$this->classDef || !$this->methodDef) { + return null; + } + + $class = $this->getFullClassName(); + try { + $nativeFunc = $this->getNativeMethod($expr, $class, $method); + } catch (DynamicCall) { + return null; + } + if ($nativeFunc === false || !$this->hasFunction($nativeFunc)) { + return null; + } + + $function = $this->getFunction($nativeFunc); + if ($function->returnsByRef + || $function->generator + || $function->hasMultiReturn() + || $function->returnType === Type::VOID + || $this->isStdContainerType($function->returnType) + || ($function->returnClass !== '' && $this->isNativeObjectClass($function->returnClass)) + ) { + return null; + } + + $calledCe = Symbol::getCalledCe(); + $direct = 'php::Var(' . self::PREFIX . $nativeFunc . '(this_))'; + $fallback = 'php::call(' . $calledCe . ', php::getMethod(' . $calledCe . ', ' . $methodPtr . '))'; + return '(EXPECTED(' . $calledCe . ' == ' . $this->getClassEntryPtr($class) . ')' + . ' ? ' . $direct . ' : ' . $fallback . ')'; + } + protected function parseStaticCall(Expr\StaticCall $expr): string { $this->validateImmutableCall($expr); @@ -1026,6 +1073,10 @@ trait MethodCallTrait } $method = $this->parseIdentifier($expr->name); $methodPtr = $this->methodNameToStr($expr->name, literal: true); + $exactCall = $this->parseExactLateStaticCall($expr, $method, $methodPtr); + if ($exactCall !== null) { + return $exactCall; + } $fn = Symbol::getCalledCe() . ', php::getMethod(' . Symbol::getCalledCe() . ', ' . $methodPtr . ')'; if ($this->debug) { $this->context->beforeStmtLines[] = $this->formatCppLineComment( diff --git a/src/Parser/PropertyAccessTrait.php b/src/Parser/PropertyAccessTrait.php index 52881fb8..c0af3569 100644 --- a/src/Parser/PropertyAccessTrait.php +++ b/src/Parser/PropertyAccessTrait.php @@ -493,7 +493,7 @@ trait PropertyAccessTrait if ($resolution->nativeProperty && $class !== null) { $classPtr = $this->getClassEntryPtr($class); $this->setNativePropertyValueSource($expr, self::NATIVE_PROPERTY_VALUE_DYNAMIC); - return Symbol::getStaticProperty() . '(' . $classPtr . ', ' . $nativeProp . ')'; + return Symbol::getResolvedStaticProperty() . '(' . $classPtr . ', ' . $nativeProp . ')'; } else { $this->setNativePropertyValueSource($expr, self::NATIVE_PROPERTY_VALUE_DYNAMIC); return $nativeProp; diff --git a/tests/compiler/static/late-static-call-exact-guard.phpt b/tests/compiler/static/late-static-call-exact-guard.phpt new file mode 100644 index 00000000..d4963f6f --- /dev/null +++ b/tests/compiler/static/late-static-call-exact-guard.phpt @@ -0,0 +1,42 @@ +--TEST-- +late static calls use the exact-class fast path without bypassing subclass dispatch +--FILE-- + +--EXPECT-- +string(19) "StaticCallBase:base" +string(24) "StaticCallInherited:base" +string(27) "StaticCallOverride:override" diff --git a/tests/compiler/static/static-array-single-offset-presence.phpt b/tests/compiler/static/static-array-single-offset-presence.phpt new file mode 100644 index 00000000..97a139d4 --- /dev/null +++ b/tests/compiler/static/static-array-single-offset-presence.phpt @@ -0,0 +1,46 @@ +--TEST-- +single-offset isset and coalesce on static arrays preserve PHP key and null semantics +--FILE-- + 42, + 'null' => null, + 2 => 'two', + '' => 'empty-key', + ]; + + public static function has(mixed $key): bool + { + return isset(self::$values[$key]); + } + + public static function get(mixed $key): mixed + { + return self::$values[$key] ?? 'fallback'; + } +} + +function main(): void +{ + var_dump(StaticPresence::has('present')); + var_dump(StaticPresence::has('null')); + var_dump(StaticPresence::has('missing')); + var_dump(StaticPresence::has(2.9)); + var_dump(StaticPresence::has(null)); + var_dump(StaticPresence::get('present')); + var_dump(StaticPresence::get('null')); + var_dump(StaticPresence::get(null)); +} +?> +--EXPECT-- +bool(true) +bool(false) +bool(false) +bool(true) +bool(true) +int(42) +string(8) "fallback" +string(9) "empty-key"