xref: /aosp_15_r20/external/toolchain-utils/pgo_tools/generate_pgo_profile.py (revision 760c253c1ed00ce9abd48f8546f08516e57485fe)
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