Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@
# not text. Mark as binary so diff viewers stop rendering raw bytes as
# garbled glyphs.
*.depex binary

# Shell scripts are executed by Linux CI and by WSL on developer machines, both
# of which reject a CRLF shebang. Keep them LF even where autocrlf is on.
*.sh text eol=lf
75 changes: 75 additions & 0 deletions .github/workflows/uefi-perf-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# CI checks for the boot and performance scripts under uefi/perf/.
#
# Running these scripts for real needs QEMU and a firmware build, which is more
# than a hosted runner should take on. This checks what can be checked without
# booting anything: shellcheck over the shell scripts, and that the Python
# helpers compile.
#
# SPDX-License-Identifier: MIT
#

permissions:
contents: read

on:
push:
branches: [main]
paths:
- 'uefi/perf/**'
- '.github/workflows/uefi-perf-check.yml'
pull_request:
paths:
- 'uefi/perf/**'
- '.github/workflows/uefi-perf-check.yml'

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true

name: uefi-perf-check

jobs:
scripts:
name: scripts / uefi-perf
runs-on: ubuntu-latest
env:
# Pinned so that a new shellcheck release cannot turn an untouched branch
# red on its own. The checksum covers the download.
SHELLCHECK_VERSION: v0.10.0
SHELLCHECK_SHA256: 6c881ab0698e4e6ea235245f22832860544f17ba386442fe7e9d629f8cbedf87
steps:
- uses: actions/checkout@v4

# shellcheck is not part of the hosted runner image.
- name: Install shellcheck
run: |
set -euo pipefail
archive="shellcheck-${SHELLCHECK_VERSION}.linux.x86_64.tar.xz"
curl -fsSL --retry 3 -o "$archive" \
"https://github.com/koalaman/shellcheck/releases/download/${SHELLCHECK_VERSION}/${archive}"
echo "${SHELLCHECK_SHA256} ${archive}" | sha256sum --check --strict
tar -xJf "$archive"
sudo install -m 0755 "shellcheck-${SHELLCHECK_VERSION}/shellcheck" /usr/local/bin/shellcheck
shellcheck --version

- name: Check shell scripts
run: |
set -euo pipefail
mapfile -t scripts < <(find uefi/perf -name '*.sh' -print | sort)
if [ "${#scripts[@]}" -eq 0 ]; then
echo "no shell scripts found under uefi/perf" >&2
exit 1
fi
printf 'checking %s\n' "${scripts[@]}"
shellcheck -S style "${scripts[@]}"

- name: Compile Python helpers
run: |
set -euo pipefail
mapfile -t helpers < <(find uefi/perf -name '*.py' -print | sort)
if [ "${#helpers[@]}" -eq 0 ]; then
echo "no Python helpers found under uefi/perf" >&2
exit 1
fi
printf 'compiling %s\n' "${helpers[@]}"
python3 -m py_compile "${helpers[@]}"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
# Ignore any component build artifacts
**/build/
**/target/
**/__pycache__/
120 changes: 120 additions & 0 deletions uefi/perf/qemu/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Boot and performance measurement under QEMU

Boots Patina firmware under QEMU and measures how long it takes, so boot
numbers come from a reproducible machine rather than from hardware.

- `run-q35-boot.sh` boots the firmware and checks that it reaches BDS.
- `make-fbpt-disk.sh` and `capture-fbpt.sh` capture and parse firmware
performance data.

## Scope

Q35 only. The scripts hardcode `qemu-system-x86_64`, `-machine q35`, and the
`QEMUQ35_CODE.fd` / `QEMUQ35_VARS.fd` flash pair.

## Requirements

- `qemu-system-x86_64` on `PATH`.
- A Q35 firmware directory containing `QEMUQ35_CODE.fd` and `QEMUQ35_VARS.fd`.

## Building firmware

