aboutsummaryrefslogtreecommitdiff
path: root/pgo_tools/generate_pgo_profile.py
blob: a5fb7d45bb98fe88e50b920195093b92c862d6aa (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
#!/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 -llvm-next_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)

    if not Path("/etc/cros_chroot_version").exists():
        sys.exit("Run me inside of the 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:])