#!/usr/bin/env python3 # Copyright 2023 The ChromiumOS Authors # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. """Generates a PGO profile for LLVM. **This script is meant to be run from inside of the chroot.** Note that this script has a few (perhaps surprising) side-effects: 1. The first time this is run in a chroot, it will pack up your existing llvm and save it as a binpkg. 2. This script clobbers your llvm installation. If the script is run to completion, your old installation will be restored. If it does not, it may not be. """ import argparse import dataclasses import logging import os from pathlib import Path import shlex import shutil import subprocess import sys import tempfile import textwrap from typing import Dict, FrozenSet, List, Optional import pgo_tools # This script runs `quickpkg` on LLVM. This file saves the version of LLVM that # was quickpkg'ed. SAVED_LLVM_BINPKG_STAMP = Path("/tmp/generate_pgo_profile_old_llvm.txt") # Triple to build with when not trying to get backend coverage. HOST_TRIPLE = "x86_64-pc-linux-gnu" # List of triples we want coverage for. IMPORTANT_TRIPLES = ( HOST_TRIPLE, "x86_64-cros-linux-gnu", "armv7a-cros-linux-gnueabihf", "aarch64-cros-linux-gnu", ) # Set of all of the cross-* libraries we need. ALL_NEEDED_CROSS_LIBS = frozenset( f"cross-{triple}/{package}" for triple in IMPORTANT_TRIPLES if triple != HOST_TRIPLE for package in ("glibc", "libcxx", "llvm-libunwind", "linux-headers") ) def ensure_llvm_binpkg_exists() -> bool: """Verifies that we have an LLVM binpkg to fall back on. Returns: True if this function actually created a binpkg, false if one already existed. """ if SAVED_LLVM_BINPKG_STAMP.exists(): pkg = Path(SAVED_LLVM_BINPKG_STAMP.read_text(encoding="utf-8")) # Double-check this, since this package is considered a cache artifact # by portage. Ergo, it can _technically_ be GC'ed at any time. if pkg.exists(): return False pkg = pgo_tools.quickpkg_llvm() SAVED_LLVM_BINPKG_STAMP.write_text(str(pkg), encoding="utf-8") return True def restore_llvm_binpkg(): """Installs the binpkg created by ensure_llvm_binpkg_exists.""" logging.info("Restoring non-PGO'ed LLVM installation") pkg = Path(SAVED_LLVM_BINPKG_STAMP.read_text(encoding="utf-8")) assert ( pkg.exists() ), f"Non-PGO'ed binpkg at {pkg} does not exist. Can't restore" pgo_tools.run(pgo_tools.generate_quickpkg_restoration_command(pkg)) def find_missing_cross_libs() -> FrozenSet[str]: """Returns cross-* libraries that need to be installed for workloads.""" equery_result = pgo_tools.run( ["equery", "l", "--format=$cp", "cross-*/*"], check=False, stdout=subprocess.PIPE, ) # If no matching package is found, equery will exit with code 3. if equery_result.returncode == 3: return ALL_NEEDED_CROSS_LIBS equery_result.check_returncode() has_packages = {x.strip() for x in equery_result.stdout.splitlines()} return ALL_NEEDED_CROSS_LIBS - has_packages def ensure_cross_libs_are_installed(): """Ensures that we have cross-* libs for all `IMPORTANT_TRIPLES`.""" missing_packages = find_missing_cross_libs() if not missing_packages: logging.info("All cross-compiler libraries are already installed") return missing_packages = sorted(missing_packages) logging.info("Installing cross-compiler libs: %s", missing_packages) pgo_tools.run( ["sudo", "emerge", "-j", "-G"] + missing_packages, ) def emerge_pgo_generate_llvm(): """Emerges a sys-devel/llvm with PGO instrumentation enabled.""" force_use = ( "llvm_pgo_generate -llvm_pgo_use" # Turn ThinLTO off, since doing so results in way faster builds. # This is assumed to be OK, since: # - ThinLTO should have no significant impact on where Clang puts # instrprof counters. # - In practice, both "PGO generated with ThinLTO enabled," and "PGO # generated without ThinLTO enabled," were benchmarked, and the # performance difference between the two was in the noise. " -thinlto" # Turn ccache off, since if there are valid ccache artifacts from prior # runs of this script, ccache will lead to us not getting profdata from # those. " -wrapper_ccache" ) use = (os.environ.get("USE", "") + " " + force_use).strip() # Use FEATURES=ccache since it's not much of a CPU time penalty, and if a # user runs this script repeatedly, they'll appreciate it. :) force_features = "ccache" features = (os.environ.get("FEATURES", "") + " " + force_features).strip() logging.info("Building LLVM with USE=%s", shlex.quote(use)) pgo_tools.run( [ "sudo", f"FEATURES={features}", f"USE={use}", "emerge", "sys-devel/llvm", ] ) def build_profiling_env(profile_dir: Path) -> Dict[str, str]: profile_pattern = str(profile_dir / "profile-%m.profraw") return { "LLVM_PROFILE_OUTPUT_FORMAT": "profraw", "LLVM_PROFILE_FILE": profile_pattern, } def ensure_clang_invocations_generate_profiles(clang_bin: str, tmpdir: Path): """Raises an exception if clang doesn't generate profraw files. Args: clang_bin: the path to a clang binary. tmpdir: a place where this function can put temporary files. """ tmpdir = tmpdir / "ensure_profiles_generated" tmpdir.mkdir(parents=True) pgo_tools.run( [clang_bin, "--help"], extra_env=build_profiling_env(tmpdir), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) is_empty = next(tmpdir.iterdir(), None) is None if is_empty: raise ValueError( f"The clang binary at {clang_bin} generated no profile" ) shutil.rmtree(tmpdir) def write_unified_cmake_file( into_dir: Path, absl_subdir: Path, gtest_subdir: Path ): (into_dir / "CMakeLists.txt").write_text( textwrap.dedent( f"""\ cmake_minimum_required(VERSION 3.10) project(generate_pgo) add_subdirectory({gtest_subdir}) add_subdirectory({absl_subdir})""" ), encoding="utf-8", ) def fetch_workloads_into(target_dir: Path): """Fetches PGO generation workloads into `target_dir`.""" # The workload here is absl and gtest. The reasoning behind that selection # was essentially a mix of: # - absl is reasonably-written and self-contained # - gtest is needed if tests are to be built; in order to have absl do much # of any linking, gtest is necessary. # # Use the version of absl that's bundled with ChromeOS at the time of # writing. target_dir.mkdir(parents=True) def fetch_and_extract(gs_url: str, into_dir: Path): tgz_full = target_dir / os.path.basename(gs_url) pgo_tools.run( [ "gsutil", "cp", gs_url, tgz_full, ], ) into_dir.mkdir() pgo_tools.run( ["tar", "xaf", tgz_full], cwd=into_dir, ) absl_dir = target_dir / "absl" fetch_and_extract( gs_url="gs://chromeos-localmirror/distfiles/" "abseil-cpp-a86bb8a97e38bc1361289a786410c0eb5824099c.tar.bz2", into_dir=absl_dir, ) gtest_dir = target_dir / "gtest" fetch_and_extract( gs_url="gs://chromeos-mirror/gentoo/distfiles/" "gtest-1b18723e874b256c1e39378c6774a90701d70f7a.tar.gz", into_dir=gtest_dir, ) unpacked_absl_dir = read_exactly_one_dirent(absl_dir) unpacked_gtest_dir = read_exactly_one_dirent(gtest_dir) write_unified_cmake_file( into_dir=target_dir, absl_subdir=unpacked_absl_dir.relative_to(target_dir), gtest_subdir=unpacked_gtest_dir.relative_to(target_dir), ) @dataclasses.dataclass(frozen=True) class WorkloadRunner: """Runs benchmark workloads.""" profraw_dir: Path target_dir: Path out_dir: Path def run( self, triple: str, extra_cflags: Optional[str] = None, sysroot: Optional[str] = None, ): logging.info( "Running workload for triple %s, extra cflags %r", triple, extra_cflags, ) if self.out_dir.exists(): shutil.rmtree(self.out_dir) self.out_dir.mkdir(parents=True) clang = triple + "-clang" profiling_env = build_profiling_env(self.profraw_dir) if sysroot: profiling_env["SYSROOT"] = sysroot cmake_command: pgo_tools.Command = [ "cmake", "-G", "Ninja", "-DCMAKE_BUILD_TYPE=RelWithDebInfo", f"-DCMAKE_C_COMPILER={clang}", f"-DCMAKE_CXX_COMPILER={clang}++", "-DABSL_BUILD_TESTING=ON", "-DABSL_USE_EXTERNAL_GOOGLETEST=ON", "-DABSL_USE_GOOGLETEST_HEAD=OFF", "-DABSL_FIND_GOOGLETEST=OFF", ] if extra_cflags: cmake_command += ( f"-DCMAKE_C_FLAGS={extra_cflags}", f"-DCMAKE_CXX_FLAGS={extra_cflags}", ) cmake_command.append(self.target_dir) pgo_tools.run( cmake_command, extra_env=profiling_env, cwd=self.out_dir, ) pgo_tools.run( ["ninja", "-v", "all"], extra_env=profiling_env, cwd=self.out_dir, ) def read_exactly_one_dirent(directory: Path) -> Path: """Returns the single Path under the given directory. Raises otherwise.""" ents = directory.iterdir() ent = next(ents, None) if ent is not None: if next(ents, None) is None: return ent raise ValueError(f"Expected exactly one entry under {directory}") def run_workloads(target_dir: Path) -> Path: """Runs all of our workloads in target_dir. Args: target_dir: a directory that already had `fetch_workloads_into` called on it. Returns: A directory in which profraw files from running the workloads are saved. """ profraw_dir = target_dir / "profiles" profraw_dir.mkdir() out_dir = target_dir / "out" runner = WorkloadRunner( profraw_dir=profraw_dir, target_dir=target_dir, out_dir=out_dir, ) # Run the workload once per triple. for triple in IMPORTANT_TRIPLES: runner.run( triple, sysroot=None if triple == HOST_TRIPLE else f"/usr/{triple}" ) # Add a run of ThinLTO, so any ThinLTO-specific lld bits get exercised. # Also, since CrOS uses -Os often, exercise that. runner.run(HOST_TRIPLE, extra_cflags="-flto=thin -Os") return profraw_dir def convert_profraw_to_pgo_profile(profraw_dir: Path) -> Path: """Creates a PGO profile from the profraw profiles in profraw_dir.""" output = profraw_dir / "merged.prof" profile_files = list(profraw_dir.glob("profile-*profraw")) if not profile_files: raise ValueError("No profraw files generated?") logging.info( "Creating a PGO profile from %d profraw files", len(profile_files) ) generate_command = [ "llvm-profdata", "merge", "--instr", f"--output={output}", ] pgo_tools.run(generate_command + profile_files) return output def main(argv: List[str]): logging.basicConfig( format=">> %(asctime)s: %(levelname)s: %(filename)s:%(lineno)d: " "%(message)s", level=logging.DEBUG, ) parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--output", required=True, type=Path, help="Where to put the PGO profile", ) parser.add_argument( "--use-old-binpkg", action="store_true", help=""" This script saves your initial LLVM installation as a binpkg, so it may restore that installation later in the build. Passing --use-old-binpkg allows this script to use a binpkg from a prior invocation of this script. """, ) opts = parser.parse_args(argv) pgo_tools.exit_if_not_in_chroot() output = opts.output llvm_binpkg_is_fresh = ensure_llvm_binpkg_exists() if not llvm_binpkg_is_fresh and not opts.use_old_binpkg: sys.exit( textwrap.dedent( f"""\ A LLVM binpkg packed by a previous run of this script is available. If you intend this run to be another attempt at the previous run, please pass --use-old-binpkg (so the old LLVM binpkg is used as our 'baseline'). If you don't, please remove the file referring to it at {SAVED_LLVM_BINPKG_STAMP}. """ ) ) logging.info("Ensuring `cross-` libraries are installed") ensure_cross_libs_are_installed() tempdir = Path(tempfile.mkdtemp(prefix="generate_llvm_pgo_profile_")) try: workloads_path = tempdir / "workloads" logging.info("Fetching workloads") fetch_workloads_into(workloads_path) # If our binpkg is not fresh, we may be operating with a weird LLVM # (e.g., a PGO'ed one ;) ). Ensure we always start with that binpkg as # our baseline. if not llvm_binpkg_is_fresh: restore_llvm_binpkg() logging.info("Building PGO instrumented LLVM") emerge_pgo_generate_llvm() logging.info("Ensuring instrumented compilers generate profiles") for triple in IMPORTANT_TRIPLES: ensure_clang_invocations_generate_profiles( triple + "-clang", tempdir ) logging.info("Running workloads") profraw_dir = run_workloads(workloads_path) # This is a subtle but critical step. The LLVM we're currently working # with was built by the LLVM represented _by our binpkg_, which may be # a radically different version of LLVM than what was installed (e.g., # it could be from our bootstrap SDK, which could be many months old). # # If our current LLVM's llvm-profdata is used to interpret the profraw # files: # 1. The profile generated will be for our new version of clang, and # may therefore be too new for the older version that we still have # to support. # 2. There may be silent incompatibilities, as the stability guarantees # of profraw files are not immediately apparent. logging.info("Restoring LLVM's binpkg") restore_llvm_binpkg() pgo_profile = convert_profraw_to_pgo_profile(profraw_dir) shutil.copyfile(pgo_profile, output) except: # Leave the tempdir, as it might help people debug. logging.info("NOTE: Tempdir will remain at %s", tempdir) raise logging.info("Removing now-obsolete tempdir") shutil.rmtree(tempdir) logging.info("PGO profile is available at %s.", output) if __name__ == "__main__": main(sys.argv[1:])