Skip to content

Collect normal-mode profiles from XCUITest target apps - #3037

Draft
karim-alweheshy wants to merge 2 commits into
bazelbuild:mainfrom
karim-alweheshy:codex/target-app-normal-profiles
Draft

karim-alweheshy wants to merge 2 commits into
bazelbuild:mainfrom
karim-alweheshy:codex/target-app-normal-profiles

Conversation

@karim-alweheshy

@karim-alweheshy karim-alweheshy commented Jul 29, 2026 •

Copy link
Copy Markdown

🤖 AI Disclaimer

I used Codex to help investigate, implement, and test this change. I reviewed
the resulting behavior against LLVM's profile format and exercised the
integration test locally.

Why this work

XCUITests can drive realistic app journeys such as launch, navigation, data
loading, scrolling, and user interaction. Those journeys are useful inputs for
instrumentation PGO only if the profile comes from the target app process that
executes the production code.

The current ios_xctestrun_runner can collect coverage from the XCTest runner,
but it cannot put LLVM_PROFILE_FILE in the XCUITest target app's environment
or retrieve that app's raw profiles. Xcode's
ClangProfileDataDirectoryPath path also selects compiler-rt continuous mode.
That mode preserves execution counters, but its writer omits LLVM value profile
data such as indirect-call targets. A counter-complete profile can therefore
still be insufficient for indirect-call promotion and profile-guided
devirtualization.

What this enables

This change lets a Bazel XCUITest turn a real target-app journey into fresh,
normal-mode LLVM profile data:

  • per-process target-app .profraw files for inspection or later merging;
  • a merged Coverage.profdata artifact suitable as input to a later
    instrumentation-PGO build;
  • value profile records, including indirect-call targets and memory-operation
    sizes, that continuous-mode coverage does not write; and
  • deterministic CI failure when the app writes no fresh profile or writes an
    invalid one, instead of silently accepting stale or incomplete data.

How a PGO build uses it

A subsequent optimized Swift build can consume the merged profile with
-profile-use=<profdata>; Clang's corresponding IR-PGO path uses
-fprofile-use=<path>. LLVM then uses two complementary classes of information:

  • Execution counters describe hot functions, blocks, branches, loops, and
    call sites. They guide inlining, branch and block layout, hot/cold splitting,
    function placement, and optimization-versus-code-size decisions.
  • Indirect-call target values tell LLVM which concrete callee usually sits
    behind a function pointer, closure, callback, or other surviving indirect
    dispatch. PGOIndirectCallPromotion can guard for the common target and turn
    its hot path into a direct call, enabling further inlining, constant
    propagation, specialization, and devirtualization.
  • Memory-operation size values let PGOMemOPSizeOpt specialize operations
    such as memcpy, memmove, memset, memcmp, and bcmp for their hottest
    observed sizes so they can become more efficient inline sequences.
  • LLVM's value-profile format can also carry C++ vtable targets when that
    optional profiling mode is enabled, allowing more efficient virtual-call
    promotion.

Continuous mode retains the execution counters, so it can still support basic
counter-guided optimization. The missing value records remove the additional
call-target and operation-size specialization opportunities above.

This PR does not invoke the profile-use compilation, guarantee that every
record results in a transformation, or change the release build by itself. It
provides the reliable target-app profile collection stage; a later optimized
build must consume Coverage.profdata, and LLVM decides which transformations
are profitable. Objective-C message sends are not generally equivalent to LLVM
indirect-call sites and are not implied by this claim.

How collection works

Passing:

--test_env=LLVM_PROFILE_FILE_FOR_TARGET_APP=%t/<name>-%p.profraw

