blob: 467ff0714454e6fba8f018eac5c7190335e21cc2 [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
# Written into the build directory by -DSWIFT_NINJA_TRACE=ON, and already bound
# to that build's swift jobs log and stats directory.
TRACE_FINALIZE_SCRIPT = 'swift-trace-finalize.sh'
# --- 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
trace_dir: Path | None
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
self.trace_dir = args.trace_dir if args.trace else None
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:
started_epoch_ms = int(time.time() * 1000)
self.result = run_test(self.name, self.build_command, self.stdout_filter)
if self.trace_dir:
write_build_trace(self.build_dir, self.trace_dir, self.name, started_epoch_ms)
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.cpp b/Source/WebKit/UIProcess/WebBackForwardList.cpp
index 73419fbf4994..12e9c6ebda40 100644
--- a/Source/WebKit/UIProcess/WebBackForwardList.cpp
+++ b/Source/WebKit/UIProcess/WebBackForwardList.cpp
@@ -990,7 +990,7 @@ void WebBackForwardList::didReceiveProvisionalMessage(IPC::Connection& connectio
#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)))
, m_messageForwarder(m_impl->getMessageReceiver())
{
}
diff --git a/Source/WebKit/UIProcess/WebBackForwardList.swift b/Source/WebKit/UIProcess/WebBackForwardList.swift
index 7556b716bef9..24e1cf8098e1 100644
--- a/Source/WebKit/UIProcess/WebBackForwardList.swift
+++ b/Source/WebKit/UIProcess/WebBackForwardList.swift
@@ -141,7 +141,8 @@ final class WebBackForwardList {
// @used ensures these are retained even under -O -wmo: rdar://179098545
@used
- 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))")
'''
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)
def write_build_trace(build_dir: Path, trace_dir: Path, name: str,
since_epoch_ms: int) -> Path | None:
finalize = build_dir / TRACE_FINALIZE_SCRIPT
if not finalize.exists():
log.warning('No %s; configure with -DSWIFT_NINJA_TRACE=ON to trace builds', finalize)
return None
trace_dir.mkdir(parents=True, exist_ok=True)
output = trace_dir / f'{name}.json.gz'
trace = subprocess.run((str(finalize), '--relative',
f'--since-epoch-ms={since_epoch_ms}', '-o', str(output)))
if trace.returncode:
log.warning('%s exited %d, so %s has no build trace',
finalize, trace.returncode, name)
return None
if not output.exists():
log.info('No ninja invocation to trace for %s', name)
return None
log.info('Wrote build trace %s', output)
return output
# --- Test runner ------------------------------------------------------------
DEFAULT_MAKE_COMMAND = (
'make -C {workspace_dir} {configuration} ARGS="'
'WK_MACOSX_DEPLOYMENT_TARGET={sdk_version} COMPILATION_CACHE_ENABLE_CACHING=NO '
'-showBuildTimingSummary'
'"'
)
DEFAULT_CMAKE_BUILD_COMMAND = (
'xcrun ninja -C {build_dir}'
)
DEFAULT_CMAKE_CONFIGURE_COMMAND = (
'xcrun cmake -S {source_dir} -B {build_dir} --preset {preset} '
'-DCMAKE_OSX_DEPLOYMENT_TARGET={sdk_version}'
)
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
trace: bool | None
trace_dir: Path
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('--trace', action=argparse.BooleanOptionalAction, default=None,
help='Write a Perfetto trace of each build to --trace-dir '
'(default: on for --cmake). Configures with -DSWIFT_NINJA_TRACE=ON.')
parser.add_argument('--trace-dir', type=Path, default=Path('build-time-traces'),
help='Directory to write build traces into (default: build-time-traces)')
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 args.trace is None:
args.trace = args.cmake
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')
internal_dir = args.source_dir / '../Internal/WebKit'
if args.make or args.cmake:
sdk_version = subprocess.check_output(
('xcodebuild', '-version', '-sdk',
'macosx.internal' if internal_dir.exists() else 'macosx',
'SDKVersion'),
text=True
).rstrip()
if args.make:
args.build_command = DEFAULT_MAKE_COMMAND.format(
configuration=args.configuration.lower(),
workspace_dir=str(internal_dir if internal_dir.exists() else args.source_dir),
sdk_version=sdk_version,
)
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,
sdk_version=sdk_version,
)
if args.trace:
args.configure_command += ' -DSWIFT_NINJA_TRACE=ON'
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()
if args.trace:
# A CI workdir outlives the build, and a test that produces no trace this
# time would otherwise leave the last run's behind to be collected.
for stale in args.trace_dir.glob('*.json.gz'):
stale.unlink()
# 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()