From ac7813d7b2208fe92cbcfac5d2360e435cdfc0b0 Mon Sep 17 00:00:00 2001 From: yangweijie <917647288@qq.com> Date: Mon, 3 Aug 2026 14:35:15 +0800 Subject: [PATCH] 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) --- src/CompilerBase.php | 65 ++++++++++++++++++- src/Preprocessor.php | 7 +- src/Translator.php | 148 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 206 insertions(+), 14 deletions(-) diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 46d4671c..8226e770 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -87,6 +87,8 @@ use PhpParser\Node\Expr\Variable; use PhpParser\Node\FunctionLike; use PhpParser\NodeAbstract; use PhpParser\NodeFinder; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor\CloningVisitor; use PhpParser\Parser; use PhpParser\ParserFactory; use PhpParser\PhpVersion; @@ -380,6 +382,13 @@ class CompilerBase implements PropertyAccessContext protected Parser $parser; protected string $phpVersion = self::DEFAULT_PHP_VERSION; 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> + */ + protected array $astParseCache = []; protected bool $isPhpZts = false; // PHP 是否为线程安全版本 // Windows 平台:保存检测到的 PHP lib 文件路径 @@ -879,15 +888,27 @@ class CompilerBase implements PropertyAccessContext 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); if (!is_dir($dir)) { mkdir($dir, 0777, true); } + if (is_file($file) && file_get_contents($file) === $content) { + return false; + } if (!file_put_contents($file, $content)) { throw new \RuntimeException('Can not write file: ' . $file); } + return true; } public function getIncludeDir(): string @@ -3826,6 +3847,48 @@ class CompilerBase implements PropertyAccessContext 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 + * @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 $stmts + * @return array + */ + protected function cloneAst(array $stmts): array + { + $traverser = new NodeTraverser(); + $traverser->addVisitor(new CloningVisitor()); + return $traverser->traverse($stmts); + } + protected function parseErrorSuppress(Expr\ErrorSuppress $expr): string { $tmpVar = $this->genTmpVarName(); diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 97c20dfb..95783251 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -126,12 +126,7 @@ class Preprocessor extends CompilerBase $this->resetNamespace(); $this->climate->info('prepare: ' . $this->getRelativePath($this->file)); - try { - $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()); - } + $ast = $this->parseCachedAst($phpCode); $this->stubImportLibrary = $this->stubFile && $this->hasLibraryImportAnnotation($ast) ? $this->getExternalImportLibraryName($this->file) diff --git a/src/Translator.php b/src/Translator.php index 52b80885..777ee4d3 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -495,8 +495,10 @@ class Translator extends Preprocessor public function save(string $code, string $file): void { - $this->writeFile($file, $code); - $this->formatCppCode($file); + $written = $this->writeFile($file, $code); + if ($written) { + $this->formatCppCode($file); + } } public function convertFile(string $file): string @@ -1095,8 +1097,9 @@ CODE; $this->indentLevel--; - $this->writeFile($file, $code); - $this->formatCppCode($file); + if ($this->writeFile($file, $code)) { + $this->formatCppCode($file); + } $this->localHeaders = []; 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 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-.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 { $miscDir = $this->getPhpxDir() . '/src/misc/'; @@ -1235,16 +1348,21 @@ CODE; 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) { $this->climate->darkGray('[cache] skip: ' . $cppFile); } return; } - $isMiscFile = $this->isPhpxMiscFile($cppFile); if ($isMiscFile) { $this->invalidateMiscObjectCache($objectFile); + } elseif ($isGenerated) { + $this->invalidateGeneratedObjectCache($objectFile); } $language = $this->getLanguageFromExtension($cppFile); @@ -1268,7 +1386,23 @@ CODE; if ($isMiscFile) { $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 @@ -2322,7 +2456,7 @@ CODE; { $this->climate->info('convert: ' . $this->getRelativePath($this->file)); - $ast = $this->parser->parse($phpCode); + $ast = $this->parseCachedAst($phpCode); $traverser = new NodeTraverser(); $traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false])); $traverser->addVisitor(new Visitor(sourceFile: $this->file));