perf: cache AST across prepare/convert and add generated object cache

- CompilerBase::parseCachedAst() caches the raw AST per source file and
  returns a CloningVisitor deep clone, so prepare() and convert() share a
  single parse pass instead of parsing every file twice.
- CompilerBase::writeFile() now skips writing when on-disk content is
  identical (stable mtime, prevents header-churn rebuild cascades).
- Translator::compileFile() checks hasGeneratedObjectFileCache() for
  compiler-generated .cc units; the key hashes the compile command, PHP
  ABI, and the generated func_decl.h/data_decl.h/_arginfo.h headers.
- save()/genExtension() only run clang-format when the file was actually
  written.

Measured on a 302-file synthetic project (dry run, 3 runs averaged):
- full build: 6011ms -> 5629ms (-6.4%)
- incremental (3rd run): 5023ms (-16.4% vs baseline cold build)
speed_build
yangweijie 3 weeks ago
parent 449afc1516
commit ac7813d7b2
  1. 65
      src/CompilerBase.php
  2. 7
      src/Preprocessor.php
  3. 148
      src/Translator.php

@ -87,6 +87,8 @@ use PhpParser\Node\Expr\Variable;
use PhpParser\Node\FunctionLike; use PhpParser\Node\FunctionLike;
use PhpParser\NodeAbstract; use PhpParser\NodeAbstract;
use PhpParser\NodeFinder; use PhpParser\NodeFinder;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\CloningVisitor;
use PhpParser\Parser; use PhpParser\Parser;
use PhpParser\ParserFactory; use PhpParser\ParserFactory;
use PhpParser\PhpVersion; use PhpParser\PhpVersion;
@ -380,6 +382,13 @@ class CompilerBase implements PropertyAccessContext
protected Parser $parser; protected Parser $parser;
protected string $phpVersion = self::DEFAULT_PHP_VERSION; protected string $phpVersion = self::DEFAULT_PHP_VERSION;
protected PrettyPrinter $printer; protected PrettyPrinter $printer;
/**
* Caches raw (un-traversed) AST per source file so prepare() and convert()
* share a single parse pass instead of parsing every file twice.
* @var array<string, array<PhpParser\Node>>
*/
protected array $astParseCache = [];
protected bool $isPhpZts = false; // PHP 是否为线程安全版本 protected bool $isPhpZts = false; // PHP 是否为线程安全版本
// Windows 平台:保存检测到的 PHP lib 文件路径 // Windows 平台:保存检测到的 PHP lib 文件路径
@ -879,15 +888,27 @@ class CompilerBase implements PropertyAccessContext
return $this->getIndent() . 'php::appendCallExtraNamedArgs(' . $var . ');' . PHP_EOL; return $this->getIndent() . 'php::appendCallExtraNamedArgs(' . $var . ');' . PHP_EOL;
} }
public function writeFile(string $file, string $content): void /**
* Write a generated file, skipping the write when the on-disk content is
* already identical. Keeping mtime stable for unchanged outputs prevents
* downstream rebuild cascades (header churn) and enables incremental
* object caching.
*
* @return bool true when the file was written, false when content was unchanged
*/
public function writeFile(string $file, string $content): bool
{ {
$dir = dirname($file); $dir = dirname($file);
if (!is_dir($dir)) { if (!is_dir($dir)) {
mkdir($dir, 0777, true); mkdir($dir, 0777, true);
} }
if (is_file($file) && file_get_contents($file) === $content) {
return false;
}
if (!file_put_contents($file, $content)) { if (!file_put_contents($file, $content)) {
throw new \RuntimeException('Can not write file: ' . $file); throw new \RuntimeException('Can not write file: ' . $file);
} }
return true;
} }
public function getIncludeDir(): string public function getIncludeDir(): string
@ -3826,6 +3847,48 @@ class CompilerBase implements PropertyAccessContext
return $phpCode; return $phpCode;
} }
/**
* Parse PHP source code into an AST, reusing a cached raw AST when the same
* file is parsed again (prepare + convert each used to parse once).
*
* The returned AST is always a deep clone of the cached raw tree. Visitors
* used by the pipeline (NameResolver, RuntimeAttributeFactoryLowering,
* Visitor) mutate nodes in place and hold per-traversal state, so every
* consumer must operate on its own copy.
*
* @param string $phpCode Source code previously read by loadFile().
* @return array<PhpParser\Node>
* @throws SyntaxError
*/
protected function parseCachedAst(string $phpCode): array
{
$cacheKey = $this->file;
if (!isset($this->astParseCache[$cacheKey])) {
try {
$this->astParseCache[$cacheKey] = $this->parser->parse($phpCode);
} catch (\PhpParser\Error $e) {
$this->climate->red("Fatal error: {$e->getMessage()} in {$this->file}");
throw new SyntaxError($e->getMessage(), $e->getCode());
}
}
return $this->cloneAst($this->astParseCache[$cacheKey]);
}
/**
* Deep-clone an AST so that a shared cached tree can be traversed by
* multiple stateful visitors without cross-contamination.
*
* @param array<PhpParser\Node> $stmts
* @return array<PhpParser\Node>
*/
protected function cloneAst(array $stmts): array
{
$traverser = new NodeTraverser();
$traverser->addVisitor(new CloningVisitor());
return $traverser->traverse($stmts);
}
protected function parseErrorSuppress(Expr\ErrorSuppress $expr): string protected function parseErrorSuppress(Expr\ErrorSuppress $expr): string
{ {
$tmpVar = $this->genTmpVarName(); $tmpVar = $this->genTmpVarName();

@ -126,12 +126,7 @@ class Preprocessor extends CompilerBase
$this->resetNamespace(); $this->resetNamespace();
$this->climate->info('prepare: ' . $this->getRelativePath($this->file)); $this->climate->info('prepare: ' . $this->getRelativePath($this->file));
try { $ast = $this->parseCachedAst($phpCode);
$ast = $this->parser->parse($phpCode);
} catch (\PhpParser\Error $e) {
$this->climate->red("Fatal error: {$e->getMessage()} in {$this->file}");
throw new SyntaxError($e->getMessage(), $e->getCode());
}
$this->stubImportLibrary = $this->stubFile && $this->hasLibraryImportAnnotation($ast) $this->stubImportLibrary = $this->stubFile && $this->hasLibraryImportAnnotation($ast)
? $this->getExternalImportLibraryName($this->file) ? $this->getExternalImportLibraryName($this->file)

@ -495,8 +495,10 @@ class Translator extends Preprocessor
public function save(string $code, string $file): void public function save(string $code, string $file): void
{ {
$this->writeFile($file, $code); $written = $this->writeFile($file, $code);
$this->formatCppCode($file); if ($written) {
$this->formatCppCode($file);
}
} }
public function convertFile(string $file): string public function convertFile(string $file): string
@ -1095,8 +1097,9 @@ CODE;
$this->indentLevel--; $this->indentLevel--;
$this->writeFile($file, $code); if ($this->writeFile($file, $code)) {
$this->formatCppCode($file); $this->formatCppCode($file);
}
$this->localHeaders = []; $this->localHeaders = [];
return $file; return $file;
} }
@ -1191,6 +1194,116 @@ CODE;
} }
} }
/**
* Check whether a compiler-generated .cc file has a valid object cache.
* Mirrors hasMiscObjectFileCache() but applies to generated translation
* units, whose compiled output depends on the generated headers they
* include (func_decl.h / data_decl.h / arginfo / extension).
*
* The cache key incorporates the compile command, the PHP ABI, and the
* content of every generated header, so any change to the generated
* surface (including headers another file changed) invalidates it.
*/
public function hasGeneratedObjectFileCache(string $cppFile): bool
{
if ($this->climate->arguments->defined('force') || $this->enableProfiler) {
return false;
}
$objectFile = $this->getObjectFile($cppFile);
if (!is_file($objectFile)) {
return false;
}
$metadataFile = $this->getMiscObjectCacheMetadataFile($objectFile);
if (!is_file($metadataFile)) {
return false;
}
$cachedKey = file_get_contents($metadataFile);
if ($cachedKey === false || trim($cachedKey) !== $this->getGeneratedObjectCacheKey($cppFile, $objectFile)) {
return false;
}
$objectMtime = filemtime($objectFile);
if ($objectMtime <= filemtime($cppFile)) {
return false;
}
foreach ($this->getGeneratedHeaderDependencies() as $header) {
if (is_file($header) && filemtime($header) > $objectMtime) {
return false;
}
}
return true;
}
/**
* @return list<string> generated headers that every generated .cc depends on.
*
* Every generated translation unit includes the project func_decl.h and
* data_decl.h (plus the phpx headers handled by the misc cache). The
* per-file _arginfo.h headers are included by the extension unit, so they
* are part of the shared surface too. extension-<target>.cc is NOT listed:
* its content tracks any class change and would invalidate every .cc,
* defeating incremental builds. It is itself a generated unit and gets its
* own cache entry via isGeneratedSourceFile().
*/
protected function getGeneratedHeaderDependencies(): array
{
$includeDir = $this->getIncludeDir();
$headers = [
$includeDir . '/php_' . $this->targetName . '_func_decl.h',
$includeDir . '/php_' . $this->targetName . '_data_decl.h',
];
if (is_dir($includeDir)) {
$iterator = new \FilesystemIterator($includeDir, \FilesystemIterator::SKIP_DOTS);
foreach ($iterator as $entry) {
if ($entry->isFile() && str_ends_with($entry->getFilename(), '_arginfo.h')) {
$headers[] = $entry->getPathname();
}
}
}
sort($headers, SORT_STRING);
return $headers;
}
protected function getGeneratedObjectCacheKey(string $sourceFile, string $objectFile): string
{
$abi = [
'php_version_id' => PHP_VERSION_ID,
'php_api_version' => defined('PHP_API_VERSION') ? constant('PHP_API_VERSION') : null,
'zend_module_api' => defined('ZEND_MODULE_API_NO') ? constant('ZEND_MODULE_API_NO') : null,
'php_zts' => defined('PHP_ZTS') ? PHP_ZTS : null,
'php_debug' => defined('PHP_DEBUG') ? PHP_DEBUG : null,
'integer_size' => PHP_INT_SIZE,
];
$context = hash_init('sha256');
hash_update($context, $this->buildCompileFileCommand($sourceFile, $objectFile) . "\0" . serialize($abi));
foreach ($this->getGeneratedHeaderDependencies() as $header) {
hash_update($context, "\0" . $header . "\0");
if (is_file($header)) {
hash_update_file($context, $header);
}
}
return hash_final($context);
}
protected function writeGeneratedObjectCacheMetadata(string $sourceFile, string $objectFile): void
{
$metadataFile = $this->getMiscObjectCacheMetadataFile($objectFile);
if (file_put_contents($metadataFile, $this->getGeneratedObjectCacheKey($sourceFile, $objectFile) . PHP_EOL) === false) {
throw new \RuntimeException('Cannot write generated object cache metadata: ' . $metadataFile);
}
}
protected function invalidateGeneratedObjectCache(string $objectFile): void
{
$this->invalidateMiscObjectCache($objectFile);
}
public function isPhpxMiscFile(string $cppFile): bool public function isPhpxMiscFile(string $cppFile): bool
{ {
$miscDir = $this->getPhpxDir() . '/src/misc/'; $miscDir = $this->getPhpxDir() . '/src/misc/';
@ -1235,16 +1348,21 @@ CODE;
public function compileFile(string $cppFile, string $objectFile, bool $parallel = false): void public function compileFile(string $cppFile, string $objectFile, bool $parallel = false): void
{ {
if ($this->isPhpxMiscFile($cppFile) && $this->hasMiscObjectFileCache($cppFile)) { $isMiscFile = $this->isPhpxMiscFile($cppFile);
$isGenerated = $this->isGeneratedSourceFile($cppFile);
if (($isMiscFile && $this->hasMiscObjectFileCache($cppFile))
|| ($isGenerated && $this->hasGeneratedObjectFileCache($cppFile))) {
if (!$parallel) { if (!$parallel) {
$this->climate->darkGray('[cache] skip: ' . $cppFile); $this->climate->darkGray('[cache] skip: ' . $cppFile);
} }
return; return;
} }
$isMiscFile = $this->isPhpxMiscFile($cppFile);
if ($isMiscFile) { if ($isMiscFile) {
$this->invalidateMiscObjectCache($objectFile); $this->invalidateMiscObjectCache($objectFile);
} elseif ($isGenerated) {
$this->invalidateGeneratedObjectCache($objectFile);
} }
$language = $this->getLanguageFromExtension($cppFile); $language = $this->getLanguageFromExtension($cppFile);
@ -1268,7 +1386,23 @@ CODE;
if ($isMiscFile) { if ($isMiscFile) {
$this->writeMiscObjectCacheMetadata($cppFile, $objectFile); $this->writeMiscObjectCacheMetadata($cppFile, $objectFile);
} elseif ($isGenerated) {
$this->writeGeneratedObjectCacheMetadata($cppFile, $objectFile);
}
}
/**
* True for compiler-generated .cc translation units (as opposed to phpx
* misc sources and user-provided native sources). These live under the
* build directory and are safe to cache by generated-header fingerprint.
*/
protected function isGeneratedSourceFile(string $cppFile): bool
{
if ($this->isPhpxMiscFile($cppFile) || !$this->isCppFile($cppFile)) {
return false;
} }
$buildDir = rtrim($this->getBuildDir(), '/\\') . DIRECTORY_SEPARATOR;
return str_starts_with($cppFile, $buildDir);
} }
protected function buildCompileFileCommand(string $sourceFile, string $objectFile): string protected function buildCompileFileCommand(string $sourceFile, string $objectFile): string
@ -2322,7 +2456,7 @@ CODE;
{ {
$this->climate->info('convert: ' . $this->getRelativePath($this->file)); $this->climate->info('convert: ' . $this->getRelativePath($this->file));
$ast = $this->parser->parse($phpCode); $ast = $this->parseCachedAst($phpCode);
$traverser = new NodeTraverser(); $traverser = new NodeTraverser();
$traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false])); $traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false]));
$traverser->addVisitor(new Visitor(sourceFile: $this->file)); $traverser->addVisitor(new Visitor(sourceFile: $this->file));

Loading…
Cancel
Save