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
master
韩天峰 2 days ago
parent 5689170839
commit b3c7ee9bd3
  1. 1
      benchmark/README.md
  2. 3
      benchmark/static-cache/.gitignore
  3. 16
      benchmark/static-cache/README.md
  4. 75
      benchmark/static-cache/benchmark.php
  5. 8
      benchmark/static-cache/project.yml
  6. 124
      benchmark/static-cache/run.php
  7. 8
      phpunit/src/LocalVariableInitializerTest.php
  8. 4
      phpunit/src/LoopControlTest.php
  9. 2
      phpunit/src/NativePropertyTest.php
  10. 9
      phpunit/src/SymbolTest.php
  11. 26
      src/CompilerBase.php
  12. 9
      src/Generator/Symbol.php
  13. 51
      src/Parser/MethodCallTrait.php
  14. 2
      src/Parser/PropertyAccessTrait.php
  15. 42
      tests/compiler/static/late-static-call-exact-guard.phpt
  16. 46
      tests/compiler/static/static-array-single-offset-presence.phpt

@ -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:

@ -0,0 +1,3 @@
/build/
/static_cache
/static_cache.exe

@ -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.

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
const STATIC_CACHE_ITERATIONS = 1_000_000;
const STATIC_CACHE_WARMUPS = 2;
const STATIC_CACHE_ROUNDS = 5;
class StaticCacheData
{
public static array $cache = [];
public static function getData(): array
{
$class = static::class;
if (!isset(self::$cache[$class])) {
self::$cache[$class] = ['table' => '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";
}

@ -0,0 +1,8 @@
name: static_cache_benchmark
mode: bin
optimize: 3
lto: true
build-dir: build
output: static_cache
sources:
- benchmark.php

@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
$root = dirname(__DIR__, 2);
$source = __DIR__ . '/benchmark.php';
$project = __DIR__ . '/project.yml';
$binary = __DIR__ . '/static_cache' . (PHP_OS_FAMILY === 'Windows' ? '.exe' : '');
$skipBuild = in_array('--skip-build', $argv, true);
/** @param list<string> $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<string, float|string> */
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],
);
}

@ -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);

@ -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);

@ -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);

@ -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

@ -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));

@ -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

@ -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(

@ -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;

@ -0,0 +1,42 @@
--TEST--
late static calls use the exact-class fast path without bypassing subclass dispatch
--FILE--
<?php
class StaticCallBase
{
public static function data(): array
{
return [static::class, 'base'];
}
public static function label(): string
{
$data = static::data();
return $data[0] . ':' . $data[1];
}
}
class StaticCallInherited extends StaticCallBase
{
}
class StaticCallOverride extends StaticCallBase
{
public static function data(): array
{
return [static::class, 'override'];
}
}
function main(): void
{
var_dump(StaticCallBase::label());
var_dump(StaticCallInherited::label());
var_dump(StaticCallOverride::label());
}
?>
--EXPECT--
string(19) "StaticCallBase:base"
string(24) "StaticCallInherited:base"
string(27) "StaticCallOverride:override"

@ -0,0 +1,46 @@
--TEST--
single-offset isset and coalesce on static arrays preserve PHP key and null semantics
--FILE--
<?php
class StaticPresence
{
public static array $values = [
'present' => 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"
Loading…
Cancel
Save