1#!/usr/bin/env python3 2# Copyright 2023 The ChromiumOS Authors 3# Use of this source code is governed by a BSD-style license that can be 4# found in the LICENSE file. 5 6"""Generates a PGO profile for LLVM. 7 8**This script is meant to be run from inside of the chroot.** 9 10Note that this script has a few (perhaps surprising) side-effects: 111. The first time this is run in a chroot, it will pack up your existing llvm 12 and save it as a binpkg. 132. This script clobbers your llvm installation. If the script is run to 14 completion, your old installation will be restored. If it does not, it may 15 not be. 16""" 17 18import argparse 19import dataclasses 20import logging 21import os 22from pathlib import Path 23import shlex 24import shutil 25import subprocess 26import sys 27import tempfile 28import textwrap 29from typing import Dict, FrozenSet, List, Optional 30 31import pgo_tools 32 33 34# This script runs `quickpkg` on LLVM. This file saves the version of LLVM that 35# was quickpkg'ed. 36SAVED_LLVM_BINPKG_STAMP = Path("/tmp/generate_pgo_profile_old_llvm.txt") 37 38# Triple to build with when not trying to get backend coverage. 39HOST_TRIPLE = "x86_64-pc-linux-gnu" 40 41# List of triples we want coverage for. 42IMPORTANT_TRIPLES = ( 43 HOST_TRIPLE, 44 "x86_64-cros-linux-gnu", 45 "armv7a-cros-linux-gnueabihf", 46 "aarch64-cros-linux-gnu", 47) 48 49# Set of all of the cross-* libraries we need. 50ALL_NEEDED_CROSS_LIBS = frozenset( 51 f"cross-{triple}/{package}" 52 for triple in IMPORTANT_TRIPLES 53 if triple != HOST_TRIPLE 54 for package in ("glibc", "libcxx", "llvm-libunwind", "linux-headers") 55) 56 57 58def ensure_llvm_binpkg_exists() -> bool: 59 """Verifies that we have an LLVM binpkg to fall back on. 60 61 Returns: 62 True if this function actually created a binpkg, false if one already 63 existed. 64 """ 65 if SAVED_LLVM_BINPKG_STAMP.exists(): 66 pkg = Path(SAVED_LLVM_BINPKG_STAMP.read_text(encoding="utf-8")) 67 # Double-check this, since this package is considered a cache artifact 68 # by portage. Ergo, it can _technically_ be GC'ed at any time. 69 if pkg.exists(): 70 return False 71 72 pkg = pgo_tools.quickpkg_llvm() 73 SAVED_LLVM_BINPKG_STAMP.write_text(str(pkg), encoding="utf-8") 74 return True 75 76 77def restore_llvm_binpkg(): 78 """Installs the binpkg created by ensure_llvm_binpkg_exists.""" 79 logging.info("Restoring non-PGO'ed LLVM installation") 80 pkg = Path(SAVED_LLVM_BINPKG_STAMP.read_text(encoding="utf-8")) 81 assert ( 82 pkg.exists() 83 ), f"Non-PGO'ed binpkg at {pkg} does not exist. Can't restore" 84 pgo_tools.run(pgo_tools.generate_quickpkg_restoration_command(pkg)) 85 86 87def find_missing_cross_libs() -> FrozenSet[str]: 88 """Returns cross-* libraries that need to be installed for workloads.""" 89 equery_result = pgo_tools.run( 90 ["equery", "l", "--format=$cp", "cross-*/*"], 91 check=False, 92 stdout=subprocess.PIPE, 93 ) 94 95 # If no matching package is found, equery will exit with code 3. 96 if equery_result.returncode == 3: 97 return ALL_NEEDED_CROSS_LIBS 98 99 equery_result.check_returncode() 100 has_packages = {x.strip() for x in equery_result.stdout.splitlines()} 101 return ALL_NEEDED_CROSS_LIBS - has_packages 102 103 104def ensure_cross_libs_are_installed(): 105 """Ensures that we have cross-* libs for all `IMPORTANT_TRIPLES`.""" 106 missing_packages = find_missing_cross_libs() 107 if not missing_packages: 108 logging.info("All cross-compiler libraries are already installed") 109 return 110 111 missing_packages = sorted(missing_packages) 112 logging.info("Installing cross-compiler libs: %s", missing_packages) 113 pgo_tools.run( 114 ["sudo", "emerge", "-j", "-G"] + missing_packages, 115 ) 116 117 118def emerge_pgo_generate_llvm(): 119 """Emerges a sys-devel/llvm with PGO instrumentation enabled.""" 120 force_use = ( 121 "llvm_pgo_generate -llvm_pgo_use" 122 # Turn ThinLTO off, since doing so results in way faster builds. 123 # This is assumed to be OK, since: 124 # - ThinLTO should have no significant impact on where Clang puts 125 # instrprof counters. 126 # - In practice, both "PGO generated with ThinLTO enabled," and "PGO 127 # generated without ThinLTO enabled," were benchmarked, and the 128 # performance difference between the two was in the noise. 129 " -thinlto" 130 # Turn ccache off, since if there are valid ccache artifacts from prior 131 # runs of this script, ccache will lead to us not getting profdata from 132 # those. 133 " -wrapper_ccache" 134 ) 135 use = (os.environ.get("USE", "") + " " + force_use).strip() 136 137 # Use FEATURES=ccache since it's not much of a CPU time penalty, and if a 138 # user runs this script repeatedly, they'll appreciate it. :) 139 force_features = "ccache" 140 features = (os.environ.get("FEATURES", "") + " " + force_features).strip() 141 logging.info("Building LLVM with USE=%s", shlex.quote(use)) 142 pgo_tools.run( 143 [ 144 "sudo", 145 f"FEATURES={features}", 146 f"USE={use}", 147 "emerge", 148 "sys-devel/llvm", 149 ] 150 ) 151 152 153def build_profiling_env(profile_dir: Path) -> Dict[str, str]: 154 profile_pattern = str(profile_dir / "profile-%m.profraw") 155 return { 156 "LLVM_PROFILE_OUTPUT_FORMAT": "profraw", 157 "LLVM_PROFILE_FILE": profile_pattern, 158 } 159 160 161def ensure_clang_invocations_generate_profiles(clang_bin: str, tmpdir: Path): 162 """Raises an exception if clang doesn't generate profraw files. 163 164 Args: 165 clang_bin: the path to a clang binary. 166 tmpdir: a place where this function can put temporary files. 167 """ 168 tmpdir = tmpdir / "ensure_profiles_generated" 169 tmpdir.mkdir(parents=True) 170 pgo_tools.run( 171 [clang_bin, "--help"], 172 extra_env=build_profiling_env(tmpdir), 173 stdout=subprocess.DEVNULL, 174 stderr=subprocess.DEVNULL, 175 ) 176 is_empty = next(tmpdir.iterdir(), None) is None 177 if is_empty: 178 raise ValueError( 179 f"The clang binary at {clang_bin} generated no profile" 180 ) 181 shutil.rmtree(tmpdir) 182 183 184def write_unified_cmake_file( 185 into_dir: Path, absl_subdir: Path, gtest_subdir: Path 186): 187 (into_dir / "CMakeLists.txt").write_text( 188 textwrap.dedent( 189 f"""\ 190 cmake_minimum_required(VERSION 3.10) 191 192 project(generate_pgo) 193 194 add_subdirectory({gtest_subdir}) 195 add_subdirectory({absl_subdir})""" 196 ), 197 encoding="utf-8", 198 ) 199 200 201def fetch_workloads_into(target_dir: Path): 202 """Fetches PGO generation workloads into `target_dir`.""" 203 # The workload here is absl and gtest. The reasoning behind that selection 204 # was essentially a mix of: 205 # - absl is reasonably-written and self-contained 206 # - gtest is needed if tests are to be built; in order to have absl do much 207 # of any linking, gtest is necessary. 208 # 209 # Use the version of absl that's bundled with ChromeOS at the time of 210 # writing. 211 target_dir.mkdir(parents=True) 212 213 def fetch_and_extract(gs_url: str, into_dir: Path): 214 tgz_full = target_dir / os.path.basename(gs_url) 215 pgo_tools.run( 216 [ 217 "gsutil", 218 "cp", 219 gs_url, 220 tgz_full, 221 ], 222 ) 223 into_dir.mkdir() 224 225 pgo_tools.run( 226 ["tar", "xaf", tgz_full], 227 cwd=into_dir, 228 ) 229 230 absl_dir = target_dir / "absl" 231 fetch_and_extract( 232 gs_url="gs://chromeos-localmirror/distfiles/" 233 "abseil-cpp-a86bb8a97e38bc1361289a786410c0eb5824099c.tar.bz2", 234 into_dir=absl_dir, 235 ) 236 237 gtest_dir = target_dir / "gtest" 238 fetch_and_extract( 239 gs_url="gs://chromeos-mirror/gentoo/distfiles/" 240 "gtest-1b18723e874b256c1e39378c6774a90701d70f7a.tar.gz", 241 into_dir=gtest_dir, 242 ) 243 244 unpacked_absl_dir = read_exactly_one_dirent(absl_dir) 245 unpacked_gtest_dir = read_exactly_one_dirent(gtest_dir) 246 write_unified_cmake_file( 247 into_dir=target_dir, 248 absl_subdir=unpacked_absl_dir.relative_to(target_dir), 249 gtest_subdir=unpacked_gtest_dir.relative_to(target_dir), 250 ) 251 252 253@dataclasses.dataclass(frozen=True) 254class WorkloadRunner: 255 """Runs benchmark workloads.""" 256 257 profraw_dir: Path 258 target_dir: Path 259 out_dir: Path 260 261 def run( 262 self, 263 triple: str, 264 extra_cflags: Optional[str] = None, 265 sysroot: Optional[str] = None, 266 ): 267 logging.info( 268 "Running workload for triple %s, extra cflags %r", 269 triple, 270 extra_cflags, 271 ) 272 if self.out_dir.exists(): 273 shutil.rmtree(self.out_dir) 274 self.out_dir.mkdir(parents=True) 275 276 clang = triple + "-clang" 277 profiling_env = build_profiling_env(self.profraw_dir) 278 if sysroot: 279 profiling_env["SYSROOT"] = sysroot 280 281 cmake_command: pgo_tools.Command = [ 282 "cmake", 283 "-G", 284 "Ninja", 285 "-DCMAKE_BUILD_TYPE=RelWithDebInfo", 286 f"-DCMAKE_C_COMPILER={clang}", 287 f"-DCMAKE_CXX_COMPILER={clang}++", 288 "-DABSL_BUILD_TESTING=ON", 289 "-DABSL_USE_EXTERNAL_GOOGLETEST=ON", 290 "-DABSL_USE_GOOGLETEST_HEAD=OFF", 291 "-DABSL_FIND_GOOGLETEST=OFF", 292 ] 293 294 if extra_cflags: 295 cmake_command += ( 296 f"-DCMAKE_C_FLAGS={extra_cflags}", 297 f"-DCMAKE_CXX_FLAGS={extra_cflags}", 298 ) 299 300 cmake_command.append(self.target_dir) 301 pgo_tools.run( 302 cmake_command, 303 extra_env=profiling_env, 304 cwd=self.out_dir, 305 ) 306 307 pgo_tools.run( 308 ["ninja", "-v", "all"], 309 extra_env=profiling_env, 310 cwd=self.out_dir, 311 ) 312 313 314def read_exactly_one_dirent(directory: Path) -> Path: 315 """Returns the single Path under the given directory. Raises otherwise.""" 316 ents = directory.iterdir() 317 ent = next(ents, None) 318 if ent is not None: 319 if next(ents, None) is None: 320 return ent 321 raise ValueError(f"Expected exactly one entry under {directory}") 322 323 324def run_workloads(target_dir: Path) -> Path: 325 """Runs all of our workloads in target_dir. 326 327 Args: 328 target_dir: a directory that already had `fetch_workloads_into` called 329 on it. 330 331 Returns: 332 A directory in which profraw files from running the workloads are 333 saved. 334 """ 335 profraw_dir = target_dir / "profiles" 336 profraw_dir.mkdir() 337 338 out_dir = target_dir / "out" 339 runner = WorkloadRunner( 340 profraw_dir=profraw_dir, 341 target_dir=target_dir, 342 out_dir=out_dir, 343 ) 344 345 # Run the workload once per triple. 346 for triple in IMPORTANT_TRIPLES: 347 runner.run( 348 triple, sysroot=None if triple == HOST_TRIPLE else f"/usr/{triple}" 349 ) 350 351 # Add a run of ThinLTO, so any ThinLTO-specific lld bits get exercised. 352 # Also, since CrOS uses -Os often, exercise that. 353 runner.run(HOST_TRIPLE, extra_cflags="-flto=thin -Os") 354 return profraw_dir 355 356 357def convert_profraw_to_pgo_profile(profraw_dir: Path) -> Path: 358 """Creates a PGO profile from the profraw profiles in profraw_dir.""" 359 output = profraw_dir / "merged.prof" 360 profile_files = list(profraw_dir.glob("profile-*profraw")) 361 if not profile_files: 362 raise ValueError("No profraw files generated?") 363 364 logging.info( 365 "Creating a PGO profile from %d profraw files", len(profile_files) 366 ) 367 generate_command = [ 368 "llvm-profdata", 369 "merge", 370 "--instr", 371 f"--output={output}", 372 ] 373 pgo_tools.run(generate_command + profile_files) 374 return output 375 376 377def main(argv: List[str]): 378 logging.basicConfig( 379 format=">> %(asctime)s: %(levelname)s: %(filename)s:%(lineno)d: " 380 "%(message)s", 381 level=logging.DEBUG, 382 ) 383 384 parser = argparse.ArgumentParser( 385 description=__doc__, 386 formatter_class=argparse.RawDescriptionHelpFormatter, 387 ) 388 parser.add_argument( 389 "--output", 390 required=True, 391 type=Path, 392 help="Where to put the PGO profile", 393 ) 394 parser.add_argument( 395 "--use-old-binpkg", 396 action="store_true", 397 help=""" 398 This script saves your initial LLVM installation as a binpkg, so it may 399 restore that installation later in the build. Passing --use-old-binpkg 400 allows this script to use a binpkg from a prior invocation of this 401 script. 402 """, 403 ) 404 opts = parser.parse_args(argv) 405 406 pgo_tools.exit_if_not_in_chroot() 407 408 output = opts.output 409 410 llvm_binpkg_is_fresh = ensure_llvm_binpkg_exists() 411 if not llvm_binpkg_is_fresh and not opts.use_old_binpkg: 412 sys.exit( 413 textwrap.dedent( 414 f"""\ 415 A LLVM binpkg packed by a previous run of this script is 416 available. If you intend this run to be another attempt at the 417 previous run, please pass --use-old-binpkg (so the old LLVM 418 binpkg is used as our 'baseline'). If you don't, please remove 419 the file referring to it at {SAVED_LLVM_BINPKG_STAMP}. 420 """ 421 ) 422 ) 423 424 logging.info("Ensuring `cross-` libraries are installed") 425 ensure_cross_libs_are_installed() 426 tempdir = Path(tempfile.mkdtemp(prefix="generate_llvm_pgo_profile_")) 427 try: 428 workloads_path = tempdir / "workloads" 429 logging.info("Fetching workloads") 430 fetch_workloads_into(workloads_path) 431 432 # If our binpkg is not fresh, we may be operating with a weird LLVM 433 # (e.g., a PGO'ed one ;) ). Ensure we always start with that binpkg as 434 # our baseline. 435 if not llvm_binpkg_is_fresh: 436 restore_llvm_binpkg() 437 438 logging.info("Building PGO instrumented LLVM") 439 emerge_pgo_generate_llvm() 440 441 logging.info("Ensuring instrumented compilers generate profiles") 442 for triple in IMPORTANT_TRIPLES: 443 ensure_clang_invocations_generate_profiles( 444 triple + "-clang", tempdir 445 ) 446 447 logging.info("Running workloads") 448 profraw_dir = run_workloads(workloads_path) 449 450 # This is a subtle but critical step. The LLVM we're currently working 451 # with was built by the LLVM represented _by our binpkg_, which may be 452 # a radically different version of LLVM than what was installed (e.g., 453 # it could be from our bootstrap SDK, which could be many months old). 454 # 455 # If our current LLVM's llvm-profdata is used to interpret the profraw 456 # files: 457 # 1. The profile generated will be for our new version of clang, and 458 # may therefore be too new for the older version that we still have 459 # to support. 460 # 2. There may be silent incompatibilities, as the stability guarantees 461 # of profraw files are not immediately apparent. 462 logging.info("Restoring LLVM's binpkg") 463 restore_llvm_binpkg() 464 pgo_profile = convert_profraw_to_pgo_profile(profraw_dir) 465 shutil.copyfile(pgo_profile, output) 466 except: 467 # Leave the tempdir, as it might help people debug. 468 logging.info("NOTE: Tempdir will remain at %s", tempdir) 469 raise 470 471 logging.info("Removing now-obsolete tempdir") 472 shutil.rmtree(tempdir) 473 logging.info("PGO profile is available at %s.", output) 474 475 476if __name__ == "__main__": 477 main(sys.argv[1:]) 478