makes the runner:

  1. Validate that collection is being requested for a simulator XCUITest with a
    host app and a safe per-process filename.
  2. Put LLVM_PROFILE_FILE in the target app through
    UITargetAppEnvironmentVariables, rather than setting it only on the XCTest
    runner.
  3. Remove ClangProfileDataDirectoryPath and its paired coverage metadata from
    the generated xctestrun file so Xcode does not select continuous mode.
  4. Clear old outputs and record a freshness marker before xcodebuild starts.
  5. Let the instrumented target app write its profile on normal process exit, or
    explicitly flush it at a chosen journey boundary with
    __llvm_profile_write_file().
  6. Resolve the target app's simulator data container after the test, select only
    matching files newer than the marker, and copy them to
    target-app-profraw in Bazel's undeclared outputs.
  7. Merge the fresh raw profiles with llvm-profdata into
    Coverage.profdata, failing the test if no fresh profile exists or merging
    fails.

The filename must be directly under %t, contain %p as its only percent
substitution, and contain no glob characters, backslashes, or line breaks.
%p prevents separate target-app processes from overwriting one another.

An explicit flush must run inside the target app because that process owns the
profile counters and value records. A call or swizzle in the XCTest runner
cannot flush another process. An explicit call is not required when
compiler-rt's normal-exit writer already produces a fresh profile, but it is
useful when the journey boundary must be deterministic or the app may later be
force-terminated or crash. The rule documentation includes Objective-C and
Swift examples.

Related to #338.

Verification

Tested locally with Bazel 9.2.0 and Xcode 26.0 (17A321):

  • bazel build //apple/testing/default_runner:ios_default_runner
  • a focused eight-case lifecycle matrix through
    //test:ios_xctestrun_runner_ui_test, covering:
    • successful target-app collection and indirect-call target values;
    • XML-safe & filenames and rejection of unsupported backslashes;
    • stale-output cleanup on unsupported arguments, premature-exit setup
      failures, pre-action failure, and TERM interruption;
    • missing and malformed target profiles while still running post-action and
      simulator cleanup; and
    • a determining post-action exit of 23 retaining precedence over simulator
      cleanup exit 148 while preserving the XCResult and fresh profile outputs.
  • bazel test //doc:check_ios
  • shellcheck test/ios_xctestrun_runner_ui_test.sh
  • git diff --check and Buildifier checks
  • independent adversarial review of the final diff: SHIP

The broader runner unit suite is locally limited by the repository fixture's
iPhone Xs selection being incompatible with the installed iOS 26 runtime.
The focused simulator executions above ran on iPhone 16/iOS 26.0.

The committed integration case launches an IR-instrumented target app twice,
explicitly flushes in the target process, collects two fresh raw profiles,
merges them, and verifies with llvm-profdata show --ic-targets that the merged
profile contains an indirect-call target record.

@karim-alweheshy
karim-alweheshy force-pushed the codex/target-app-normal-profiles branch 3 times, most recently from f4f4b97 to a71f243 Compare July 29, 2026 12:07
@karim-alweheshy

karim-alweheshy commented Aug 4, 2026 •

Copy link
Copy Markdown
Author

CI follow-up: a no-source-change retry is fully green across Bazel 8.0.1, Bazel 8.x, Bazel 9.x, last-green Bazel, buildifier, and documentation tests. The prior failure had the same unrelated simulator and coverage targets time out in both Bazel 8 lanes; this PR’s new profiling integration target did not fail, and an earlier same-patch lane also passed. I therefore treated the failure as shared simulator infrastructure noise and did not add a source workaround.

@karim-alweheshy
karim-alweheshy force-pushed the codex/target-app-normal-profiles branch from 04101b7 to d4cdf16 Compare August 5, 2026 05:22
@karim-alweheshy
karim-alweheshy marked this pull request as ready for review August 5, 2026 05:22
@karim-alweheshy

Copy link
Copy Markdown
Author

@aaronsky @keith, could one of you review this XCUITest runner change when convenient? It preserves the existing test-process profile collection and adds normal-mode collection from the launched target app, with focused integration coverage. The no-source-change CI retry is green across every supported Bazel lane.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant