diff --git a/.github/workflows/buildAndroid.yml b/.github/workflows/buildAndroid.yml
index de8b5ff08840..7f5df3a09a07 100644
--- a/.github/workflows/buildAndroid.yml
+++ b/.github/workflows/buildAndroid.yml
@@ -27,6 +27,14 @@ on:
description: Force a full native build, bypassing Rock remote cache
type: string
default: 'false'
+ run-pgo:
+ description: Train and use a native PGO profile for a Release build
+ type: boolean
+ default: false
+ benchmark-pgo:
+ description: Benchmark paired local builds after the PGO Release build
+ type: boolean
+ default: false
outputs:
VERSION_CODE:
@@ -51,7 +59,7 @@ on:
jobs:
build:
name: Build Android app
- runs-on: blacksmith-32vcpu-ubuntu-2404
+ runs-on: ${{ inputs.run-pgo && 'pgo-android' || 'blacksmith-32vcpu-ubuntu-2404' }}
env:
PULL_REQUEST_NUMBER: ${{ inputs.pull-request-number }}
outputs:
@@ -62,6 +70,12 @@ jobs:
SOURCEMAP_FILENAME: index.android.bundle.map
PROGUARD_MAPPING_FILENAME: mapping.txt
steps:
+ - name: Validate PGO build inputs
+ if: ${{ (inputs.run-pgo && inputs.variant != 'Release') || (inputs.benchmark-pgo && !inputs.run-pgo) }}
+ run: |
+ echo '::error::PGO training requires a Release build, and benchmarking requires PGO training.'
+ exit 1
+
- name: Checkout
uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
@@ -160,13 +174,40 @@ jobs:
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
+ - name: Train Android PGO on the signed-in physical device
+ if: ${{ inputs.run-pgo }}
+ env:
+ SENTRY_DISABLE_AUTO_UPLOAD: 'true'
+ SENTRY_DISABLE_NATIVE_DEBUG_UPLOAD: 'true'
+ run: |
+ set -euo pipefail
+ : "${PGO_ANDROID_DEVICE_ID:?Set PGO_ANDROID_DEVICE_ID on the device runner}"
+ : "${PGO_JOURNEY_FIXTURE:?Set PGO_JOURNEY_FIXTURE to an owner-approved heavy-account fixture}"
+ test -s "$PGO_JOURNEY_FIXTURE"
+ command -v agent-device
+ test "$(agent-device --version)" = '0.20.6'
+ test -d "${ANDROID_NDK_HOME:?Set ANDROID_NDK_HOME to the pinned NDK}"
+ ./node_modules/.bin/bun scripts/pgo/pgo.ts android build-instrumented --device "$PGO_ANDROID_DEVICE_ID"
+ ./node_modules/.bin/bun scripts/pgo/pgo.ts android verify-instrumented --device "$PGO_ANDROID_DEVICE_ID"
+ ./node_modules/.bin/bun scripts/pgo/pgo.ts android install-instrumented --device "$PGO_ANDROID_DEVICE_ID"
+ batch_id="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
+ ./node_modules/.bin/bun scripts/pgo/journey.ts android --fixture "$PGO_JOURNEY_FIXTURE" --device "$PGO_ANDROID_DEVICE_ID" --preflight --batch-id "${batch_id}-preflight"
+ ./node_modules/.bin/bun scripts/pgo/journey.ts android --fixture "$PGO_JOURNEY_FIXTURE" --device "$PGO_ANDROID_DEVICE_ID" --runs 3 --collect --batch-id "$batch_id"
+
+ - name: Validate Android training profile
+ id: preparePgo
+ if: ${{ inputs.run-pgo }}
+ run: ./node_modules/.bin/bun scripts/pgo/ciProfile.ts prepare android "${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
+
- name: Rock Remote Build - Android
uses: callstackincubator/android@4cedf4d9b5c167452c96fe67233577e0fde9a025
env:
GITHUB_TOKEN: ${{ github.token }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
IS_HYBRID_APP: true
- FORCE_NATIVE_BUILD: ${{ inputs.force-native-build == 'true' && github.run_id || '' }}
+ FORCE_NATIVE_BUILD: ${{ inputs.run-pgo && format('{0}-{1}', github.run_id, github.run_attempt) || inputs.force-native-build == 'true' && github.run_id || '' }}
+ EXPENSIFY_PGO_MODE: ${{ inputs.run-pgo && 'use' || 'off' }}
+ PGO_PROFILE_SHA256: ${{ steps.preparePgo.outputs.PROFILE_SHA256 }}
with:
variant: ${{ inputs.variant }}
aab: ${{ inputs.variant == 'Release' }}
@@ -182,7 +223,7 @@ jobs:
keystore-key-password: ${{ steps.load-credentials.outputs.ANDROID_UPLOAD_KEY_PASSWORD }}
keystore-path: '../tools/buildtools/upload-key.keystore'
comment-bot: false
- rock-build-extra-params: ${{ inputs.variant == 'Adhoc' && '--extra-params "-PreactNativeArchitectures=arm64-v8a,x86_64 --profile"' || '--extra-params "--profile"' }}
+ rock-build-extra-params: ${{ inputs.run-pgo && format('--local --extra-params "-PpgoMode=use -PpgoProfile={0} -PpatchedArtifacts.forceBuildFromSource=true --no-build-cache --rerun-tasks --profile"', steps.preparePgo.outputs.PROFILE_PATH) || inputs.variant == 'Adhoc' && '--extra-params "-PreactNativeArchitectures=arm64-v8a,x86_64 --profile"' || '--extra-params "--profile"' }}
custom-identifier: ${{ steps.computeIdentifier.outputs.IDENTIFIER }}
validate-elf-alignment: ${{ inputs.variant != 'Release' && 'true' || 'false' }}
@@ -251,6 +292,18 @@ jobs:
name: ${{ inputs.artifact-prefix }}androidBuild-artifact
path: /tmp/android-artifacts/*.aab
+ - name: Seal Android PGO release proof
+ if: ${{ inputs.run-pgo }}
+ run: ./node_modules/.bin/bun scripts/pgo/ciProfile.ts seal android "$(jq -r .version package.json)" "/tmp/android-artifacts/${{ steps.collectArtifacts.outputs.AAB_FILENAME }}" /tmp/android-artifacts/pgo-proof-android.json
+
+ - name: Upload Android PGO release proof
+ if: ${{ inputs.run-pgo }}
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
+ with:
+ name: ${{ inputs.artifact-prefix }}pgo-proof-android
+ path: /tmp/android-artifacts/pgo-proof-android.json
+ if-no-files-found: error
+
- name: Upload Android sourcemap artifact
# v6
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
@@ -303,3 +356,31 @@ jobs:
with:
name: ${{ inputs.artifact-prefix }}android-apk-artifact
path: Expensify.apk
+
+ - name: Benchmark paired Android PGO builds
+ if: ${{ inputs.benchmark-pgo }}
+ env:
+ SENTRY_DISABLE_AUTO_UPLOAD: 'true'
+ SENTRY_DISABLE_NATIVE_DEBUG_UPLOAD: 'true'
+ run: |
+ set -euo pipefail
+ scripts/pgo/pgo.ts android build-release --device "$PGO_ANDROID_DEVICE_ID"
+ scripts/pgo/pgo.ts android build-optimized --device "$PGO_ANDROID_DEVICE_ID" --profile "${{ steps.preparePgo.outputs.PROFILE_PATH }}"
+ for order in release-first optimized-first; do
+ batch="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${order}"
+ scripts/pgo/pgo.ts android benchmark-journey 10 30 --device "$PGO_ANDROID_DEVICE_ID" --fixture "$PGO_JOURNEY_FIXTURE" --benchmark-order "$order" --benchmark-batch "$batch"
+ scripts/pgo/pgo.ts android benchmark-heavy-journey 10 30 --device "$PGO_ANDROID_DEVICE_ID" --fixture "$PGO_JOURNEY_FIXTURE" --benchmark-order "$order" --benchmark-batch "$batch"
+ done
+ for manifest in .pgo/android/benchmarks/*/"${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"-*/run.json; do
+ jq '{platform, runs, order, scenario, sourceRevision, mobileRevision, artifactSHA256}' "$manifest" > "${manifest%.json}-proof.json"
+ done
+
+ - name: Upload Android PGO benchmark samples
+ if: ${{ inputs.benchmark-pgo }}
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
+ with:
+ name: ${{ inputs.artifact-prefix }}pgo-benchmarks-android
+ path: |
+ .pgo/android/benchmarks/*/${{ github.run_id }}-${{ github.run_attempt }}-*/*.csv
+ .pgo/android/benchmarks/*/${{ github.run_id }}-${{ github.run_attempt }}-*/run-proof.json
+ if-no-files-found: error
diff --git a/.github/workflows/buildIOS.yml b/.github/workflows/buildIOS.yml
index 7cdb09c2d8a1..6317d3880ae3 100644
--- a/.github/workflows/buildIOS.yml
+++ b/.github/workflows/buildIOS.yml
@@ -27,6 +27,14 @@ on:
description: Force a full native build, bypassing Rock remote cache
type: string
default: 'false'
+ run-pgo:
+ description: Train and use a native PGO profile for a Release build
+ type: boolean
+ default: false
+ benchmark-pgo:
+ description: Benchmark paired local builds after the PGO Release build
+ type: boolean
+ default: false
download-dsyms:
description: Download the prebuilt react-native dSYMs so Sentry can symbolicate its native frames. Off by default - it adds two large downloads per build.
type: boolean
@@ -52,7 +60,7 @@ on:
jobs:
build:
name: Build iOS HybridApp
- runs-on: blacksmith-12vcpu-macos-latest
+ runs-on: ${{ inputs.run-pgo && 'pgo-ios' || 'blacksmith-12vcpu-macos-latest' }}
env:
PULL_REQUEST_NUMBER: ${{ inputs.pull-request-number }}
RCT_SYMBOLICATE_PREBUILT_FRAMEWORKS: ${{ inputs.download-dsyms && '1' || '0' }}
@@ -63,6 +71,12 @@ jobs:
DSYM_FILENAME: ${{ steps.set-ipa-filename.outputs.DSYM_FILENAME }}
SOURCEMAP_FILENAME: main.jsbundle.map
steps:
+ - name: Validate PGO build inputs
+ if: ${{ (inputs.run-pgo && inputs.variant != 'Release') || (inputs.benchmark-pgo && !inputs.run-pgo) }}
+ run: |
+ echo '::error::PGO training requires a Release build, and benchmarking requires PGO training.'
+ exit 1
+
- name: Checkout
# Upstream checkout on macOS - Blacksmith's git-mirror cache can't work there (see workflows/README.md)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -118,6 +132,7 @@ jobs:
run: bundle install
- name: Resolve react-native artifact version
+ if: ${{ inputs.variant != 'Release' }}
id: resolve-rn-artifacts
env:
GITHUB_TOKEN: ${{ github.token }}
@@ -131,6 +146,7 @@ jobs:
echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT"
- name: Cache react-native artifacts
+ if: ${{ inputs.variant != 'Release' }}
# v5.0.1
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb
with:
@@ -138,6 +154,7 @@ jobs:
key: ${{ runner.os }}-rn-artifacts-react-hybrid-${{ steps.resolve-rn-artifacts.outputs.VERSION }}-dsyms-${{ inputs.download-dsyms }}
- name: Cache Pod dependencies
+ if: ${{ inputs.variant != 'Release' }}
# v5.0.1
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb
id: pods-cache
@@ -151,7 +168,7 @@ jobs:
- name: Install cocoapods
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4
- if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true' || steps.setup-node.outputs.cache-hit != 'true'
+ if: ${{ inputs.variant != 'Release' && (steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true' || steps.setup-node.outputs.cache-hit != 'true') }}
with:
timeout_minutes: 10
max_attempts: 5
@@ -241,6 +258,48 @@ jobs:
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
+ - name: Train iOS PGO on the signed-in physical device
+ if: ${{ inputs.run-pgo }}
+ env:
+ SENTRY_DISABLE_AUTO_UPLOAD: 'true'
+ SENTRY_DISABLE_NATIVE_DEBUG_UPLOAD: 'true'
+ run: |
+ set -euo pipefail
+ : "${PGO_IOS_DEVICE_ID:?Set PGO_IOS_DEVICE_ID on the device runner}"
+ : "${IOS_DEVELOPMENT_TEAM:?Install development signing and set IOS_DEVELOPMENT_TEAM on the runner}"
+ : "${PGO_JOURNEY_FIXTURE:?Set PGO_JOURNEY_FIXTURE to an owner-approved heavy-account fixture}"
+ test -s "$PGO_JOURNEY_FIXTURE"
+ command -v agent-device
+ test "$(agent-device --version)" = '0.20.6'
+ ./node_modules/.bin/bun scripts/pgo/pgo.ts ios build-instrumented --device "$PGO_IOS_DEVICE_ID"
+ ./node_modules/.bin/bun scripts/pgo/pgo.ts ios verify-instrumented --device "$PGO_IOS_DEVICE_ID"
+ ./node_modules/.bin/bun scripts/pgo/pgo.ts ios install-instrumented --device "$PGO_IOS_DEVICE_ID"
+ batch_id="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
+ ./node_modules/.bin/bun scripts/pgo/journey.ts ios --fixture "$PGO_JOURNEY_FIXTURE" --device "$PGO_IOS_DEVICE_ID" --preflight --batch-id "${batch_id}-preflight"
+ ./node_modules/.bin/bun scripts/pgo/journey.ts ios --fixture "$PGO_JOURNEY_FIXTURE" --device "$PGO_IOS_DEVICE_ID" --runs 3 --collect --batch-id "$batch_id"
+
+ - name: Validate iOS training profile
+ id: preparePgo
+ if: ${{ inputs.run-pgo }}
+ run: ./node_modules/.bin/bun scripts/pgo/ciProfile.ts prepare ios "${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
+
+ - name: Prepare source pods for the optimized archive
+ if: ${{ inputs.run-pgo }}
+ run: |
+ set -euo pipefail
+ profile="${GITHUB_WORKSPACE}/.pgo/ios/arm64/newdot.profdata"
+ {
+ echo 'BUILD_RN_FROM_SOURCE=1'
+ echo 'RCT_USE_RN_DEP=0'
+ echo 'RCT_USE_PREBUILT_RNCORE=0'
+ echo 'RCT_BUILD_HERMES_FROM_SOURCE=true'
+ echo 'EXPENSIFY_PGO_MODE=use'
+ echo "EXPENSIFY_PGO_PROFILE=$profile"
+ } >> "$GITHUB_ENV"
+ cd Mobile-Expensify/iOS
+ BUILD_RN_FROM_SOURCE=1 RCT_USE_RN_DEP=0 RCT_USE_PREBUILT_RNCORE=0 RCT_BUILD_HERMES_FROM_SOURCE=true EXPENSIFY_PGO_MODE=use EXPENSIFY_PGO_PROFILE="$profile" bundle exec pod install --silent
+ rm -rf Pods/hermes-engine/build
+
- name: Prepare provisioning profiles JSON
id: prepare-profiles
run: |
@@ -257,7 +316,8 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
IS_HYBRID_APP: true
- FORCE_NATIVE_BUILD: ${{ inputs.force-native-build == 'true' && github.run_id || '' }}
+ FORCE_NATIVE_BUILD: ${{ inputs.run-pgo && format('{0}-{1}', github.run_id, github.run_attempt) || inputs.force-native-build == 'true' && github.run_id || '' }}
+ PGO_PROFILE_SHA256: ${{ steps.preparePgo.outputs.PROFILE_SHA256 }}
with:
destination: device
re-sign: true
@@ -268,6 +328,7 @@ jobs:
provisioning-profiles: ${{ steps.prepare-profiles.outputs.PROFILES }}
comment-bot: false
custom-identifier: ${{ steps.computeIdentifier.outputs.IDENTIFIER }}
+ rock-build-extra-params: ${{ inputs.run-pgo && format('--local --extra-params "-xcconfig {0}/.pgo/ios/arm64/release.xcconfig"', github.workspace) || '' }}
- name: Set artifact URL output
id: set-artifact-url
@@ -287,6 +348,22 @@ jobs:
} >> "$GITHUB_OUTPUT"
fi
+ - name: Seal iOS PGO release proof
+ if: ${{ inputs.run-pgo }}
+ run: ./node_modules/.bin/bun scripts/pgo/ciProfile.ts seal ios "$(jq -r .version package.json)" ".rock/cache/ios/export/${{ steps.set-ipa-filename.outputs.IPA_FILENAME }}" pgo-proof-ios.json
+
+ - name: Verify sealed iOS PGO release proof
+ if: ${{ inputs.variant == 'Release' }}
+ run: ./node_modules/.bin/bun scripts/pgo/ciProfile.ts verify ios "$(jq -r .version package.json)" pgo-proof-ios.json ".rock/cache/ios/export/${{ steps.set-ipa-filename.outputs.IPA_FILENAME }}"
+
+ - name: Upload iOS PGO release proof
+ if: ${{ inputs.run-pgo }}
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
+ with:
+ name: ${{ inputs.artifact-prefix }}pgo-proof-ios
+ path: pgo-proof-ios.json
+ if-no-files-found: error
+
- name: Find and upload IPA artifact
# v6
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
@@ -332,3 +409,31 @@ jobs:
with:
name: ${{ inputs.artifact-prefix }}ios-sourcemap-artifact
path: Mobile-Expensify/main.jsbundle.map
+
+ - name: Benchmark paired iOS PGO builds
+ if: ${{ inputs.benchmark-pgo }}
+ env:
+ SENTRY_DISABLE_AUTO_UPLOAD: 'true'
+ SENTRY_DISABLE_NATIVE_DEBUG_UPLOAD: 'true'
+ run: |
+ set -euo pipefail
+ scripts/pgo/pgo.ts ios build-release --device "$PGO_IOS_DEVICE_ID"
+ scripts/pgo/pgo.ts ios build-optimized --device "$PGO_IOS_DEVICE_ID" --profile "${{ steps.preparePgo.outputs.PROFILE_PATH }}"
+ for order in release-first optimized-first; do
+ batch="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${order}"
+ scripts/pgo/pgo.ts ios benchmark-journey 10 30 --device "$PGO_IOS_DEVICE_ID" --fixture "$PGO_JOURNEY_FIXTURE" --benchmark-order "$order" --benchmark-batch "$batch"
+ scripts/pgo/pgo.ts ios benchmark-heavy-journey 10 30 --device "$PGO_IOS_DEVICE_ID" --fixture "$PGO_JOURNEY_FIXTURE" --benchmark-order "$order" --benchmark-batch "$batch"
+ done
+ for manifest in .pgo/ios/benchmarks/*/"${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"-*/run.json; do
+ jq '{platform, runs, order, scenario, sourceRevision, mobileRevision, artifactSHA256}' "$manifest" > "${manifest%.json}-proof.json"
+ done
+
+ - name: Upload iOS PGO benchmark samples
+ if: ${{ inputs.benchmark-pgo }}
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
+ with:
+ name: ${{ inputs.artifact-prefix }}pgo-benchmarks-ios
+ path: |
+ .pgo/ios/benchmarks/*/${{ github.run_id }}-${{ github.run_attempt }}-*/*.csv
+ .pgo/ios/benchmarks/*/${{ github.run_id }}-${{ github.run_attempt }}-*/run-proof.json
+ if-no-files-found: error
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 348d7ddb6fee..f07f56c8a338 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -206,6 +206,7 @@ jobs:
with:
ref: ${{ needs.prep.outputs.DEPLOY_SHA }}
variant: Release
+ run-pgo: true
secrets: inherit
androidUploadGooglePlay:
@@ -434,6 +435,7 @@ jobs:
with:
ref: ${{ needs.prep.outputs.DEPLOY_SHA }}
variant: Release
+ run-pgo: true
download-dsyms: true
secrets: inherit
diff --git a/.github/workflows/pgoPreview.yml b/.github/workflows/pgoPreview.yml
new file mode 100644
index 000000000000..2e78d1a75872
--- /dev/null
+++ b/.github/workflows/pgoPreview.yml
@@ -0,0 +1,42 @@
+name: Verify PGO release build and benchmarks
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+ branches: [main]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: pgo-preview-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ # The signed-in device and release credentials are available only to a reviewed branch in Expensify/App.
+ android:
+ name: Train, build, and benchmark Android PGO
+ if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == '@chrispader/perf/pgo-ci-preview' }}
+ cache-mode: read
+ uses: ./.github/workflows/buildAndroid.yml
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
+ variant: Release
+ run-pgo: true
+ benchmark-pgo: true
+ artifact-prefix: preview-
+ secrets: inherit
+
+ ios:
+ name: Train, build, and benchmark iOS PGO
+ if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == '@chrispader/perf/pgo-ci-preview' }}
+ cache-mode: read
+ uses: ./.github/workflows/buildIOS.yml
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
+ variant: Release
+ run-pgo: true
+ benchmark-pgo: true
+ artifact-prefix: preview-
+ download-dsyms: true
+ secrets: inherit
diff --git a/.gitignore b/.gitignore
index 8c6d0691fdb9..dda47ce6a069 100644
--- a/.gitignore
+++ b/.gitignore
@@ -136,6 +136,9 @@ config/certificatePinning/roots/
# Rspack build profiling output (RSPACK_PROFILE=OVERVIEW|ALL)
.rspack-profile-*/
+# Local native PGO profiles
+.pgo/
+
# Expo
.expo
dist/
diff --git a/Mobile-Expensify b/Mobile-Expensify
index 8b0d0a2977b0..7f8d598e6da7 160000
--- a/Mobile-Expensify
+++ b/Mobile-Expensify
@@ -1 +1 @@
-Subproject commit 8b0d0a2977b0b9d18eefacb9b6449dd5cfd57eca
+Subproject commit 7f8d598e6da7931f3c66b8bcdb394f429d76a259
diff --git a/android/app/build.gradle b/android/app/build.gradle
index dd25dd1c86f7..b1ef5ee77761 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -84,6 +84,16 @@ project.ext.envConfigFiles = [
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
*/
def enableProguardInReleaseBuilds = true
+def pgoMode = providers.gradleProperty('pgoMode').getOrElse('off')
+def pgoProfile = providers.gradleProperty('pgoProfile').orNull
+
+if (!(pgoMode in ['off', 'generate', 'use'])) {
+ throw new GradleException("Unsupported pgoMode '${pgoMode}'. Use off, generate, or use.")
+}
+
+if (pgoMode == 'use' && !pgoProfile) {
+ throw new GradleException('pgoProfile is required when pgoMode=use.')
+}
/**
* The preferred build flavor of JavaScriptCore (JSC)
@@ -119,6 +129,7 @@ android {
// Supported language variants must be declared here to avoid from being removed during the compilation.
// This also helps us to not include unnecessary language variants in the APK.
resConfigs "en", "es"
+ buildConfigField 'boolean', 'PGO_PROFILE_GENERATION', (pgoMode == 'generate').toString()
}
flavorDimensions "default"
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 1097f2dedffc..a46ad4244225 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -26,6 +26,14 @@
android:theme="@style/AppTheme"
tools:replace="android:supportsRtl">
+
+
+
+
+
+
#import
+#ifndef EXPENSIFY_PGO_GENERATE
+#define EXPENSIFY_PGO_GENERATE 0
+#endif
+
+#if EXPENSIFY_PGO_GENERATE
+#include
+
+extern "C" void __llvm_profile_set_filename(const char *FilenamePat);
+extern "C" int __llvm_profile_write_file(void);
+extern "C" void __llvm_profile_reset_counters(void);
+
+using PGOSetFilename = void (*)(const char *);
+using PGOWriteFile = int (*)();
+using PGOResetCounters = void (*)();
+
+template
+static Function HermesPGOFunction(const char *symbol) {
+ return reinterpret_cast(dlsym(RTLD_DEFAULT, symbol));
+}
+#endif
+
+static NSString *const PGO_DIRECTORY_NAME = @"ExpensifyPGO";
+static NSString *const PGO_STATUS_MARKER = @"profile-status.txt";
+static CFStringRef const PGO_WRITE_NOTIFICATION = CFSTR("com.expensify.pgo.write-profiles");
+static CFStringRef const PGO_CLEAR_NOTIFICATION = CFSTR("com.expensify.pgo.clear-profiles");
+
static RCTSurfaceHostingProxyRootView *_rootView = nil;
static UIView *_loadingView = nil;
@@ -20,6 +46,29 @@
static bool _fade = false;
static bool _nativeHidden = false;
+static long long CurrentTimeMilliseconds(void) {
+ return (long long)([[NSDate date] timeIntervalSince1970] * 1000);
+}
+
+@interface RCTBootSplash ()
++ (NSURL *)pgoDirectoryURL;
++ (void)writePGOStatus:(NSString *)status result:(int)result;
++ (void)writePGOProfiles;
++ (void)clearPGOProfiles;
+@end
+
+static void HandlePGONotification(__unused CFNotificationCenterRef center,
+ __unused void *observer,
+ CFStringRef name,
+ __unused const void *object,
+ __unused CFDictionaryRef userInfo) {
+ if (CFEqual(name, PGO_WRITE_NOTIFICATION)) {
+ [RCTBootSplash writePGOProfiles];
+ } else if (CFEqual(name, PGO_CLEAR_NOTIFICATION)) {
+ [RCTBootSplash clearPGOProfiles];
+ }
+}
+
@implementation RCTBootSplash
RCT_EXPORT_MODULE();
@@ -32,6 +81,81 @@ + (BOOL)requiresMainQueueSetup {
return NO;
}
++ (NSURL *)pgoDirectoryURL {
+ NSURL *cachesURL = [[[NSFileManager defaultManager] URLsForDirectory:NSCachesDirectory
+ inDomains:NSUserDomainMask] firstObject];
+ NSURL *directoryURL = [cachesURL URLByAppendingPathComponent:PGO_DIRECTORY_NAME isDirectory:YES];
+ [[NSFileManager defaultManager] createDirectoryAtURL:directoryURL
+ withIntermediateDirectories:YES
+ attributes:nil
+ error:nil];
+ return directoryURL;
+}
+
++ (void)writePGOStatus:(NSString *)status result:(int)result {
+ NSString *contents = [NSString stringWithFormat:@"%lld,%@,%d", CurrentTimeMilliseconds(), status, result];
+ NSURL *statusURL = [[self pgoDirectoryURL] URLByAppendingPathComponent:PGO_STATUS_MARKER];
+ [contents writeToURL:statusURL atomically:YES encoding:NSUTF8StringEncoding error:nil];
+}
+
++ (void)initializePGOProfileCollection {
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ CFNotificationCenterRef notificationCenter = CFNotificationCenterGetDarwinNotifyCenter();
+ CFNotificationCenterAddObserver(notificationCenter, NULL, HandlePGONotification, PGO_WRITE_NOTIFICATION, NULL, CFNotificationSuspensionBehaviorDeliverImmediately);
+ CFNotificationCenterAddObserver(notificationCenter, NULL, HandlePGONotification, PGO_CLEAR_NOTIFICATION, NULL, CFNotificationSuspensionBehaviorDeliverImmediately);
+
+#if EXPENSIFY_PGO_GENERATE
+ NSString *profilePattern = [[[self pgoDirectoryURL] URLByAppendingPathComponent:@"newdot-%m.profraw"] path];
+ __llvm_profile_set_filename(profilePattern.fileSystemRepresentation);
+ PGOSetFilename setHermesFilename = HermesPGOFunction("expensify_llvm_profile_set_filename");
+ if (setHermesFilename != nullptr) {
+ setHermesFilename(profilePattern.fileSystemRepresentation);
+ }
+#endif
+ });
+}
+
++ (void)writePGOProfiles {
+#if EXPENSIFY_PGO_GENERATE
+ int appResult = __llvm_profile_write_file();
+ PGOWriteFile writeHermesFile = HermesPGOFunction("expensify_llvm_profile_write_file");
+ int hermesResult = writeHermesFile != nullptr ? writeHermesFile() : 1;
+ int result = appResult == 0 && hermesResult == 0 ? 0 : 1;
+ [self writePGOStatus:@"written" result:result];
+ NSLog(@"ExpensifyPGO: wrote LLVM profile with result=%d appResult=%d hermesResult=%d", result, appResult, hermesResult);
+#else
+ [self writePGOStatus:@"not-instrumented" result:1];
+ NSLog(@"ExpensifyPGO: ignored profile write in a non-instrumented build");
+#endif
+}
+
++ (void)clearPGOProfiles {
+ NSFileManager *fileManager = [NSFileManager defaultManager];
+ NSURL *directoryURL = [self pgoDirectoryURL];
+ NSArray *files = [fileManager contentsOfDirectoryAtURL:directoryURL
+ includingPropertiesForKeys:nil
+ options:0
+ error:nil];
+ for (NSURL *fileURL in files) {
+ if ([fileURL.lastPathComponent hasSuffix:@".profraw"] || [fileURL.lastPathComponent isEqualToString:PGO_STATUS_MARKER]) {
+ [fileManager removeItemAtURL:fileURL error:nil];
+ }
+ }
+
+#if EXPENSIFY_PGO_GENERATE
+ __llvm_profile_reset_counters();
+ PGOResetCounters resetHermesCounters = HermesPGOFunction("expensify_llvm_profile_reset_counters");
+ int result = resetHermesCounters != nullptr ? 0 : 1;
+ if (resetHermesCounters != nullptr) {
+ resetHermesCounters();
+ }
+ [self writePGOStatus:@"cleared" result:result];
+#else
+ [self writePGOStatus:@"not-instrumented" result:1];
+#endif
+}
+
+ (bool)isLoadingViewVisible {
return _loadingView != nil && ![_loadingView isHidden];
}
diff --git a/modules/ExpensifyNitroUtils/android/CMakeLists.txt b/modules/ExpensifyNitroUtils/android/CMakeLists.txt
index 0f384e8dd874..ab05b09f3c0c 100644
--- a/modules/ExpensifyNitroUtils/android/CMakeLists.txt
+++ b/modules/ExpensifyNitroUtils/android/CMakeLists.txt
@@ -1,13 +1,42 @@
project(ExpensifyNitroUtils)
+# cspell:ignore STREQUAL libclang fprofile defsym
+
cmake_minimum_required(VERSION 3.9.0)
set(PACKAGE_NAME ExpensifyNitroUtils)
set(CMAKE_VERBOSE_MAKEFILE ON)
set(CMAKE_CXX_STANDARD 20)
+if(EXPENSIFY_PGO_MODE STREQUAL "generate")
+ execute_process(
+ COMMAND ${CMAKE_CXX_COMPILER} --print-resource-dir
+ OUTPUT_VARIABLE CLANG_RESOURCE_DIR
+ OUTPUT_STRIP_TRAILING_WHITESPACE
+ )
+ set(PGO_RUNTIME "${CLANG_RESOURCE_DIR}/lib/linux/libclang_rt.profile-aarch64-android.a")
+ if(NOT EXISTS "${PGO_RUNTIME}")
+ message(FATAL_ERROR "LLVM profiling runtime was not found at ${PGO_RUNTIME}")
+ endif()
+ add_compile_options(-fprofile-generate)
+ add_link_options(
+ -fprofile-generate
+ "-Wl,--whole-archive,${PGO_RUNTIME},--no-whole-archive"
+ -Wl,--undefined=__llvm_profile_set_filename
+ -Wl,--undefined=__llvm_profile_write_file
+ -Wl,--defsym=expensify_llvm_profile_set_filename=__llvm_profile_set_filename
+ -Wl,--defsym=expensify_llvm_profile_write_file=__llvm_profile_write_file
+ -Wl,--export-dynamic-symbol=expensify_llvm_profile_set_filename
+ -Wl,--export-dynamic-symbol=expensify_llvm_profile_write_file
+ )
+elseif(EXPENSIFY_PGO_MODE STREQUAL "use")
+ add_compile_options("-fprofile-use=${EXPENSIFY_PGO_PROFILE}")
+ add_link_options("-fprofile-use=${EXPENSIFY_PGO_PROFILE}")
+endif()
+
# Define C++ library and add all sources
add_library(${PACKAGE_NAME} SHARED
src/main/cpp/cpp-adapter.cpp
+ src/main/cpp/PgoProfileWriter.cpp
)
# Add Nitrogen specs
@@ -29,4 +58,4 @@ target_link_libraries(
${PACKAGE_NAME}
${LOG_LIB}
android
-)
\ No newline at end of file
+)
diff --git a/modules/ExpensifyNitroUtils/android/build.gradle b/modules/ExpensifyNitroUtils/android/build.gradle
index cef4d21f1e8f..692d58148fa6 100644
--- a/modules/ExpensifyNitroUtils/android/build.gradle
+++ b/modules/ExpensifyNitroUtils/android/build.gradle
@@ -34,6 +34,17 @@ def getExtOrIntegerDefault(name) {
return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["ExpensifyNitroUtils_" + name]).toInteger()
}
+def pgoMode = rootProject.findProperty('pgoMode') ?: 'off'
+def pgoProfile = rootProject.findProperty('pgoProfile')
+
+if (!(pgoMode in ['off', 'generate', 'use'])) {
+ throw new GradleException("Unsupported pgoMode '${pgoMode}'. Use off, generate, or use.")
+}
+
+if (pgoMode == 'use' && !pgoProfile) {
+ throw new GradleException('pgoProfile is required when pgoMode=use.')
+}
+
android {
namespace "com.margelo.nitro.utils"
ndkVersion getExtOrDefault("ndkVersion")
@@ -47,7 +58,10 @@ android {
externalNativeBuild {
cmake {
cppFlags "-O2 -frtti -fexceptions -Wall -fstack-protector-all"
- arguments "-DANDROID_STL=c++_shared"
+ arguments "-DANDROID_STL=c++_shared", "-DEXPENSIFY_PGO_MODE=${pgoMode}"
+ if (pgoMode == 'use') {
+ arguments "-DEXPENSIFY_PGO_PROFILE=${file(pgoProfile).absolutePath}"
+ }
abiFilters (*reactNativeArchitectures())
}
}
@@ -126,4 +140,3 @@ dependencies {
// Add a dependency on NitroModules
implementation project(":react-native-nitro-modules")
}
-
diff --git a/modules/ExpensifyNitroUtils/android/src/main/cpp/PgoProfileWriter.cpp b/modules/ExpensifyNitroUtils/android/src/main/cpp/PgoProfileWriter.cpp
new file mode 100644
index 000000000000..492d68e1f09e
--- /dev/null
+++ b/modules/ExpensifyNitroUtils/android/src/main/cpp/PgoProfileWriter.cpp
@@ -0,0 +1,63 @@
+#include
+#include
+
+#include
+#include
+
+namespace {
+using SetFilename = void (*)(const char *);
+using WriteFile = int (*)();
+
+constexpr std::array kInstrumentedLibraries = {
+ "libhermesvm.so",
+ "libreactnative.so",
+ "libjsi.so",
+ "libExpensifyNitroUtils.so",
+};
+
+bool writeProfile(void *handle, const std::string &profilePath) {
+ const auto setFilename = reinterpret_cast(dlsym(handle, "expensify_llvm_profile_set_filename"));
+ const auto writeFile = reinterpret_cast(dlsym(handle, "expensify_llvm_profile_write_file"));
+ if (setFilename == nullptr || writeFile == nullptr) {
+ return false;
+ }
+
+ setFilename(profilePath.c_str());
+ return writeFile() == 0;
+}
+
+jint writeProfiles(JNIEnv *env, jstring directory) {
+ const char *directoryChars = env->GetStringUTFChars(directory, nullptr);
+ if (directoryChars == nullptr) {
+ return 0;
+ }
+
+ const std::string profilePath = std::string(directoryChars) + "/newdot-%m.profraw";
+ env->ReleaseStringUTFChars(directory, directoryChars);
+
+ jint writtenProfiles = 0;
+ for (const char *library : kInstrumentedLibraries) {
+ void *handle = dlopen(library, RTLD_NOW | RTLD_NOLOAD);
+ if (handle == nullptr) {
+ continue;
+ }
+
+ if (writeProfile(handle, profilePath)) {
+ ++writtenProfiles;
+ }
+ dlclose(handle);
+ }
+
+ return writtenProfiles;
+}
+} // namespace
+
+extern "C" JNIEXPORT jint JNICALL
+Java_org_me_mobiexpensifyg_PgoProfileWriter_writeProfiles(JNIEnv *env, jclass, jstring directory) {
+ return writeProfiles(env, directory);
+}
+
+extern "C" JNIEXPORT jint JNICALL
+Java_com_expensify_chat_PgoProfileWriter_writeProfiles(JNIEnv *env, jclass, jstring directory) {
+ return writeProfiles(env, directory);
+}
diff --git a/package.json b/package.json
index ecd0e55419e2..b3f8940e90a5 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
"build-staging": "ENV=staging bun ./node_modules/.bin/rsbuild build --config config/rsbuild/rsbuild.config.ts && bun ./scripts/combine-web-sourcemaps.ts",
"build-adhoc": "ENV=adhoc bun ./node_modules/.bin/rsbuild build --config config/rsbuild/rsbuild.config.ts && bun ./scripts/combine-web-sourcemaps.ts",
"benchmark-app-startup": "bun scripts/benchmarkAppStartup.ts",
+ "pgo:journey": "bun scripts/pgo/journey.ts",
"bootstrap-device": "bun scripts/bootstrapForDevice.ts",
"createDocsRoutes": "bun .github/scripts/createDocsRoutes.ts",
"generateAllowedUrls": "bun .github/scripts/generateAllowedUrls.ts",
diff --git a/patches/react-native/details.md b/patches/react-native/details.md
index 0d83081b6d09..2b18be31ad06 100644
--- a/patches/react-native/details.md
+++ b/patches/react-native/details.md
@@ -1,5 +1,12 @@
# `react-native` patches
+### [react-native+0.86.0+044+add-local-pgo-build-switch.patch](react-native+0.86.0+044+add-local-pgo-build-switch.patch)
+
+- Reason: Adds an opt-in local LLVM PGO mode to the React Native Android runtime and Hermes source builds. The mode is disabled by default and is used only by the local PGO tooling.
+- Upstream PR/issue: 🛑
+- E/App issue: 🛑
+- PR Introducing Patch: 🛑
+
### [react-native+0.86.0+001+initial.patch](react-native+0.86.0+001+initial.patch)
- Reason: Fixes keyboard flickering issue when opening/closing modals. When an input is blurred and a modal is opened, the `rootView` becomes the `firstResponder`, causing the system to retain an incorrect keyboard state. This leads to keyboard flickering when the modal is closed. The patch adds code to resign the `rootView`'s `firstResponder` status before presenting the modal to prevent this issue.
diff --git a/patches/react-native/react-native+0.86.0+044+add-local-pgo-build-switch.patch b/patches/react-native/react-native+0.86.0+044+add-local-pgo-build-switch.patch
new file mode 100644
index 000000000000..d7e9802f6fd1
--- /dev/null
+++ b/patches/react-native/react-native+0.86.0+044+add-local-pgo-build-switch.patch
@@ -0,0 +1,184 @@
+diff --git a/node_modules/react-native/ReactAndroid/build.gradle.kts b/node_modules/react-native/ReactAndroid/build.gradle.kts
+index bae034d..94d11e1 100644
+--- a/node_modules/react-native/ReactAndroid/build.gradle.kts
++++ b/node_modules/react-native/ReactAndroid/build.gradle.kts
+@@ -42,6 +42,16 @@ val reactNativeRootDir = projectDir.parent
+ val hermesV1Enabled =
+ rootProject.extensions.getByType(PrivateReactExtension::class.java).hermesV1Enabled.get()
+
++val pgoMode = project.findProperty("pgoMode")?.toString() ?: "off"
++val pgoProfile = project.findProperty("pgoProfile")?.toString()
++
++check(pgoMode in setOf("off", "generate", "use")) {
++ "Unsupported pgoMode '$pgoMode'. Use off, generate, or use."
++}
++if (pgoMode == "use") {
++ check(!pgoProfile.isNullOrBlank()) { "pgoProfile is required when pgoMode=use." }
++}
++
+ // We put the publishing version from gradle.properties inside ext. so other
+ // subprojects can access it as well.
+ extra["publishing_version"] = project.findProperty("VERSION_NAME")?.toString()!!
+@@ -579,8 +589,13 @@ android {
+ "-DANDROID_TOOLCHAIN=clang",
+ "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON",
+ "-DCMAKE_POLICY_DEFAULT_CMP0069=NEW",
++ "-DPGO_MODE=$pgoMode",
+ )
+
++ if (pgoMode == "use") {
++ arguments("-DPGO_PROFILE=$pgoProfile")
++ }
++
+ if (hermesV1Enabled) {
+ arguments("-DHERMES_V1_ENABLED=1")
+ }
+diff --git a/node_modules/react-native/ReactAndroid/hermes-engine/build.gradle.kts b/node_modules/react-native/ReactAndroid/hermes-engine/build.gradle.kts
+index 6610782..853b937 100644
+--- a/node_modules/react-native/ReactAndroid/hermes-engine/build.gradle.kts
++++ b/node_modules/react-native/ReactAndroid/hermes-engine/build.gradle.kts
+@@ -52,6 +52,18 @@ fun getSDKManagerPath(): String {
+
+ val hermesV1Enabled =
+ rootProject.extensions.getByType(PrivateReactExtension::class.java).hermesV1Enabled.get()
++
++val pgoMode = project.findProperty("pgoMode")?.toString() ?: "off"
++val pgoProfile = project.findProperty("pgoProfile")?.toString()
++val pgoCmakePath = File(projectDir, "pgo.cmake")
++
++check(pgoMode in setOf("off", "generate", "use")) {
++ "Unsupported pgoMode '$pgoMode'. Use off, generate, or use."
++}
++if (pgoMode == "use") {
++ check(!pgoProfile.isNullOrBlank()) { "pgoProfile is required when pgoMode=use." }
++}
++
+ val reactNativeRootDir = project(":packages:react-native:ReactAndroid").projectDir.parent
+ val customDownloadDir = System.getenv("REACT_NATIVE_DOWNLOADS_DIR")
+ val downloadsDir =
+@@ -415,8 +427,14 @@ android {
+ // We intentionally build Hermes with Intl support only. This is to simplify
+ // the build setup and to avoid overcomplicating the build-type matrix.
+ "-DHERMES_ENABLE_INTL=True",
++ "-DPGO_MODE=$pgoMode",
++ "-DCMAKE_PROJECT_Hermes_INCLUDE=${pgoCmakePath.absolutePath}",
+ )
+
++ if (pgoMode == "use") {
++ arguments("-DPGO_PROFILE=$pgoProfile")
++ }
++
+ if (hermesV1Enabled) {
+ arguments("-DHERMESVM_HEAP_HV_MODE=HEAP_HV_PREFER32")
+ }
+diff --git a/node_modules/react-native/ReactAndroid/hermes-engine/pgo.cmake b/node_modules/react-native/ReactAndroid/hermes-engine/pgo.cmake
+new file mode 100644
+index 0000000..bcc335d
+--- /dev/null
++++ b/node_modules/react-native/ReactAndroid/hermes-engine/pgo.cmake
+@@ -0,0 +1,25 @@
++if(PGO_MODE STREQUAL "generate")
++ execute_process(
++ COMMAND ${CMAKE_CXX_COMPILER} --print-resource-dir
++ OUTPUT_VARIABLE CLANG_RESOURCE_DIR
++ OUTPUT_STRIP_TRAILING_WHITESPACE
++ )
++ set(PGO_RUNTIME "${CLANG_RESOURCE_DIR}/lib/linux/libclang_rt.profile-aarch64-android.a")
++ if(NOT EXISTS "${PGO_RUNTIME}")
++ message(FATAL_ERROR "LLVM profiling runtime was not found at ${PGO_RUNTIME}")
++ endif()
++ add_compile_options(-fprofile-generate)
++ add_link_options(
++ -fprofile-generate
++ "-Wl,--whole-archive,${PGO_RUNTIME},--no-whole-archive"
++ -Wl,--undefined=__llvm_profile_set_filename
++ -Wl,--undefined=__llvm_profile_write_file
++ -Wl,--defsym=expensify_llvm_profile_set_filename=__llvm_profile_set_filename
++ -Wl,--defsym=expensify_llvm_profile_write_file=__llvm_profile_write_file
++ -Wl,--export-dynamic-symbol=expensify_llvm_profile_set_filename
++ -Wl,--export-dynamic-symbol=expensify_llvm_profile_write_file
++ )
++elseif(PGO_MODE STREQUAL "use")
++ add_compile_options("-fprofile-use=${PGO_PROFILE}")
++ add_link_options("-fprofile-use=${PGO_PROFILE}")
++endif()
+diff --git a/node_modules/react-native/ReactAndroid/src/main/jni/CMakeLists.txt b/node_modules/react-native/ReactAndroid/src/main/jni/CMakeLists.txt
+index 8125e02..d466798 100644
+--- a/node_modules/react-native/ReactAndroid/src/main/jni/CMakeLists.txt
++++ b/node_modules/react-native/ReactAndroid/src/main/jni/CMakeLists.txt
+@@ -8,6 +8,32 @@ set(CMAKE_VERBOSE_MAKEFILE on)
+
+ project(ReactAndroid)
+
++if(PGO_MODE STREQUAL "generate")
++ execute_process(
++ COMMAND ${CMAKE_CXX_COMPILER} --print-resource-dir
++ OUTPUT_VARIABLE CLANG_RESOURCE_DIR
++ OUTPUT_STRIP_TRAILING_WHITESPACE
++ )
++ set(PGO_RUNTIME "${CLANG_RESOURCE_DIR}/lib/linux/libclang_rt.profile-aarch64-android.a")
++ if(NOT EXISTS "${PGO_RUNTIME}")
++ message(FATAL_ERROR "LLVM profiling runtime was not found at ${PGO_RUNTIME}")
++ endif()
++ add_compile_options(-fprofile-generate)
++ add_link_options(
++ -fprofile-generate
++ "-Wl,--whole-archive,${PGO_RUNTIME},--no-whole-archive"
++ -Wl,--undefined=__llvm_profile_set_filename
++ -Wl,--undefined=__llvm_profile_write_file
++ -Wl,--defsym=expensify_llvm_profile_set_filename=__llvm_profile_set_filename
++ -Wl,--defsym=expensify_llvm_profile_write_file=__llvm_profile_write_file
++ -Wl,--export-dynamic-symbol=expensify_llvm_profile_set_filename
++ -Wl,--export-dynamic-symbol=expensify_llvm_profile_write_file
++ )
++elseif(PGO_MODE STREQUAL "use")
++ add_compile_options("-fprofile-use=${PGO_PROFILE}")
++ add_link_options("-fprofile-use=${PGO_PROFILE}")
++endif()
++
+ include(CheckIPOSupported)
+ check_ipo_supported(RESULT IPO_SUPPORT)
+ if (IPO_SUPPORT)
+diff --git a/node_modules/react-native/sdks/hermes-engine/utils/build-hermes-xcode.sh b/node_modules/react-native/sdks/hermes-engine/utils/build-hermes-xcode.sh
+--- a/node_modules/react-native/sdks/hermes-engine/utils/build-hermes-xcode.sh
++++ b/node_modules/react-native/sdks/hermes-engine/utils/build-hermes-xcode.sh
+@@ -59,6 +59,19 @@ if [[ $xcode_major_version -ge 15 ]]; then
+ xcode_15_flags="LINKER:-ld_classic"
+ fi
+
++pgo_compile_flags=""
++pgo_linker_flags=""
++if [[ "${EXPENSIFY_PGO_MODE:-off}" == "generate" ]]; then
++ pgo_compile_flags="-fprofile-generate"
++ pgo_linker_flags="-fprofile-generate;LINKER:-alias,___llvm_profile_set_filename,_expensify_llvm_profile_set_filename;LINKER:-alias,___llvm_profile_write_file,_expensify_llvm_profile_write_file;LINKER:-alias,___llvm_profile_reset_counters,_expensify_llvm_profile_reset_counters"
++elif [[ "${EXPENSIFY_PGO_MODE:-off}" == "use" ]]; then
++ if [[ -z "${EXPENSIFY_PGO_PROFILE:-}" ]]; then
++ echo "EXPENSIFY_PGO_PROFILE is required when EXPENSIFY_PGO_MODE=use" >&2
++ exit 1
++ fi
++ pgo_compile_flags="-fprofile-use=${EXPENSIFY_PGO_PROFILE}"
++fi
++
+ boost_context_flag=""
+ if [[ $PLATFORM_NAME == "catalyst" ]]; then
+ boost_context_flag="-DHERMES_ALLOW_BOOST_CONTEXT=0"
+@@ -70,7 +84,7 @@ echo "Configure Apple framework"
+ "$CMAKE_BINARY" \
+ -S "${PODS_ROOT}/hermes-engine" \
+ -B "${PODS_ROOT}/hermes-engine/build/${PLATFORM_NAME}" \
+- -DHERMES_EXTRA_LINKER_FLAGS="$xcode_15_flags" \
++ -DHERMES_EXTRA_LINKER_FLAGS="$xcode_15_flags;$pgo_linker_flags" \
+ -DHERMES_APPLE_TARGET_PLATFORM:STRING="$PLATFORM_NAME" \
+ -DCMAKE_OSX_ARCHITECTURES:STRING="$architectures" \
+ -DCMAKE_OSX_DEPLOYMENT_TARGET:STRING="$deployment_target" \
+@@ -82,8 +96,8 @@ echo "Configure Apple framework"
+ -DHERMES_ENABLE_BITCODE:BOOLEAN=false \
+ -DHERMES_BUILD_APPLE_FRAMEWORK:BOOLEAN=true \
+ -DHERMES_BUILD_SHARED_JSI:BOOLEAN=false \
+- -DCMAKE_CXX_FLAGS:STRING="-gdwarf" \
+- -DCMAKE_C_FLAGS:STRING="-gdwarf" \
++ -DCMAKE_CXX_FLAGS:STRING="-gdwarf $pgo_compile_flags" \
++ -DCMAKE_C_FLAGS:STRING="-gdwarf $pgo_compile_flags" \
+ -DIMPORT_HOST_COMPILERS:PATH="${hermesc_path}" \
+ -DJSI_DIR="$jsi_path" \
+ -DHERMES_RELEASE_VERSION="for RN $release_version" \
diff --git a/rock.config.mjs b/rock.config.mjs
index b6f00dbe3970..a0e8aa107503 100644
--- a/rock.config.mjs
+++ b/rock.config.mjs
@@ -40,7 +40,18 @@ export default {
...(isHybrid ? ['Mobile-Expensify/patches'] : []),
'.github/actions/composite/getXcodeVersion/action.yml',
],
- env: ['BUNDLER', 'USE_WEB_PROXY', 'PUSHER_DEV_SUFFIX', 'SECURE_NGROK_URL', 'NGROK_URL', 'USE_NGROK', 'FORCE_NATIVE_BUILD', 'RCT_SYMBOLICATE_PREBUILT_FRAMEWORKS'],
+ env: [
+ 'BUNDLER',
+ 'USE_WEB_PROXY',
+ 'PUSHER_DEV_SUFFIX',
+ 'SECURE_NGROK_URL',
+ 'NGROK_URL',
+ 'USE_NGROK',
+ 'FORCE_NATIVE_BUILD',
+ 'RCT_SYMBOLICATE_PREBUILT_FRAMEWORKS',
+ 'EXPENSIFY_PGO_MODE',
+ 'PGO_PROFILE_SHA256',
+ ],
ignorePaths: ['Mobile-Expensify/Android/assets/app/shared/bundle.js'],
},
// Forces React Native to build from source to include our custom patches
diff --git a/scripts/lib/nativeAppBenchmark.ts b/scripts/lib/nativeAppBenchmark.ts
index bce54b015663..434fc2c038fe 100644
--- a/scripts/lib/nativeAppBenchmark.ts
+++ b/scripts/lib/nativeAppBenchmark.ts
@@ -6,6 +6,7 @@ import {assertAndroidAppInstalled, createAndroidAdapter, parseAndroidProcessIden
import {
createIOSAdapter,
iOSBenchmarkMarkerPath,
+ iOSProcessListArguments,
parseIOSInstalledAppURL,
parseIOSInstalledAppsResponse,
parseIOSLaunchProcessIdentifier,
@@ -27,6 +28,7 @@ export {
createNativeAppBenchmarkAdapter,
findBenchmarkDuration,
iOSBenchmarkMarkerPath,
+ iOSProcessListArguments,
latestBenchmarkEvents,
parseAndroidProcessIdentifier,
parseBenchmarkLogEvents,
diff --git a/scripts/lib/nativeAppBenchmark/ios.ts b/scripts/lib/nativeAppBenchmark/ios.ts
index fa82833d1719..d5a82d60b543 100644
--- a/scripts/lib/nativeAppBenchmark/ios.ts
+++ b/scripts/lib/nativeAppBenchmark/ios.ts
@@ -1,4 +1,4 @@
-// cspell:ignore BEGINSWITH devicectl
+// cspell:ignore devicectl
/** Implements iOS benchmark setup, launch, process control, and marker collection through CoreDevice. */
@@ -67,20 +67,7 @@ async function createIOSAdapter({rootDirectory, deviceIdentifier, appID}: Omit 0 ? processIdentifier : undefined;
}
+/** Lists every process so client-side matching works across CoreDevice versions with different filter field names. */
+function iOSProcessListArguments(deviceIdentifier: string, outputPath: string): string[] {
+ return ['devicectl', 'device', 'info', 'processes', '--device', deviceIdentifier, '--json-output', outputPath, '--quiet'];
+}
+
/** Maps a span name to its encoded marker path inside the iOS app data container. */
function iOSBenchmarkMarkerPath(spanName: string): string {
return `${IOS_BENCHMARK_DIRECTORY}/${encodeURIComponent(spanName)}.log`;
@@ -283,5 +275,13 @@ function isCoreDeviceInstalledApp(value: JsonValue): value is CoreDeviceInstalle
return isJSONObject(value) && typeof value.bundleIdentifier === 'string' && typeof value.url === 'string';
}
-export {createIOSAdapter, iOSBenchmarkMarkerPath, parseIOSInstalledAppURL, parseIOSInstalledAppsResponse, parseIOSLaunchProcessIdentifier, parseIOSRunningAppProcessIdentifier};
+export {
+ createIOSAdapter,
+ iOSBenchmarkMarkerPath,
+ iOSProcessListArguments,
+ parseIOSInstalledAppURL,
+ parseIOSInstalledAppsResponse,
+ parseIOSLaunchProcessIdentifier,
+ parseIOSRunningAppProcessIdentifier,
+};
export type {CoreDeviceInstalledAppsResponse};
diff --git a/scripts/lib/scriptUtils.ts b/scripts/lib/scriptUtils.ts
new file mode 100644
index 000000000000..9a665b430b86
--- /dev/null
+++ b/scripts/lib/scriptUtils.ts
@@ -0,0 +1,104 @@
+/** Reusable helpers for repository tooling scripts. */
+
+import {spawnSync} from 'node:child_process';
+import {existsSync, readdirSync} from 'node:fs';
+import {join, resolve} from 'node:path';
+import process from 'node:process';
+
+const rootDirectory = resolve(import.meta.dirname, '../..');
+
+function fail(message: string): never {
+ throw new Error(message);
+}
+
+function run(command: string, args: string[], cwd = rootDirectory): void {
+ const result = spawnSync(command, args, {cwd, stdio: 'inherit'});
+ if (result.error) {
+ fail(`Failed to run ${command}: ${result.error.message}`);
+ }
+ if (result.status !== 0) {
+ fail(`${command} exited with status ${result.status ?? 'unknown'}.`);
+ }
+}
+
+function capture(command: string, args: string[], cwd = rootDirectory): string {
+ const result = spawnSync(command, args, {cwd, encoding: 'utf8', maxBuffer: 100 * 1024 * 1024});
+ if (result.error) {
+ fail(`Failed to run ${command}: ${result.error.message}`);
+ }
+ if (result.status !== 0) {
+ const stderr = result.stderr.trim();
+ fail(stderr.length > 0 ? stderr : `${command} exited with status ${result.status ?? 'unknown'}.`);
+ }
+ return result.stdout;
+}
+
+function captureBinary(command: string, args: string[], cwd = rootDirectory): Buffer {
+ const result = spawnSync(command, args, {cwd, encoding: 'buffer', maxBuffer: 100 * 1024 * 1024});
+ if (result.error) {
+ fail(`Failed to run ${command}: ${result.error.message}`);
+ }
+ if (result.status !== 0) {
+ fail(`${command} exited with status ${result.status ?? 'unknown'}.`);
+ }
+ return result.stdout;
+}
+
+function runAllowFailure(command: string, args: string[], cwd = rootDirectory): boolean {
+ const result = spawnSync(command, args, {cwd, stdio: 'ignore'});
+ return !result.error && result.status === 0;
+}
+
+function sleep(milliseconds: number): Promise {
+ return new Promise((resolvePromise) => {
+ setTimeout(resolvePromise, milliseconds);
+ });
+}
+
+function findFiles(directory: string, extension: string): string[] {
+ if (!existsSync(directory)) {
+ return [];
+ }
+
+ return readdirSync(directory, {withFileTypes: true}).flatMap((entry) => {
+ const path = join(directory, entry.name);
+ if (entry.isDirectory()) {
+ return findFiles(path, extension);
+ }
+ return entry.isFile() && entry.name.endsWith(extension) ? [path] : [];
+ });
+}
+
+function requirePositiveInteger(value: number, label: string): number {
+ if (!Number.isSafeInteger(value) || value <= 0) {
+ fail(`${label} must be a positive integer, received: ${value}`);
+ }
+ return value;
+}
+
+function parsePositiveInteger(rawValue: string, label: string): number {
+ return requirePositiveInteger(Number(rawValue), label);
+}
+
+function parseChoice(rawValue: string, choices: readonly T[], label: string): T {
+ const choice = choices.find((candidate) => candidate === rawValue);
+ if (!choice) {
+ fail(`${label} must be one of: ${choices.join(', ')}. Received: ${rawValue}`);
+ }
+ return choice;
+}
+
+function environmentString(name: string): string | undefined {
+ const value: unknown = process.env[name];
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
+}
+
+function valueAt(values: readonly T[], index: number): T {
+ const value = values.at(index);
+ if (value === undefined) {
+ fail(`Expected a value at index ${index}.`);
+ }
+ return value;
+}
+
+export {capture, captureBinary, environmentString, fail, findFiles, parseChoice, parsePositiveInteger, requirePositiveInteger, rootDirectory, run, runAllowFailure, sleep, valueAt};
diff --git a/scripts/pgo/JOURNEY.md b/scripts/pgo/JOURNEY.md
new file mode 100644
index 000000000000..46a4bf16bcb6
--- /dev/null
+++ b/scripts/pgo/JOURNEY.md
@@ -0,0 +1,103 @@
+# Automated native PGO journey
+
+
+
+`journey.ts` drives the installed app through its accessibility tree using `agent-device` (developed with version 0.20.6). It runs without an LLM. The same TypeScript workload accepts an Android serial or iOS UDID. It uses labels and test IDs, and sizes scroll gestures from the device's reported bounds. The app must use English and portrait orientation. The full journey, including message sending and native profile export, has been validated on a physical Samsung phone and iPhone 13 Pro.
+
+## Account and device setup
+
+Use a dedicated, owner-approved heavy account with a populated Inbox, a long chat history, and enough expenses and expense reports to scroll. Keep the account and its data between runs. Installing over the existing app preserves authentication; uninstalling or clearing app data does not.
+
+The runner never enters credentials, retrieves a magic code, or switches accounts. It checks the account email in Account and exits with `SIGN_IN_REQUIRED` (exit code 2) if sign-in is needed. Stop and ask Chris to sign in manually before continuing. A CI job must surface this failure for manual intervention instead of substituting another account.
+
+Before every run, it opens Inbox to allow the app's automatic large-account preference update, then opens Account → Preferences and ensures Priority mode is **Most recent**. If it is **#focus**, the runner changes it and verifies the result. It checks the preference again at the end and rejects the run if #focus became enabled.
+
+Install `agent-device` on the runner and pin its version. Android needs USB debugging and a connected, authorized device. The runner temporarily enables the tool's test keyboard for reliable text replacement and restores the previous keyboard when its session closes. iOS requires macOS, Xcode, a trusted device with Developer Mode enabled, and signing for the tool's XCTest runner. The existing iOS profile collector supports physical devices; simulator profiles are not inputs to a device build.
+
+Create an ignored fixture, for example `.pgo/journey-fixture.json`:
+
+```json
+{
+ "accountEmail": "heavy@example.com",
+ "accountClass": "heavy",
+ "description": "Owner-approved high-traffic test account with populated chats, expenses, and reports.",
+ "report": {
+ "query": "busy",
+ "resultLabelPrefix": "#busy, Test workspace",
+ "title": "#busy"
+ },
+ "personalChat": {
+ "query": "heavy@example.com",
+ "resultLabelPrefix": "Test User (you), heavy@example.com",
+ "title": "Test User (you)"
+ },
+ "allowMessages": false,
+ "scrolls": 8,
+ "tabCycles": 3
+}
+```
+
+Choose the report name and workspace from the actual account. Search matching permits a changing message preview after the configured identity, and rejects ambiguous results. The personal-chat query must be the exact approved account email. That result can show either the email or the latest message beneath its `(you)` title, so both forms are supported for repeated runs. Confirm the personal chat with Chris before setting `allowMessages` to `true`. Messages are restricted to that account's `(you)` chat; each run sends two uniquely marked test messages and leaves them in place. An existing composer draft causes a failure.
+
+## Validate and record
+
+First check the account and #focus setting without sending messages:
+
+```bash
+scripts/pgo/journey.ts android --device DEVICE_SERIAL --app-id APP_ID --fixture .pgo/journey-fixture.json --preflight
+```
+
+Use `--navigation-only` instead of `--preflight` to exercise all navigation and scrolling without sending messages. This mode cannot collect a training profile. Omit both flags for the complete journey once message permission is recorded in the fixture.
+
+The complete journey relaunches the app, scrolls Inbox in both directions, opens and scrolls the selected report, composes two messages in the personal chat, scrolls Spend expenses and reports, switches between Inbox, Spend, and Workspaces three times, and reopens the report. Android also edits each draft before sending; iOS enters the final text once because replacing non-empty text in its composer was unreliable during live validation. The scroll count is a maximum per direction: two unchanged snapshots stop that direction at the list boundary. Scrolling must expose changing content; an empty or stationary list fails the run.
+
+After installing and verifying an instrumented build with the existing PGO commands, collect three independent repetitions:
+
+```bash
+scripts/pgo/journey.ts android --device DEVICE_SERIAL --app-id APP_ID --fixture .pgo/journey-fixture.json --runs 3 --collect
+scripts/pgo/journey.ts ios --device DEVICE_UDID --app-id BUNDLE_ID --fixture .pgo/journey-fixture.json --runs 3 --collect
+```
+
+Start with three repetitions per platform. Use the recorded duration and resulting native counter coverage to adjust the workload; extra repetitions of the same data do not add new paths. Most activity here exercises Hermes, Fabric mounting, layout, text, native input, and scrolling. This trains the compiled native implementation used by JavaScript; it does not compile the application's JavaScript with LLVM. Avoid spending most of the run waiting on network responses or looping over trivial settings screens.
+
+On September 23, 2026, the Android navigation-only run completed in 6 minutes 7 seconds. Two complete instrumented recordings passed in 7 minutes 1 second and 6 minutes 55 seconds, excluding preflight. Each sent two messages and exported four raw profiles; both recordings merged successfully, separately and together. These timings include automation overhead and the final account/preference check; they are not app performance measurements. The first recording's four profiles were also checked individually for nonzero native counters.
+
+On the same day, a complete instrumented iOS validation recording passed on a connected iPhone 13 Pro in 6 minutes 35 seconds, excluding preflight. A subsequent three-repetition training batch passed in 6 minutes 41 seconds, 6 minutes 35 seconds, and 6 minutes 37 seconds per journey. The batch exported six raw profiles and produced a 15 MB merged profile containing 77,590 functions. Every repetition sent two approved messages to the account's personal chat, then confirmed the heavy account and #focus off. These timings include automation overhead; they are not app performance measurements.
+
+The collector checks that the installed app can flush profiles, discards setup output, clears previous device profiles, and starts a fresh process for each repetition. It flushes once after a successful journey and verification, then archives that run's raw files. It merges only after every requested repetition succeeds. Failed batches produce no final merged profile. A failed send is never retried automatically.
+
+Each batch lives under `.pgo//journeys//` and includes `result.json`, per-run raw profiles, `journey.profdata`, and a function summary. The result records the fixture, app and device identifiers, both repository revisions, automation version, completed runs, and durations. These artifacts contain account identifiers and must stay in restricted CI storage.
+
+The journey profile is kept separate from the startup profile. The existing optimized-build command reads `newdot.profdata` from its platform directory: CI must explicitly select or weight the successful scenario profiles, write that final input, and preserve the iOS `.format` marker. Do not silently replace the startup input with an incomplete navigation run.
+
+## Release pipeline integration
+
+Use an exclusive device lease on a persistent runner: Linux or macOS with an arm64 Android device, and macOS with an arm64 iPhone. Build the instrumented artifact from the release candidate, install it over the signed-in app, run preflight and the full collection, then build the optimized artifact from the same source and toolchain. Gate publication on a separate performance and correctness check. The instrumented artifact is a CI input, not the store upload.
+
+Run these steps for each staging or production release candidate before publication. Pin the compiler, SDK, dependencies, architecture, build settings, and automation version; retain them alongside the profile. The current runner records source revisions but does not validate binary provenance. The release pipeline must enforce that the training artifact and optimized build match, and keep simultaneous jobs from sharing a device or profile output directory. Store signing credentials separately from the fixture.
+
+The release build workflows collect and validate a profile when their caller enables PGO. The PR preview also runs held-out interaction benchmarks on separate local baseline and optimized builds. Store promotion still needs a gate that verifies the staged binary's proof.
+
+## Held-out interaction benchmark
+
+The PGO workflow has a shorter, read-only benchmark that is separate from the six-minute profile-collection journey. Each sample relaunches the signed-in app, selects Spend → Expenses, returns to Inbox, then times a warm Inbox → Spend → Inbox switch. The app's existing first-paint, content-load, and Inbox navigation spans measure the UI work. One complete warm-up is discarded for each build, followed by the requested number of fresh-process samples. The benchmark checks the same approved account and #focus preference, but sends no messages.
+
+```bash
+scripts/pgo/pgo.ts android benchmark-journey 20 30 --device DEVICE_SERIAL --app-id APP_ID --fixture .pgo/journey-fixture.json
+scripts/pgo/pgo.ts ios benchmark-journey 20 30 --device DEVICE_UDID --app-id BUNDLE_ID --fixture .pgo/journey-fixture.json
+```
+
+Build both the release and optimized artifacts with the current span allowlist first. `build-optimized --profile /absolute/path/to/merged.profdata` selects an immutable archived profile; without `--profile`, it uses `newdot.profdata`. The output uses the existing startup benchmark's raw-sample and summary CSV format in a unique `.pgo//benchmarks/journey//` directory. The two phases run together, so a failed or repeated phase cannot silently pair with stale data. Compare each interactive span separately; adding their durations together does not give a full journey time because there is automation time between taps. Keep this benchmark out of the profile-training batch, and avoid using its result as a release gate until build-order drift and device temperature have been characterized.
+
+## Heavy report benchmark
+
+Use a separate read-only benchmark for a populated report. Each sample starts a fresh signed-in process, finds the fixture's report through Search, measures the app-defined `/r/*` span from report navigation to the report list's first layout, then makes four fast swipes toward older messages and four back toward recent messages. It checks that content changes after each direction; there is no accessibility snapshot between individual flings. The search, device commands, settling, and swipes are **not** included in the report-open latency sample. The span can end when a skeleton is laid out, so treat it as first report render, not complete message or network loading.
+
+```bash
+scripts/pgo/pgo.ts android benchmark-heavy-journey 20 30 --device DEVICE_SERIAL --app-id APP_ID --fixture .pgo/journey-fixture.json
+scripts/pgo/pgo.ts ios benchmark-heavy-journey 20 30 --device DEVICE_UDID --app-id BUNDLE_ID --fixture .pgo/journey-fixture.json
+```
+
+Rebuild both app variants after adding the `/r/*` span to the build-time benchmark allowlist. The earlier artifacts cannot emit this metric. Each batch goes to `.pgo//benchmarks/heavy-journey//` with raw and summary CSVs, artifact hashes, and a run manifest. On Android, an additional frame CSV counts rendered and missed-deadline frames during the fast-scroll window. Compare its missed-frame share separately from report-open latency; it is not elapsed scroll time. The current physical-iPhone frame probe could not consistently attach Xcode's Animation Hitches trace to the app process, so the iOS runner validates scroll movement but deliberately reports no scroll-frame score. A reliable iOS scrolling claim needs an XCTest performance test with a signposted scroll interval or another native frame trace that works on the CI device.
+
+Neither the six-minute collector nor this benchmark's total execution time measures app speed. Android frame counts still include any app rendering triggered during the capture window, and the report-open span can be affected by cached data, network, and device load. Keep the account dataset and device state stable, and warm both variants the same way. Run at least two batches with opposite phase orders by passing `--benchmark-order optimized-first` to the second batch; the default is `release-first`. Report distributions and confidence intervals, and only claim a benefit for the specified device and workload when the improvement exceeds run-to-run variation. No PGO profile can guarantee every launch or interaction will be faster.
diff --git a/scripts/pgo/README.md b/scripts/pgo/README.md
new file mode 100644
index 000000000000..415e77831d49
--- /dev/null
+++ b/scripts/pgo/README.md
@@ -0,0 +1,181 @@
+# Native profile-guided optimization
+
+
+
+This directory contains the local LLVM PGO workflow for Android and iOS. The `pgo.ts` command builds release, instrumented, and profile-optimized apps, retrieves native profiles, merges them, and compares startup performance. On Android, the current flags cover source-built React Native, Hermes, and ExpensifyNitroUtils libraries. On iOS, the workflow forces React Native and Hermes source builds and instruments the app and source-based CocoaPods targets. Precompiled frameworks do not participate.
+
+The command enables `ManualAppStartup` and the existing Spend/Inbox navigation spans in every app it builds. Startup profile collection waits for `ManualAppStartup` to finish, then explicitly writes the LLVM counters before the app process is stopped. Both benchmark modes use app-defined span durations rather than device-automation wall time.
+
+## Prepare local release identifiers
+
+Bootstrap the release projects once so the apps can be installed beside other Expensify builds:
+
+```bash
+scripts/bootstrapForDevice.ts --build-variants release --identifier-suffix pgo
+```
+
+For Christoph Pader's local identifiers, this produces:
+
+| Platform | Identifier |
+| --- | --- |
+| Android | `com.chrispader.expensify.pgo` |
+| iOS | `com.chrispader.expensify.expensifylite.pgo` |
+
+The PGO command reads the Android release application ID from `Mobile-Expensify/Android/build.gradle` and the iOS bundle identifier from the archived app. Pass `--app-id` to override either value.
+
+## Preconditions
+
+- Prefer physical arm64 Android and iOS devices for final profile collection and measurements. An Android emulator running the same ABI and instrumented native build can provide useful counts, but emulator-only behavior can bias the workload. An iOS simulator builds for a different platform, so do not reuse its native profiles for a device build.
+- Android requires the NDK version pinned in `Mobile-Expensify/Android/build.gradle`. Set `ANDROID_NDK_HOME` if it is not installed in the default SDK directory.
+- iOS requires Xcode command-line tools, an unlocked device, and valid local Apple Development signing for the app and its extensions. Select a team with `IOS_DEVELOPMENT_TEAM` when automatic discovery is insufficient.
+- Install dependencies and apply the repository patches before building.
+- Seed a test account and data set before recording an interactive profile.
+
+Every command starts with a platform and a workflow:
+
+```bash
+scripts/pgo/pgo.ts android --help
+scripts/pgo/pgo.ts ios --help
+```
+
+## Collect a startup profile
+
+Build, verify, and install the instrumented app:
+
+```bash
+scripts/pgo/pgo.ts android build-instrumented
+scripts/pgo/pgo.ts android verify-instrumented
+scripts/pgo/pgo.ts android install-instrumented
+```
+
+Record ten cold-process startups, retrieve the raw profiles, and merge them:
+
+```bash
+scripts/pgo/pgo.ts android record-startups
+```
+
+The optional positional arguments set the run count and span timeout:
+
+```bash
+scripts/pgo/pgo.ts android record-startups 20 45
+```
+
+The command removes only old `newdot-*.profraw` files before collection. It requires `ManualAppStartup` to complete on every run and flushes the native counters after each successful run. A missing span fails the collection instead of adding a partial startup. The merged profile is written to `.pgo/android/arm64-v8a/newdot.profdata` or `.pgo/ios/arm64/newdot.profdata`.
+
+The iOS workflow uses the same commands:
+
+```bash
+scripts/pgo/pgo.ts ios build-instrumented
+scripts/pgo/pgo.ts ios verify-instrumented
+scripts/pgo/pgo.ts ios install-instrumented
+scripts/pgo/pgo.ts ios record-startups
+```
+
+The app must remain in the foreground until each profile write completes. Force-terminating an iOS app is not a reliable profile flush.
+
+## Collect an interactive profile
+
+For the automated heavy-account workload, see [Automated native PGO journey](./JOURNEY.md). It checks the signed-in account, disables #focus, drives the critical user flows, and can archive and merge profiles from each successful run.
+
+For a broader profile, install the instrumented app, perform the agreed journey, and write the counters while the app is still running:
+
+```bash
+scripts/pgo/pgo.ts android dump
+scripts/pgo/pgo.ts android pull
+scripts/pgo/pgo.ts android merge
+```
+
+Use the equivalent `ios` commands on iOS. A representative journey should exercise the common signed-in path: open and scroll chats, send a message, visit a workspace setting, attach and view a file, and run a search. Keep account state, data size, and network conditions stable between collections.
+
+Write the counters once per app process. Repeated dumps without resetting counters can merge the same accumulated work again. Independent scenarios need a fresh process, cleared device profile files, and separately archived output. The startup recorder and journey runner each handle that lifecycle; CI must still choose and combine the scenario profiles.
+
+## Build and benchmark the optimized app
+
+Build the optimized app only after recording a fresh profile. Build the baseline release from the same source revision and toolchain:
+
+```bash
+scripts/pgo/pgo.ts android build-optimized
+scripts/pgo/pgo.ts android build-release
+scripts/pgo/pgo.ts android benchmark 20 45
+```
+
+Pass `--profile /absolute/path/to/merged.profdata` to `build-optimized` when comparing an archived profile. The path becomes part of the compiler input; keep the file immutable for the build. Without it, the command uses the platform's `newdot.profdata`. Local PGO builds disable Sentry uploads. iOS temporarily skips the FullStory and Sentry upload phases and restores the original Xcode project afterward. Android excludes `fullstoryRelease` and archives the pre-FullStory APK, so its local benchmarks are internally paired but do not represent the fully post-processed production APK. Do not compare their absolute timings directly with earlier or store builds that used FullStory processing.
+
+The benchmark installs the archived baseline, runs one warm-up and the requested `ManualAppStartup` samples, then repeats the process with the optimized app. Both builds use the same application identifier, so installing the second artifact preserves the seeded account and data. Results are stored under `.pgo//benchmarks/` in the repository benchmark CSV format.
+
+For a separate, read-only interaction benchmark, use the approved signed-in account fixture described in [JOURNEY.md](./JOURNEY.md):
+
+```bash
+scripts/pgo/pgo.ts android benchmark-journey 20 30 --device DEVICE_SERIAL --app-id com.chrispader.expensify.pgo --fixture .pgo/journey-fixture.json
+scripts/pgo/pgo.ts ios benchmark-journey 20 30 --device DEVICE_UDID --app-id com.chrispader.expensify.expensifylite.pgo --fixture .pgo/journey-fixture.json
+```
+
+Each variant gets one discarded warm-up and 20 measured fresh-process journeys. Before each sample, the runner selects Spend → Expenses and returns to Inbox; it then times a warm Inbox → Spend → Inbox tab switch. Expenses can restore the account's saved query and filters, so keep that state fixed across both phases. The CSVs under a unique `.pgo//benchmarks/journey//` directory contain `ManualNavigateToReportsFirstPaint`, `ManualNavigateToReportsContentLoad`, and `ManualNavigateToInboxTab` samples, alongside a run manifest with artifact hashes. These are app-defined tap-to-paint timings, so automation overhead is outside the measurement. The journey sends no messages and leaves account data unchanged. Both phases run in one command and are compared only within that batch. Rebuild both archived variants with this version of the tooling before benchmarking; older PGO builds only logged startup.
+
+For a populated-chat, fast-scroll benchmark, use `benchmark-heavy-journey` with the same arguments. It records the app's report-open span on both platforms and Android's rendered/missed scroll frames in a separate CSV. iOS scroll-frame measurement is not currently reliable on the connected phone; the runner verifies that scrolling moves through real content but does not report a scroll performance number. See [JOURNEY.md](./JOURNEY.md) for the exact measured interval, setup, and limitations. Both variants must be rebuilt to include the additional report-open marker.
+
+The stages can also run independently:
+
+```bash
+scripts/pgo/pgo.ts android benchmark-release 20 45
+scripts/pgo/pgo.ts android benchmark-optimized 20 45
+scripts/pgo/pgo.ts android compare-benchmarks
+```
+
+Select a device or override an identifier when discovery is ambiguous:
+
+```bash
+scripts/pgo/pgo.ts android benchmark 20 45 --device DEVICE_SERIAL --app-id com.chrispader.expensify.pgo
+scripts/pgo/pgo.ts ios benchmark 20 45 --device Chris14Pro --app-id com.chrispader.expensify.expensifylite.pgo
+```
+
+Release, instrumented, and optimized artifacts are archived under `.pgo/android/arm64-v8a/apk/` and `.pgo/ios/arm64/app/`. Do not apply a profile to another architecture, compiler, source revision, dependency graph, or build configuration.
+
+## What to collect
+
+A startup-only profile is useful, but it tends to overfit initialization and can make post-startup code colder. For startup and overall app performance, use a mixed profile with measured weights:
+
+1. Record a fixed startup suite across the important states, such as a signed-out launch, a signed-in inbox launch, and a launch with a realistically large local data set.
+2. Record a small set of common interactive journeys. Prefer bounded flows with deterministic seeded data and an explicit success signal.
+3. Keep the raw output for each scenario separate. A longer scenario generates more counter volume than a short one, so equal repetition does not give scenarios equal influence.
+4. Keep a separate benchmark suite that does not contribute to the training profile. This catches overfitting.
+
+The current `record-startups` command accumulates and merges the startup runs automatically. For a mixed CI profile, preserve each scenario's output independently, convert it to its own indexed profile, then combine the scenario profiles with `llvm-profdata merge --weighted-input`. Choose weights from measured production flow frequency and performance impact, not from a default startup-to-interaction ratio. A giant tour of every feature is less useful than a stable suite that reflects real traffic.
+
+## A realistic CI design
+
+The release build workflows now contain a candidate CI path for Android and iOS. Callers opt into `run-pgo`; the deploy workflow enables it, while ordinary PR builds stay on their existing runners. PGO jobs use dedicated `pgo-android` and `pgo-ios` physical-device runners. Each job builds and installs an instrumented app, checks the signed-in heavy account, turns off #focus through the journey preflight, records three complete journeys, validates the merged profile, and asks Rock for a local source-based store build that consumes it. The build fails if the fixture, device, profile, or app artifact is missing. A proof artifact binds the AAB or IPA hash to the profile hash and both repository revisions without account details.
+
+The PGO preview workflow runs on each update to an Expensify/App PR whose head is the trusted `@chrispader/perf/pgo-ci-preview` branch in that same repository. It calls the build jobs with `benchmark-pgo` enabled and has no store-upload jobs. After sealing the store-build proof, each job builds separate baseline and optimized apps locally and runs the read-only navigation and heavy-report benchmarks in both phase orders. It publishes only CSV samples and a reduced manifest with revisions and artifact hashes. These local benchmark builds omit some store post-processing, so their numbers do not measure the exact AAB or IPA. A PR from the Margelo fork cannot run this privileged workflow with Expensify secrets; an Expensify maintainer must mirror the reviewed branch into Expensify/App first.
+
+Before enabling release runs, provision each runner with the normal build toolchain, one unlocked physical device, a pinned `agent-device` installation, and a private `PGO_JOURNEY_FIXTURE` file. The fixture must identify an owner-approved high-traffic account and its own message chat. Set `PGO_ANDROID_DEVICE_ID` and `ANDROID_NDK_HOME` on Android, or `PGO_IOS_DEVICE_ID` and `IOS_DEVELOPMENT_TEAM` on iOS. iOS also needs an installed Apple Development certificate, app and extension development provisioning, and working XCTest UI Automation signing. Sign in manually on each device and ask the account owner before renewing authentication; CI must never enter credentials. Keep the fixture and device session outside repository artifacts. The runner labels are a provisioning contract, not resources created by this PR.
+
+The candidate path is not yet a complete production guarantee. Normal production promotion reuses the previously uploaded staging binary, and the submit jobs do not yet verify the proof of that staging binary. The iOS PoC also reported missing and mismatched function-profile warnings in the optimized archive. Keep this PR as a draft until the physical runners are available, end-to-end store builds have succeeded, iOS warning coverage is resolved, and promotion has an approved proof gate. A successful local profile alone is not evidence that a submitted store binary used it.
+
+Run profile generation as a scheduled or release-candidate job, not on every pull request. Use pinned self-hosted runners or a device farm, preferably with physical arm64 devices, the production compiler versions, stable thermal and power conditions, a seeded account, and deterministic local fixtures where possible. Build one instrumented artifact per platform, run the fixed startup and interaction suites against that exact artifact, flush after every successful scenario, merge the separate outputs with documented weights, then build the optimized artifact without changing the checkout or toolchain.
+
+Store both repository revisions, dependency locks, compiler and SDK versions, ABI, build settings, scenario version, and test-data fixture with each profile. Reject mismatched artifacts before the optimized build. Keep the instrumented build and raw profiles so a failed collection can be investigated without rerunning it.
+
+Benchmark both variants on the same device after a cooldown, discard post-install warm-ups, and retain raw samples. The `benchmark` workflow runs all baseline samples before all optimized samples, so thermal or time-dependent drift can bias the comparison. The two archived builds intentionally share an identifier to preserve app data, which prevents side-by-side alternation. CI can reduce drift with ABBA install blocks that preserve the shared identifier and discard each post-install warm-up. Another option is to give the builds distinct identifiers, seed matched state, and use the repository's alternating startup benchmark. Evaluate both startup and held-out interactive journeys that were excluded from training. Gate on several releases of data rather than a single noisy run.
+
+Hosted CI is usually insufficient for the complete workflow because hosted runners rarely expose stable physical devices. Android emulators can validate automation and may contribute useful same-ABI counts when the workload is representative, but final validation should use target devices. iOS device collection also needs signing and physical-device access. A small self-hosted device pool or managed device farm is the realistic route to automation.
+
+## Limitations
+
+- LLVM PGO improves compiled native code. It can optimize a source-built Hermes engine, but it does not directly reorder JavaScript or replace JavaScript startup analysis.
+- Precompiled vendored frameworks cannot be instrumented after the fact.
+- The iOS flush currently exports the app executable's profile runtime and Hermes's separate runtime. Other dynamically linked frameworks need their own export path before their instrumentation can contribute to the merged profile.
+- Profiles become stale after native source, dependency, compiler, SDK, ABI, or important build-setting changes. Regenerate them instead of accepting out-of-date warnings.
+- Instrumented builds have overhead. Never use them as the performance baseline.
+- Local bootstrap settings disable Android minification so synthetic identifiers and debug signing work. A production decision still needs a production-like, minified CI build with the repository's signing and dependency issues resolved.
+- Device temperature, battery state, background work, network variability, and test-account drift can overwhelm small gains. Preserve raw samples and use enough repetitions.
+- A profile is only as good as its workload. Narrow startup training can regress later interactions, while an unweighted feature tour can dilute hot production paths.
+
+Android Baseline Profiles are a separate, complementary input to ART compilation of Java and Kotlin. Android Startup Profiles are the related mechanism for DEX layout. Neither replaces this LLVM profile for C, C++, and Swift code.
+
+## References
+
+- [Android NDK profile-guided optimization](https://developer.android.com/ndk/guides/pgo)
+- [Clang profile-guided optimization](https://clang.llvm.org/docs/UsersManual.html#profile-guided-optimization)
+- [`llvm-profdata` weighted inputs](https://llvm.org/docs/CommandGuide/llvm-profdata.html#cmdoption-llvm-profdata-merge-weighted-input)
+- [Android Baseline Profiles](https://developer.android.com/topic/performance/baselineprofiles/overview)
diff --git a/scripts/pgo/android.ts b/scripts/pgo/android.ts
new file mode 100644
index 000000000000..e0ef640aa0a8
--- /dev/null
+++ b/scripts/pgo/android.ts
@@ -0,0 +1,225 @@
+// cspell:ignore sdcard profdata Ppatched Ppgo profraw Readelf readelf zipinfo libreactnative libhermesvm libjsi cnts
+
+/** Android-specific release builds, native profile persistence, retrieval, and instrumentation checks. */
+
+import {copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
+import {homedir, platform, tmpdir} from 'node:os';
+import {join} from 'node:path';
+import process from 'node:process';
+
+import type {BenchmarkKind, BuildArtifactPaths, BuildKind, PlatformAdapter} from './shared';
+
+import {capture, captureBinary, fail, findFiles, rootDirectory, run, sleep} from '../lib/scriptUtils';
+import {BENCHMARK_SPANS_ENVIRONMENT} from './shared';
+
+const ANDROID_JAVA_NAMESPACE = 'org.me.mobiexpensifyg';
+const PROFILE_BROADCAST_ACTION = 'com.expensify.chat.action.WRITE_PGO_PROFILES';
+
+function createAndroidPgoAdapter(configuredAppID?: string, configuredDeviceIdentifier?: string): PlatformAdapter {
+ const ndkVersion = '27.1.12297006';
+ const profileDirectory = join(rootDirectory, '.pgo/android/arm64-v8a');
+ const rawProfileDirectory = join(profileDirectory, 'raw');
+ const apkDirectory = join(profileDirectory, 'apk');
+ const benchmarkDirectory = join(rootDirectory, '.pgo/android/benchmarks');
+ const androidDirectory = join(rootDirectory, 'Mobile-Expensify/Android');
+ const buildGradlePath = join(androidDirectory, 'build.gradle');
+ const gradleReleaseApkPath = join(androidDirectory, 'build/intermediates/apk/release/packageRelease/Expensify-release.apk');
+ const profileDumpTimeoutSeconds = 5;
+
+ const artifactPaths: BuildArtifactPaths = {
+ release: join(apkDirectory, 'Expensify-release.apk'),
+ instrumented: join(apkDirectory, 'Expensify-release-instrumented.apk'),
+ optimized: join(apkDirectory, 'Expensify-release-optimized.apk'),
+ };
+ const benchmarkPaths: Record = {
+ release: join(benchmarkDirectory, 'release.csv'),
+ optimized: join(benchmarkDirectory, 'pgo-optimized.csv'),
+ };
+
+ let cachedAppID: string | undefined;
+
+ function appID(): string {
+ cachedAppID ??= androidApplicationID(readFileSync(buildGradlePath, 'utf8'), configuredAppID);
+ return cachedAppID;
+ }
+
+ function deviceProfileDirectory(): string {
+ return `/sdcard/Android/data/${appID()}/cache`;
+ }
+
+ function adb(args: string[]): void {
+ run('adb', configuredDeviceIdentifier ? ['-s', configuredDeviceIdentifier, ...args] : args);
+ }
+
+ function adbCapture(args: string[]): string {
+ return capture('adb', configuredDeviceIdentifier ? ['-s', configuredDeviceIdentifier, ...args] : args);
+ }
+
+ function llvmTool(name: string): string {
+ const androidNdkHome: unknown = process.env.ANDROID_NDK_HOME;
+ const androidNdkRoot: unknown = process.env.ANDROID_NDK_ROOT;
+ const configuredNdkRoot = [androidNdkHome, androidNdkRoot].find((value): value is string => typeof value === 'string');
+ const ndkRoot = configuredNdkRoot ?? join(homedir(), `Library/Android/sdk/ndk/${ndkVersion}`);
+ const hostTag = platform() === 'linux' ? 'linux-x86_64' : 'darwin-x86_64';
+ const toolPath = join(ndkRoot, `toolchains/llvm/prebuilt/${hostTag}/bin/${name}`);
+ if (!existsSync(toolPath)) {
+ fail(`Missing ${name} at ${toolPath}. Set ANDROID_NDK_HOME (or ANDROID_NDK_ROOT) to the NDK used by the build.`);
+ }
+ return toolPath;
+ }
+
+ function archiveReleaseApk(destination: string): void {
+ if (!existsSync(gradleReleaseApkPath)) {
+ fail(`Missing built release APK at ${gradleReleaseApkPath}.`);
+ }
+ mkdirSync(apkDirectory, {recursive: true});
+ copyFileSync(gradleReleaseApkPath, destination);
+ console.log(`Copied release APK: ${destination}`);
+ }
+
+ function build(kind: BuildKind, profileOverride?: string): void {
+ const profilePath = profileOverride ?? join(profileDirectory, 'newdot.profdata');
+ if (kind === 'optimized' && !existsSync(profilePath)) {
+ fail(`Missing ${profilePath}. Run merge first or pass --profile.`);
+ }
+
+ const pgoModes: Record = {release: 'off', instrumented: 'generate', optimized: 'use'};
+ const gradleArguments = [
+ ':packageRelease',
+ '-PpatchedArtifacts.forceBuildFromSource=true',
+ '-PreactNativeArchitectures=arm64-v8a',
+ `-PpgoMode=${pgoModes[kind]}`,
+ '-x',
+ 'fullstoryRelease',
+ ];
+ if (kind === 'optimized') {
+ gradleArguments.push(`-PpgoProfile=${profilePath}`);
+ }
+
+ run('/usr/bin/env', [BENCHMARK_SPANS_ENVIRONMENT, 'SENTRY_DISABLE_AUTO_UPLOAD=true', './gradlew', ...gradleArguments], androidDirectory);
+ archiveReleaseApk(artifactPaths[kind]);
+ }
+
+ function install(kind: BuildKind): void {
+ const apkPath = artifactPaths[kind];
+ if (!existsSync(apkPath)) {
+ fail(`Missing APK at ${apkPath}. Build the APK first.`);
+ }
+ adb(['install', '-r', apkPath]);
+ console.log(`Installed APK: ${apkPath}`);
+ }
+
+ async function waitForProfileDump(): Promise {
+ const deadline = Date.now() + profileDumpTimeoutSeconds * 1000;
+ while (Date.now() < deadline) {
+ const profileLogs = adbCapture(['logcat', '-d', '-s', 'PgoProfileReceiver:I', '*:S']);
+ const profileCount = /Wrote ([1-9][0-9]*) LLVM PGO profile/.exec(profileLogs)?.[1];
+ if (profileCount) {
+ console.log(`Wrote ${profileCount} LLVM PGO profile(s).`);
+ return;
+ }
+ if (profileLogs.includes('Ignoring PGO profile request in a non-instrumented build.')) {
+ fail('The installed APK is not instrumented. Build and install build-instrumented before recording profiles; build-optimized cannot generate .profraw files.');
+ }
+ if (profileLogs.includes('Wrote 0 LLVM PGO profile(s).')) {
+ fail('The receiver found no instrumented native libraries in the installed APK. Rebuild and install build-instrumented before recording profiles.');
+ }
+ await sleep(100);
+ }
+ const profileLogs = adbCapture(['logcat', '-d', '-s', 'PgoProfileReceiver:I', '*:S']);
+ fail(`The PGO receiver did not confirm a profile write within ${profileDumpTimeoutSeconds}s.\n${profileLogs}`);
+ }
+
+ async function dumpProfiles(): Promise {
+ adb(['logcat', '-c']);
+ adb(['shell', 'am', 'broadcast', '-a', PROFILE_BROADCAST_ACTION, '-n', androidProfileReceiverComponent(appID())]);
+ await waitForProfileDump();
+ }
+
+ function pullProfiles(): void {
+ rmSync(rawProfileDirectory, {recursive: true, force: true});
+ mkdirSync(rawProfileDirectory, {recursive: true});
+ adb(['pull', `${deviceProfileDirectory()}/.`, rawProfileDirectory]);
+ const profiles = findFiles(rawProfileDirectory, '.profraw');
+ if (profiles.length === 0) {
+ fail(`No .profraw files found in ${deviceProfileDirectory()}. Run dump first.`);
+ }
+ console.log(profiles.join('\n'));
+ }
+
+ function verifyInstrumentation(): void {
+ const apkPath = artifactPaths.instrumented;
+ if (!existsSync(apkPath)) {
+ fail(`Missing APK at ${apkPath}. Build the instrumented release first.`);
+ }
+
+ const extractedDirectory = mkdtempSync(join(tmpdir(), 'expensify-pgo-apk-'));
+ const llvmReadelf = llvmTool('llvm-readelf');
+ const llvmNm = llvmTool('llvm-nm');
+ const apkEntries = new Set(capture('zipinfo', ['-1', apkPath]).split('\n'));
+ const libraries = ['libreactnative.so', 'libhermesvm.so', 'libjsi.so', 'libExpensifyNitroUtils.so'];
+ try {
+ for (const library of libraries) {
+ const apkEntry = `lib/arm64-v8a/${library}`;
+ if (!apkEntries.has(apkEntry)) {
+ fail(`Missing expected arm64 library in APK: ${apkEntry}`);
+ }
+ const extractedLibrary = join(extractedDirectory, library);
+ writeFileSync(extractedLibrary, captureBinary('unzip', ['-p', apkPath, apkEntry]));
+ const sectionHeaders = capture(llvmReadelf, ['-SW', extractedLibrary]);
+ if (!['__llvm_prf_data', '__llvm_prf_cnts', '__llvm_prf_names'].every((section) => sectionHeaders.includes(section))) {
+ fail(`LLVM PGO instrumentation is missing from ${apkEntry}.`);
+ }
+ const dynamicSymbols = capture(llvmNm, ['-D', '--defined-only', extractedLibrary]);
+ if (!['expensify_llvm_profile_set_filename', 'expensify_llvm_profile_write_file'].every((symbol) => dynamicSymbols.includes(symbol))) {
+ fail(`LLVM PGO profile-writing APIs are not exported from ${apkEntry}.`);
+ }
+ if (library === 'libExpensifyNitroUtils.so' && !dynamicSymbols.includes('Java_org_me_mobiexpensifyg_PgoProfileWriter_writeProfiles')) {
+ fail(`The PGO JNI writer is missing from ${apkEntry}. Rebuild the native module before installing.`);
+ }
+ console.log(`Verified LLVM PGO instrumentation: ${apkEntry}`);
+ }
+ } finally {
+ rmSync(extractedDirectory, {recursive: true, force: true});
+ }
+ }
+
+ return {
+ name: 'android',
+ profileDirectory,
+ rawProfileDirectory,
+ mergedProfilePath: join(profileDirectory, 'newdot.profdata'),
+ benchmarkDirectory,
+ benchmarkPaths,
+ artifactPaths,
+ appID,
+ build,
+ install,
+ verifyInstrumentation,
+ clearDeviceProfiles: async () => {
+ console.log(`Clearing previous device PGO profiles from ${deviceProfileDirectory()}.`);
+ adb(['shell', `rm -f '${deviceProfileDirectory()}'/newdot-*.profraw`]);
+ },
+ dumpProfiles,
+ pullProfiles,
+ llvmTool,
+ };
+}
+
+function androidApplicationID(buildGradle: string, configuredAppID?: string): string {
+ if (configuredAppID) {
+ return configuredAppID;
+ }
+ const applicationID = /defaultConfig\s*\{[\s\S]*?applicationId\s+["']([^"']+)["']/.exec(buildGradle)?.[1];
+ if (!applicationID) {
+ fail('Unable to read the release application ID from Mobile-Expensify/Android/build.gradle. Pass it with --app-id.');
+ }
+ return applicationID;
+}
+
+function androidProfileReceiverComponent(appID: string): string {
+ return `${appID}/${ANDROID_JAVA_NAMESPACE}.PgoProfileReceiver`;
+}
+
+export default createAndroidPgoAdapter;
+export {PROFILE_BROADCAST_ACTION, androidApplicationID, androidProfileReceiverComponent};
diff --git a/scripts/pgo/ciProfile.ts b/scripts/pgo/ciProfile.ts
new file mode 100644
index 000000000000..59edfd123802
--- /dev/null
+++ b/scripts/pgo/ciProfile.ts
@@ -0,0 +1,240 @@
+#!/usr/bin/env bun
+
+// cspell:ignore Xfrontend profdata xcconfig fprofile unprofiled CPLUSPLUSFLAGS
+
+import {createHash} from 'node:crypto';
+import {copyFileSync, existsSync, readFileSync, statSync, writeFileSync} from 'node:fs';
+import {basename, join} from 'node:path';
+import process from 'node:process';
+
+import type {PlatformName} from '../lib/nativeAppBenchmark';
+
+import {capture, fail, rootDirectory, valueAt} from '../lib/scriptUtils';
+import createAndroidPgoAdapter from './android';
+import createIOSPgoAdapter from './ios';
+
+type JourneyResult = {
+ platform: PlatformName;
+ status: 'passed';
+ accountClass: 'heavy';
+ sourceRevision: string;
+ mobileRevision: string;
+ requestedRuns: 3;
+ completedRuns: 3;
+ collectProfiles: true;
+ navigationOnly: false;
+};
+
+type PgoProof = {
+ schema: 1;
+ platform: PlatformName;
+ appVersion: string;
+ sourceRevision: string;
+ mobileRevision: string;
+ profileSha256: string;
+ binaryName: string;
+ binarySha256: string;
+};
+
+/** Validate a completed heavy-account batch before a publishable build can consume it. */
+function prepare(platform: PlatformName, batchID: string): void {
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(batchID)) {
+ fail('Invalid PGO batch ID.');
+ }
+ const batchDirectory = join(rootDirectory, '.pgo', platform, 'journeys', batchID);
+ const result = readJSON(join(batchDirectory, 'result.json'));
+ const revisions = currentRevisions();
+ if (!isJourneyResult(result, platform, revisions)) {
+ fail('PGO journey did not complete three collected runs on a heavy account at the current revisions.');
+ }
+ const adapter = platform === 'android' ? createAndroidPgoAdapter() : createIOSPgoAdapter();
+ const sourceProfile = join(batchDirectory, 'journey.profdata');
+ requireNonemptyFile(sourceProfile);
+ const summary = capture(adapter.llvmTool('llvm-profdata'), ['show', sourceProfile]);
+ const functionCount = /Total functions:\s*([0-9]+)/.exec(summary)?.[1];
+ if (!hasExecutedFunctions(summary)) {
+ fail('The merged LLVM profile has no executed functions.');
+ }
+ if (adapter.profileFormat) {
+ const formatPath = `${sourceProfile}.format`;
+ requireNonemptyFile(formatPath);
+ if (readFileSync(formatPath, 'utf8').trim() !== adapter.profileFormat) {
+ fail('The iOS profile format does not match the current instrumentation.');
+ }
+ const functions = capture(adapter.llvmTool('llvm-profdata'), ['show', '--all-functions', '--counts', sourceProfile]);
+ if (!hasExecutedSwiftFunctions(functions)) {
+ fail('The merged iOS profile has no executed Swift functions. Rebuild with Swift IR instrumentation and collect fresh profiles.');
+ }
+ copyFileSync(formatPath, `${adapter.mergedProfilePath}.format`);
+ }
+ copyFileSync(sourceProfile, adapter.mergedProfilePath);
+ const profileSha256 = sha256(adapter.mergedProfilePath);
+ if (platform === 'ios') {
+ const profilePath = adapter.mergedProfilePath;
+ const flags = `-fprofile-use=${profilePath} -Wno-error=profile-instr-unprofiled`;
+ writeFileSync(
+ join(adapter.profileDirectory, 'release.xcconfig'),
+ `CLANG_USE_OPTIMIZATION_PROFILE = NO\nOTHER_CFLAGS = $(inherited) ${flags}\nOTHER_CPLUSPLUSFLAGS = $(inherited) ${flags}\nOTHER_SWIFT_FLAGS = $(inherited) -Xfrontend -ir-profile-use=${profilePath}\n`,
+ );
+ }
+ writeFileSync(join(adapter.profileDirectory, 'ci-profile.json'), `${JSON.stringify({...revisions, profileSha256}, null, 2)}\n`);
+ appendOutput('PROFILE_SHA256', profileSha256);
+ appendOutput('PROFILE_PATH', adapter.mergedProfilePath);
+ console.log(`Verified ${functionCount} profiled functions across three journeys.`);
+}
+
+/** Bind the exact store artifact to the validated profile without publishing account data. */
+function seal(platform: PlatformName, appVersion: string, binaryPath: string, outputPath: string): void {
+ const adapter = platform === 'android' ? createAndroidPgoAdapter() : createIOSPgoAdapter();
+ const profileMetadata = readJSON(join(adapter.profileDirectory, 'ci-profile.json'));
+ const revisions = currentRevisions();
+ if (
+ !isRecord(profileMetadata) ||
+ profileMetadata.sourceRevision !== revisions.sourceRevision ||
+ profileMetadata.mobileRevision !== revisions.mobileRevision ||
+ profileMetadata.profileSha256 !== sha256(adapter.mergedProfilePath)
+ ) {
+ fail('The release build does not match the verified PGO profile.');
+ }
+ requireNonemptyFile(binaryPath);
+ const proof: PgoProof = {
+ schema: 1,
+ platform,
+ appVersion,
+ ...revisions,
+ profileSha256: profileMetadata.profileSha256,
+ binaryName: basename(binaryPath),
+ binarySha256: sha256(binaryPath),
+ };
+ writeFileSync(outputPath, `${JSON.stringify(proof, null, 2)}\n`);
+}
+
+/** Refuse a production promotion without proof for the exact staging version. */
+function verify(platform: PlatformName, appVersion: string, proofPath: string, binaryPath?: string): void {
+ const proof = readJSON(proofPath);
+ if (!isPgoProof(proof, platform, appVersion)) {
+ fail(`Missing or invalid ${platform} PGO proof for ${appVersion}.`);
+ }
+ if (binaryPath && (proof.binaryName !== basename(binaryPath) || proof.binarySha256 !== sha256(binaryPath))) {
+ fail(`The ${platform} binary does not match its PGO proof.`);
+ }
+ console.log(`Verified ${platform} PGO proof for ${appVersion}.`);
+}
+
+function isJourneyResult(input: unknown, platform: PlatformName, revisions: {sourceRevision: string; mobileRevision: string}): input is JourneyResult {
+ return (
+ isRecord(input) &&
+ input.platform === platform &&
+ input.status === 'passed' &&
+ input.accountClass === 'heavy' &&
+ input.sourceRevision === revisions.sourceRevision &&
+ input.mobileRevision === revisions.mobileRevision &&
+ input.requestedRuns === 3 &&
+ input.completedRuns === 3 &&
+ input.collectProfiles === true &&
+ input.navigationOnly === false
+ );
+}
+
+function isPgoProof(input: unknown, platform: PlatformName, appVersion: string): input is PgoProof {
+ return (
+ isRecord(input) &&
+ input.schema === 1 &&
+ input.platform === platform &&
+ input.appVersion === appVersion &&
+ isGitRevision(input.sourceRevision) &&
+ isGitRevision(input.mobileRevision) &&
+ isSha256(input.profileSha256) &&
+ isSha256(input.binarySha256) &&
+ typeof input.binaryName === 'string' &&
+ input.binaryName.endsWith(platform === 'android' ? '.aab' : '.ipa')
+ );
+}
+
+function currentRevisions(): {sourceRevision: string; mobileRevision: string} {
+ return {
+ sourceRevision: capture('git', ['rev-parse', 'HEAD']).trim(),
+ mobileRevision: capture('git', ['-C', 'Mobile-Expensify', 'rev-parse', 'HEAD']).trim(),
+ };
+}
+
+function readJSON(path: string): unknown {
+ requireNonemptyFile(path);
+ return JSON.parse(readFileSync(path, 'utf8')) as unknown;
+}
+
+function requireNonemptyFile(path: string): void {
+ if (existsSync(path) && statSync(path).size > 0) {
+ return;
+ }
+ fail(`Missing or empty PGO file: ${path}`);
+}
+
+function sha256(path: string): string {
+ requireNonemptyFile(path);
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
+}
+
+/** LLVM versions expose either an aggregate count or their maximum function count. */
+function hasExecutedFunctions(summary: string): boolean {
+ const functions = Number(/Total functions:\s*([0-9]+)/.exec(summary)?.[1]);
+ const count = /Total count:\s*([0-9]+)/.exec(summary)?.[1] ?? /Maximum function count:\s*([0-9]+)/.exec(summary)?.[1];
+ return Number.isSafeInteger(functions) && functions > 0 && count !== undefined && BigInt(count) > 0n;
+}
+
+/** Require Swift counters so Clang-only profiles cannot silently pass the iOS gate. */
+function hasExecutedSwiftFunctions(report: string): boolean {
+ const functions = report.matchAll(/^ {2}(\S[^\n]*):\r?\n((?: {4}[^\n]*\r?\n)*)/gm);
+ return Array.from(functions).some((entry) => /(?:^|:)\$s/.test(entry[1] ?? '') && /(?:Function count:\s*|Block counts:\s*\[[^\]\n]*)[1-9][0-9]*/.test(entry[2] ?? ''));
+}
+
+function isGitRevision(value: unknown): boolean {
+ return typeof value === 'string' && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value);
+}
+
+function isSha256(value: unknown): value is string {
+ return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function appendOutput(name: string, value: string): void {
+ const outputPath = process.env.GITHUB_OUTPUT;
+ if (!outputPath) {
+ return;
+ }
+ writeFileSync(outputPath, `${name}=${value}\n`, {flag: 'a'});
+}
+
+if (import.meta.main) {
+ const [command, platform, ...args] = process.argv.slice(2);
+ if (platform !== 'android' && platform !== 'ios') {
+ fail('Expected platform android or ios.');
+ }
+ switch (command) {
+ case 'prepare':
+ if (args.length !== 1) {
+ fail('Expected prepare .');
+ }
+ prepare(platform, valueAt(args, 0));
+ break;
+ case 'seal':
+ if (args.length !== 3) {
+ fail('Expected seal