Build [`patina-qemu`](https://github.com/OpenDevicePartnership/patina-qemu)
following [Building the Firmware][building], adding the performance flag:

```sh
stuart_build -c Platforms/QemuQ35Pkg/PlatformBuild.py 'BLD_*_PERF_TRACE_ENABLE=TRUE'
```

Images land in `Build/QemuQ35Pkg/DEBUG_CLANGPDB/FV/`.

`PERF_TRACE_ENABLE` defaults to `FALSE`. Platform PEI always publishes the
Patina performance configuration HOB, which carries the enable state and the
bitmask selecting which measurements are recorded; the DXE Core reads its
configuration from that HOB. The flag is what PEI writes into it, so the
setting is fixed at build time, and swapping in a different DXE Core binary
will not turn measurement on.

## Running

```sh
./run-q35-boot.sh --firmware-dir <dir> --out-dir <dir>
```

`boot-debugcon.log` in the output directory holds the debug console output and
is the first thing to check on failure. The variable store is copied before
use, so the firmware directory stays reusable. Each script documents its own
exit codes in its header.

## Confirming that measurement is enabled

A firmware built with the tracing flag reports the configuration it published
during PEI:

```text
PublishPatinaPerformanceConfigHob: Patina Performance Config HOB: Enabled=1, EnabledMeasurements=0x9
```

`Enabled=0` means the firmware was built without `PERF_TRACE_ENABLE` and will
produce no performance records, though it still boots normally.

## Capturing firmware performance data

Patina publishes a firmware basic boot performance table (FBPT) during boot.
Reading it needs the UEFI Shell application `FbptDump.efi`, which is not part
of a default build. Add `UefiTestingPkg/PerfTests/FbptDump/FbptDump.inf` to
`QemuQ35Pkg.dsc` before building.

Build a disk that boots to the shell and dumps the table, then capture:

```sh
./make-fbpt-disk.sh --build-dir <build>/X64 --out dump-disk.img
./capture-fbpt.sh --firmware-dir <fw> --disk dump-disk.img --out-dir <results>
```

The guest powers itself off once the dump completes, so the capture ends on its
own rather than on a timeout. The output directory receives `FBPT.bin`, the
boot log, the dump application's output, and the parsed `fbpt.xml` /
`fbpt.txt`.

Each capture prints the boot time in milliseconds, taken from the ACPI basic
boot performance record:

```text
boot time (reset to OS loader handoff): 2642.295 ms
ResetEnd 0.000 ms
OSLoaderLoadImageStart 2629.683 ms
OSLoaderStartImageStart 2642.295 ms
```

Per-phase records are not all on one time base in this firmware, so the summary
stops there; use `fbpt.txt` or `fbpt.xml` for that detail. The summary can also
be run against an existing report:

```sh
python3 boot_time_summary.py <results>/fbpt.xml
```

### Parsing

Parsing needs `edk2-pytool-extensions`:

```sh
pip install edk2-pytool-extensions
```

`capture-fbpt.sh` runs the parser through `fpdt_parser_any_platform.py`, a
wrapper that lets the Windows-oriented `fpdt_parser` run on Linux, where CI
runs. Its module docstring covers the detail.

For a breakdown by module, feed the parsed XML to the report generator with a
source tree to resolve GUIDs against:

```sh
perf_report_generator -i <results>/fbpt.xml -r report.html -s <patina-qemu>
```

Unmatched start records are expected when the guest powers off from the shell,
since phases that would normally end at boot never complete.

[building]: https://github.com/OpenDevicePartnership/patina-qemu/blob/main/docs/src/building/building.md
97 changes: 97 additions & 0 deletions uefi/perf/qemu/boot_time_summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Prints ms-resolution boot milestones from a parsed FBPT XML report.

The capture harness is expected to report a boot time for each QEMU
invocation, so every run prints a short summary instead of leaving the numbers
to manual post-processing of the generated reports.

Only the ACPI-defined basic boot performance record is summarized here. The
parser also emits per-phase records, but their reported millisecond values are
not all on one time base in this firmware -- PEI end, for example, comes out as
264303577 ms, roughly 73 hours -- so folding them into a summary would report
figures that look authoritative and are not. Per-phase detail remains available
in the generated text and XML reports for anyone who needs it.

License
-------

Copyright (c) Microsoft Corporation.

SPDX-License-Identifier: MIT
"""

import sys
import xml.etree.ElementTree as ET

BASIC_BOOT_RECORD = "FirmwareBasicBootPerformanceEvent"
MILLISECONDS_ATTRIBUTE = "ValueInMilliseconds"

# Reset is the zero point of the record, and the OS loader milestones bracket
# the handoff out of firmware, so the start of the loader image is the boot
# time this harness reports.
RESET_MILESTONE = "ResetEnd"
OS_LOADER_HANDOFF_MILESTONE = "OSLoaderStartImageStart"

REPORTED_MILESTONES = (
RESET_MILESTONE,
"OSLoaderLoadImageStart",
OS_LOADER_HANDOFF_MILESTONE,
"ExitBootServicesEntry",
"ExitBootServicesExit",
)

# Milestones the firmware never reached are left at zero. Reset legitimately
# sits at zero, so it is always reported; the rest are only shown once they
# hold a real measurement.
UNREACHED_MILESTONE_MS = 0.0

EXIT_USAGE = 2
EXIT_NO_DATA = 1


def _milliseconds(element):
raw = element.get(MILLISECONDS_ATTRIBUTE)
return None if raw is None else float(raw)


def summarize(xml_path):
"""Prints the boot milestones, returning a process exit code."""
record = ET.parse(xml_path).getroot().find(f".//{BASIC_BOOT_RECORD}")
if record is None:
print(f"no {BASIC_BOOT_RECORD} in {xml_path}", file=sys.stderr)
return EXIT_NO_DATA

handoff = record.find(OS_LOADER_HANDOFF_MILESTONE)
handoff_ms = None if handoff is None else _milliseconds(handoff)
if handoff_ms is None or handoff_ms == UNREACHED_MILESTONE_MS:
print(
f"no {OS_LOADER_HANDOFF_MILESTONE} timestamp; the guest did not "
"reach the OS loader",
file=sys.stderr,
)
return EXIT_NO_DATA

print(f"boot time (reset to OS loader handoff): {handoff_ms:.3f} ms")

for name in REPORTED_MILESTONES:
element = record.find(name)
if element is None:
continue
value = _milliseconds(element)
if value is None:
continue
if value == UNREACHED_MILESTONE_MS and name != RESET_MILESTONE:
continue
print(f" {name:<26}{value:>12.3f} ms")

return 0


def main(argv):
if len(argv) != 2:
print(f"usage: {argv[0]} FBPT_XML", file=sys.stderr)
return EXIT_USAGE
return summarize(argv[1])


if __name__ == "__main__":
sys.exit(main(sys.argv))
Loading
Loading