blob: cfd7ef2c3ed300c3ea9028d37d76a163859a151c [file] [edit]
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import difflib
import json
import logging
import os
import re
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import IO
log = logging.getLogger('measure-build-time')
SCRIPTS = Path(__file__).parent
# --- Tests and subtests -----------------------------------------------------
class Task:
name: str
description: str
depends: list[type[Task]] = []
subtest_of: str | None = None
build_command: str
stdout_filter: str | None
build_dir: Path
source_dir: Path
result: TestResult | None
def __init__(self, args: Arguments):
self.build_command = args.build_command
self.stdout_filter = args.stdout_filter
self.source_dir = args.source_dir
self.build_dir = args.build_dir
def __enter__(self):
pass
def __exit__(self, exc_type, exc_value, traceback):
pass
def run(self):
log.info('--- %s %s', self.name, '-' * (74 - len(self.name)))
with self:
self.result = run_test(self.name, self.build_command, self.stdout_filter)
return self.result
class CleanBuild(Task):
name = 'clean'
description = 'Delete the build directory and run a full build from scratch.'
def __init__(self, args: Arguments):
super().__init__(args)
if args.configure_command:
self.build_command = f'{args.configure_command} && {self.build_command}'
def __enter__(self):
if self.build_dir.exists():
if sys.stdin.isatty():
# Prompt for confirmation when being run interactively.
if input(f'OK to delete {self.build_dir}? [yN] ').lower() != 'y':
sys.exit('Clean build aborted, exiting')
subprocess.run(('rm', '-r', self.build_dir), check=True)
class PatchIncrementalBuild(Task):
depends = [CleanBuild]
subtest_of = 'incremental'
patch: str
def __init__(self, args: Arguments):
super().__init__(args)
self.unapply_patch = args.unapply_patch
def __enter__(self):
log.info('Apply patch: git -C %s apply -3 --numstat --apply', str(self.source_dir))
git = subprocess.run(('git', '-C', self.source_dir, 'apply', '-3', '--numstat', '--apply'),
capture_output=True, input=self.patch, text=True)
if git.returncode:
sys.exit(git.stderr)
self.paths_patched = [
# Parse --numstat output lines like:
# "1D\t1\tSource/WebKit/UIProcess/WebBackForwardList.cpp"
# to track the lines changed by the patch.
self.source_dir / line.split('\t', maxsplit=3)[2]
for line in git.stdout.splitlines()
]
def __exit__(self, *args):
if not self.unapply_patch:
return
before_apply = [(path, path.stat()) for path in self.paths_patched]
log.info('Reverse patch: git -C %s apply -3 --reverse', str(self.source_dir))
git = subprocess.run(('git', '-C', self.source_dir, 'apply', '-3', '--reverse'),
capture_output=True, input=self.patch, text=True)
log.info('Reset file modification times for: %s',
', '.join(map(str, self.paths_patched)))
for path, info in before_apply:
os.utime(path, ns=(info.st_atime_ns, info.st_mtime_ns))
if git.returncode:
sys.exit(git.stderr)
subprocess.run(('git', '-C', self.source_dir, 'update-index', '--remove', *self.paths_patched),
check=True)
def make_unified_diff(path: str, original: str, modified: str) -> str:
"""Generate a `git apply`-compatible unified diff for a single file."""
body = ''.join(difflib.unified_diff(
original.splitlines(keepends=True),
modified.splitlines(keepends=True),
fromfile=f'a/{path}',
tofile=f'b/{path}',
))
return f'diff --git a/{path} b/{path}\n' + body
class ContentEditIncrementalBuild(PatchIncrementalBuild):
"""Modify a file's content at runtime to invalidate content-based build caching.
Subclasses set `path` and override `modify_content()`. The patch is computed
from the file's current content so it always applies cleanly.
"""
@property
def path(self) -> str:
raise NotImplementedError
def modify_content(self, content: str) -> str:
raise NotImplementedError
def __enter__(self):
full_path = self.source_dir / self.path
original = full_path.read_text()
modified = self.modify_content(original)
if modified == original:
sys.exit(f'modify_content() did not change {self.path}')
self.patch = make_unified_diff(self.path, original, modified)
super().__enter__()
class HeaderCommentIncrementalBuild(ContentEditIncrementalBuild):
"""Append a C++-style comment to a header file."""
def modify_content(self, content: str) -> str:
if not content.endswith('\n'):
content += '\n'
return content + '// Edit by measure-build-time to invalidate content-based caching.\n'
class HashCommentIncrementalBuild(ContentEditIncrementalBuild):
"""Append a hash-style comment to a definition file (e.g. .serialization.in)."""
def modify_content(self, content: str) -> str:
if not content.endswith('\n'):
content += '\n'
return content + '# Edit by measure-build-time to invalidate content-based caching.\n'
class SourceWTFLogIncrementalBuild(ContentEditIncrementalBuild):
"""Insert a WTFLogAlways call at the top of an arbitrary function body."""
def modify_content(self, content: str) -> str:
# WebKit style places a function body's opening brace on its own line, so
# the first lone `{` line in a source file is reliably the start of a
# function body (rather than e.g. a class or namespace declaration, which
# keep their `{` at the end of the signature line).
match = re.search(r'\n\{\n', content)
if not match:
sys.exit(f'No function body opening brace found in {self.path}')
insert_at = match.end()
return (content[:insert_at]
+ ' WTFLogAlways("Edit by measure-build-time");\n'
+ content[insert_at:])
# NOTE: Many of these paths are chosen somewhat arbitrarily based on recent changes to the codebase.
# To reevaluate, run a log command like:
#
# git log --name-only --pretty=format: --after=2026-01-01 '*.serialization.in' | sort | uniq -c | sort -rg
#
# with different path patterns.
class IncrementalBuild1(HeaderCommentIncrementalBuild):
name = 'webcore-header'
description = 'Append a comment to a widely-included WebCore header (Document.h) and rebuild.'
path = 'Source/WebCore/dom/Document.h'
class IncrementalBuild2(HeaderCommentIncrementalBuild):
name = 'jsc-offlineasm-header'
description = 'Append a comment to a JavaScriptCore header consumed by offlineasm (WasmCallee.h) and rebuild.'
path = 'Source/JavaScriptCore/wasm/WasmCallee.h'
class IncrementalBuild3(HeaderCommentIncrementalBuild):
name = 'webkit-header'
description = 'Append a comment to a widely-included WebKit header (WebPageProxy.h) and rebuild.'
path = 'Source/WebKit/UIProcess/WebPageProxy.h'
class IncrementalBuild4(SourceWTFLogIncrementalBuild):
name = 'jsc-cpp-source'
description = 'Insert a WTFLogAlways call into a JavaScriptCore C++ source file (FTLLowerDFGToB3.cpp) and rebuild.'
path = 'Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp'
class IncrementalBuild5(SourceWTFLogIncrementalBuild):
name = 'webcore-mm-source'
description = 'Insert a WTFLogAlways call into a WebCore Objective-C++ source file (WebAccessibilityObjectWrapperMac.mm) and rebuild.'
path = 'Source/WebCore/accessibility/mac/WebAccessibilityObjectWrapperMac.mm'
class IncrementalBuild6(PatchIncrementalBuild):
name = 'webkit-swift-interface'
description = 'Apply a small patch that changes a Swift-C++ class\'s public initializer and rebuild.'
patch = '''\
diff --git a/Source/WebKit/UIProcess/WebBackForwardList.swift b/Source/WebKit/UIProcess/WebBackForwardList.swift
index 5ddc84b79cb2..1e447dc476c0 100644
--- a/Source/WebKit/UIProcess/WebBackForwardList.swift
+++ b/Source/WebKit/UIProcess/WebBackForwardList.swift
@@ -135,7 +135,8 @@ final class WebBackForwardList {
#endif
}()
- init(page: WebKit.WeakPtrWebPageProxy) {
+ init(page: WebKit.WeakPtrWebPageProxy, addedByMeasureBuildTimeDoNotCommit: Bool = true) {
+ print(addedByMeasureBuildTimeDoNotCommit)
self.page = page
self.messageForwarder = WebKit.WebBackForwardListMessageForwarder.create(target: self)
backForwardLog("(Back/Forward) Created WebBackForwardList \(ObjectIdentifier(self))")
diff --git a/Source/WebKit/UIProcess/WebBackForwardList.cpp b/Source/WebKit/UIProcess/WebBackForwardList.cpp
index 1c90c5556c38..8da62b88edf6 100644
--- a/Source/WebKit/UIProcess/WebBackForwardList.cpp
+++ b/Source/WebKit/UIProcess/WebBackForwardList.cpp
@@ -946,7 +946,7 @@ String WebBackForwardList::loggingString() const
#else // ENABLE(BACK_FORWARD_LIST_SWIFT)
WebBackForwardListWrapper::WebBackForwardListWrapper(WebPageProxy& webPageProxy)
- : m_impl(WTF::makeUniqueWithoutFastMallocCheck<WebBackForwardList>(WebBackForwardList::init(webPageProxy)))
+ : m_impl(WTF::makeUniqueWithoutFastMallocCheck<WebBackForwardList>(WebBackForwardList::init(webPageProxy, true)))
{
}
'''
class IncrementalBuild7(HashCommentIncrementalBuild):
name = 'serialization-file'
description = 'Append a comment to an IPC serialization definition file (WebCoreArgumentCoders.serialization.in) and rebuild.'
path = 'Source/WebKit/Shared/WebCoreArgumentCoders.serialization.in'
class NullBuild(Task):
name = 'null'
description = 'Re-run the build immediately after all other builds, with no source changes.'
depends = [CleanBuild, IncrementalBuild1, IncrementalBuild2,
IncrementalBuild3, IncrementalBuild4, IncrementalBuild5,
IncrementalBuild6, IncrementalBuild7]
AVAILABLE_TESTS = [
CleanBuild,
NullBuild,
IncrementalBuild1,
IncrementalBuild2,
IncrementalBuild3,
IncrementalBuild4,
IncrementalBuild5,
IncrementalBuild6,
IncrementalBuild7,
]
# --- Test execution and result parsing ---------------------------------------
TIMING_ENTRY_RE = re.compile(
r'^(.+?)\s+\(\d+\s+tasks?\)\s+\|\s+([\d.]+)\s+seconds$',
re.MULTILINE,
)
XCODE_TIMING_SUMMARY_RE = re.compile(
r'^Build Timing Summary$(.+?)(?=^\*\* BUILD|\Z)',
re.MULTILINE | re.DOTALL,
)
TestResult = dict
def sanitize_phase_name(task_name: str) -> str:
sanitized = task_name.replace('+', 'Plus')
return re.sub(r'[^a-zA-Z0-9]', '', sanitized)
def parse_timing_summary(log_fd: IO[bytes]) -> dict[str, int]:
"""Extract per-phase timing from Xcode's Build Timing Summary section."""
# The build log might be very long, but the timing summary is always at
# the end. Only search the final 1MB of the log.
log_fd.seek(0, os.SEEK_END)
size = log_fd.tell()
log_fd.seek(max(0, size - 2**20))
match = XCODE_TIMING_SUMMARY_RE.search(log_fd.read().decode('utf-8', errors='replace'))
if not match:
return {}
section = match.group(1)
timing: dict[str, int] = {}
for m in TIMING_ENTRY_RE.finditer(section):
timing[m.group(1)] = int(float(m.group(2)) * 1000)
return timing
def build_type_result(wall_time_ms: int, timing_summary: dict[str, int]) -> dict:
result: dict = {
'metrics': {
'Time': {'current': [wall_time_ms]},
},
}
if timing_summary:
phase_tests: dict = {}
for task_name, task_ms in timing_summary.items():
phase_tests[sanitize_phase_name(task_name)] = {
'metrics': {'Time': {'current': [task_ms]}},
}
result['tests'] = {
'Phases': {
'metrics': {
'CPUTime': {'current': [sum(timing_summary.values())]},
},
'tests': phase_tests,
},
}
return result
def run_build(command: str, stdout_filter: str | None = None) -> tuple[int, int, IO[bytes]]:
"""Run the build command, capturing output. Returns (exit_code, wall_time_ms, log_fd)."""
log_fd = tempfile.NamedTemporaryFile(prefix='measure-build-time-')
filter_cmd = stdout_filter if stdout_filter else 'cat'
full_command = f'set -o pipefail; ({command}) 2>&1 | tee {log_fd.name} | {filter_cmd}'
start = time.monotonic()
proc = subprocess.run(full_command, shell=True, executable='/bin/bash')
elapsed_ms = int((time.monotonic() - start) * 1000)
log_fd.seek(0)
return proc.returncode, elapsed_ms, log_fd
def run_test(name: str, command: str, stdout_filter: str | None = None) -> TestResult | None:
log.info('Build: %s', command)
rc, wall_time_ms, stdout_fd = run_build(command, stdout_filter)
if rc != 0:
log.error('%s build failed with exit code %d', name, rc)
return None
timing_summary = parse_timing_summary(stdout_fd)
log.info('%s build: %dms wall, %d phases parsed', name, wall_time_ms, len(timing_summary))
log.info('-' * 79)
return build_type_result(wall_time_ms, timing_summary)
# --- Test runner ------------------------------------------------------------
DEFAULT_MAKE_COMMAND = (
'make -C {workspace_dir} {configuration} ARGS=-showBuildTimingSummary'
)
DEFAULT_CMAKE_BUILD_COMMAND = (
'xcrun ninja -C {build_dir}'
)
DEFAULT_CMAKE_CONFIGURE_COMMAND = (
'xcrun cmake -S {source_dir} -B {build_dir} --preset {preset}'
)
class Arguments(argparse.Namespace):
build_command: str
configure_command: str | None
make: bool
cmake: bool
configuration: str
build_dir: Path
source_dir: Path
tests: list[str]
unapply_patch: bool
keep_going: bool
stdout_filter: str | None
output: str
def get_args() -> Arguments:
test_list = '\n'.join(f' {T.name:<24}{T.description}' for T in AVAILABLE_TESTS)
parser = argparse.ArgumentParser(
description='Measure build time and emit perf metrics JSON.',
epilog=f'available tests:\n{test_list}',
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument('--build-command',
help='Shell command to run the build')
parser.add_argument('--configure-command',
help='Shell command to configure the build')
parser.add_argument('--make', action='store_true',
help='Use a default Make invocation to build WebKit with Xcode.')
parser.add_argument('--cmake', action='store_true',
help='Use a default CMake invocation to build WebKit with Ninja.')
parser.add_argument('--build-dir', type=Path,
help='Path to top-level build directory (deleted by `clean` test)')
parser.add_argument('--source-dir', type=Path,
help='Path to WebKit checkout used for incremental build testing')
parser.add_argument('--configuration', default='debug',
help='Build configuration to use (default: debug)')
parser.add_argument('--tests', nargs='+', default=[t.name for t in AVAILABLE_TESTS],
choices=[t.name for t in AVAILABLE_TESTS],
help='Which tests to run (default: all). Will skip test dependencies which are not listed.')
parser.add_argument('--unapply-patch', action=argparse.BooleanOptionalAction, default=False,
help='Whether to undo changes made to the source directory between tests')
parser.add_argument('--keep-going', action=argparse.BooleanOptionalAction, default=True,
help='Continue benchmarking after a build command has failed')
parser.add_argument('--metric-key',
help='Top-level test key in the results. '
'Can be omitted, but must be provided when emitting a real benchmark score.')
parser.add_argument('--stdout-filter', default=None,
help='Shell command to filter build stdout through (e.g. filter-build-webkit)')
parser.add_argument('--output', '-o', default='build-time-results.json',
help='Output JSON file path (default: build-time-results.json)')
args = parser.parse_args(namespace=Arguments())
if not args.build_dir:
args.build_dir = Path(subprocess.check_output(
(SCRIPTS / 'webkit-build-directory', '--top-level'),
text=True
).rstrip())
if not args.source_dir:
args.source_dir = SCRIPTS.parent.parent
if sum((
1 if args.make else 0,
1 if args.cmake else 0,
1 if args.build_command or args.configure_command else 0,
)) > 1:
parser.error('cannot mix --cmake or --make with a custom build or '
'configure command')
if not any((args.make, args.cmake, args.build_command)):
parser.error('one of --make, --cmake, or --build-command is required')
if args.configure_command and not args.build_command:
parser.error('--build-command must be provided when using a custom '
'--configure-command')
if args.make:
internal_dir = args.source_dir / '../Internal/WebKit'
args.build_command = DEFAULT_MAKE_COMMAND.format(
configuration=args.configuration.lower(),
workspace_dir=str(internal_dir if internal_dir.exists() else args.source_dir)
)
elif args.cmake:
args.build_command = DEFAULT_CMAKE_BUILD_COMMAND.format(
build_dir=str(args.build_dir),
)
cmake_preset = {
'debug': 'mac-dev-debug',
'release': 'mac-dev-release',
}.get(args.configuration.lower(), args.configuration)
args.configure_command = DEFAULT_CMAKE_CONFIGURE_COMMAND.format(
build_dir=str(args.build_dir),
source_dir=str(args.source_dir),
preset=cmake_preset,
)
if not args.metric_key:
# e.g. "BuildTime-Debug"
args.metric_key = f'BuildTime-{args.configuration.capitalize()}'
return args
def main():
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
)
args = get_args()
# Read each test's `depends` lists to compute a topological order to run
# all the tests. Ties are broken by AVAILABLE_TESTS order, so the plan is
# deterministic. Does NOT perform cycle detection.
plan: list[type[Task]] = []
roots = [T for T in AVAILABLE_TESTS if not T.depends]
edges = {T: set(T.depends) for T in AVAILABLE_TESTS}
while roots:
T = roots.pop(0)
plan.append(T)
for T2 in AVAILABLE_TESTS:
if T in edges[T2]:
edges[T2].remove(T)
if not edges[T2]:
roots.append(T2)
tests: dict[str, TestResult] = {}
for T in plan:
if T.name not in args.tests:
continue
if any(T2.name not in (tests.get(T2.subtest_of, {}).get('tests', {})
if T2.subtest_of else tests)
for T2 in T.depends
if T2.name in args.tests):
log.info('Skipping %s build due to failed dependencies', T.name)
continue
result = T(args).run()
if not result:
if args.keep_going:
continue
sys.exit('Build failed, exiting due to --no-keep-going.')
if T.subtest_of:
aggregate_result = tests.setdefault(T.subtest_of, {
'metrics': {'Time': ['Geometric']},
'tests': {}
})
aggregate_result['tests'][T.name] = result
else:
tests[T.name] = result
if not tests:
raise sys.exit('All tests failed, exiting.')
results = {
args.metric_key: {
'tests': tests,
},
}
output_json = json.dumps(results, indent=2)
with open(args.output, 'w') as f:
f.write(output_json)
f.write('\n')
print(output_json)
if __name__ == '__main__':
main()