Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
3fa458a
build: create script for PGO builds
chrispader Aug 4, 2026
3295d38
build: configure PGO build
chrispader Aug 4, 2026
e1747b5
build: add `verify-instrumented` tool
chrispader Aug 4, 2026
96518ea
chore: update RN patch
chrispader Aug 4, 2026
27c7035
chore: add `.pgo` dir to `.gitignore`
chrispader Aug 4, 2026
e8d1104
fix: PGO merging
chrispader Aug 4, 2026
2b114fd
build: add `install-instrumented` command
chrispader Aug 4, 2026
9322dba
Update README.md
chrispader Aug 4, 2026
47f9d5b
Update android-local-proof.sh
chrispader Aug 4, 2026
6ba45d6
build: create record-startups command
chrispader Aug 4, 2026
62808e8
fix: prevent startup profile merge scope error
chrispader Aug 4, 2026
0da014a
build: add dedicated PGO build variants
chrispader Aug 4, 2026
ea4b7fb
Wait for native app-ready signal during PGO startup recording
chrispader Aug 4, 2026
151f4f1
chore: improve script
chrispader Aug 4, 2026
1921255
chore: add PGO benchmarking suite
chrispader Aug 4, 2026
13df28a
build: add dedicated PGO build variants
chrispader Aug 4, 2026
f906d1a
Merge branch '@chrispader/perf/pgo' into @chrispader/perf/pgo-startup…
chrispader Aug 4, 2026
7bf73a0
revert: separate build variants for PGO
chrispader Aug 4, 2026
4ce4248
chore: move apk outputs to `.pgo` folder
chrispader Aug 4, 2026
c267adb
chore: archive release build
chrispader Aug 4, 2026
43425ff
chore: copy generated release file instead of moving it
chrispader Aug 4, 2026
aa96634
chore: extend PGO benchmark suite
chrispader Aug 4, 2026
dfceb96
chore: reformat benchmark output
chrispader Aug 4, 2026
e25759f
refactor: migrate script to TypeScript
chrispader Aug 5, 2026
04f20ae
refactor: use CLI tool in the PGO tool
chrispader Aug 5, 2026
7f56cfb
Merge branch 'main' into @chrispader/perf/pgo
chrispader Aug 5, 2026
c68accc
feat: implement iOS PGO
chrispader Aug 5, 2026
fa69456
fix: ios optimized build
chrispader Aug 5, 2026
5ddd179
fix: benchmark release first
chrispader Aug 5, 2026
22a19cd
feat: allow defining device
chrispader Aug 6, 2026
d35b7f5
Update README.md
chrispader Aug 6, 2026
b143c0f
perf: merge main and refresh native PGO workflow
chrispader Sep 21, 2026
9befe34
chore: allow native PGO terminology in spelling checks
chrispader Sep 21, 2026
169f09f
docs: clarify PGO scenario isolation and artifact matching
chrispader Sep 21, 2026
66a65c1
fix: support Xcode 27 benchmark process discovery
chrispader Sep 21, 2026
f44192d
chore: merge latest main into PGO work
chrispader Sep 21, 2026
d94d768
docs: clarify iOS PGO profile coverage
chrispader Sep 21, 2026
2beedd0
Merge remote-tracking branch 'origin/main' into @chrispader/perf/pgo
chrispader Sep 22, 2026
72c97bd
Merge remote-tracking branch 'origin/main' into @chrispader/perf/pgo
chrispader Sep 22, 2026
c4bd27d
refactor: share PGO script utilities
chrispader Sep 22, 2026
6aa1031
fix: allow partial iOS PGO profiles
chrispader Sep 23, 2026
f6978f1
perf: automate native PGO journeys
chrispader Sep 23, 2026
a9cae54
fix: match iOS PGO tab status labels
chrispader Sep 23, 2026
394ac25
fix: complete iOS PGO journey on physical device
chrispader Sep 23, 2026
b7b4105
fix(pgo): handle mobile navigation tree edge cases
chrispader Sep 23, 2026
7673df9
perf(pgo): benchmark short signed-in journeys
chrispader Sep 24, 2026
21f2d6c
perf(pgo): add heavy report benchmark
chrispader Sep 24, 2026
e83fa48
fix(pgo): reset heavy benchmark navigation between runs
chrispader Sep 25, 2026
d0cfd6a
fix(pgo): wait for navigation after Android relaunch
chrispader Sep 25, 2026
f9b823c
chore(pgo): merge main into PGO branch
chrispader Sep 28, 2026
e2cf575
chore(pgo): merge latest App main
chrispader Oct 12, 2026
13943b7
chore(pgo): refresh latest App main
chrispader Oct 12, 2026
69a8d67
fix(pgo): align iOS IR profiling and Rock builds
chrispader Oct 12, 2026
83c7263
chore(pgo): merge latest App main
chrispader Oct 12, 2026
e9dec6c
fix(pgo): resolve foundation validation failures
chrispader Oct 12, 2026
0e6f454
chore(pgo): sync latest App main
chrispader Oct 12, 2026
74f329c
chore(pgo): include latest upstream test fix
chrispader Oct 12, 2026
16db894
fix(pgo): reject partial Android profiles and resolve Linux NDK paths
chrispader Oct 13, 2026
4cce518
chore(pgo): merge latest main into foundation
chrispader Oct 13, 2026
6aab15b
fix(pgo): skip Gradle lookup for explicit Android app IDs
chrispader Oct 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
11 changes: 11 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@
android:theme="@style/AppTheme"
tools:replace="android:supportsRtl">

<receiver
android:name=".PgoProfileReceiver"
android:exported="true">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Vit's agent) This receiver ships in production with no permission, so any app on the device can send it. One line locks it to adb: the shell user holds DUMP, and normal apps can't get it. Mobile-Expensify#14046 needs the same change.

Suggested change
android:exported="true">
android:exported="true"
android:permission="android.permission.DUMP">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can go further by putting this receiver in a separate AndroidManifest, along with PgoProfileReceiver and PgoProfileWriter in a pgo folder.

sourceSets {
    if (pgoMode == 'generate') {
        release {
            manifest.srcFile 'src/pgo/AndroidManifest.xml'
            java.srcDirs += 'src/pgo/java'
        }
    }
}

<intent-filter>
<action android:name="com.expensify.chat.action.WRITE_PGO_PROFILES" />
</intent-filter>
</receiver>

<service
android:name="com.expensify.reactnativebackgroundtask.BackgroundJobService"
android:permission="android.permission.BIND_JOB_SERVICE"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.expensify.chat;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.util.Log;

/**
* Receives an explicit adb broadcast at the end of a local PGO collection run.
*
* The receiver is inert in ordinary builds. It deliberately lives outside the JS surface so a
* manual profiling journey can finish before its native counters are persisted.
*/
public final class PgoProfileReceiver extends BroadcastReceiver {
private static final String TAG = "PgoProfileReceiver";

@Override
public void onReceive(Context context, Intent intent) {
if (!BuildConfig.PGO_PROFILE_GENERATION) {
Log.w(TAG, "Ignoring PGO profile request in a non-instrumented build.");
return;
}

final int writtenProfiles = PgoProfileWriter.writeProfiles(context);
Log.i(TAG, "Wrote " + writtenProfiles + " LLVM PGO profile(s).");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.expensify.chat;

import android.content.Context;

/** Development-only bridge for persisting LLVM PGO counters before Android kills the process. */
public final class PgoProfileWriter {
private PgoProfileWriter() {}

private static native int writeProfiles(String directory);

public static int writeProfiles(Context context) {
if (!BuildConfig.PGO_PROFILE_GENERATION) {
return 0;
}

System.loadLibrary("ExpensifyNitroUtils");
return writeProfiles(context.getCacheDir().getAbsolutePath());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Vit's agent) The PGO scripts only target HybridApp (org.me.mobiexpensifyg), so this standalone copy never runs. It also writes to getCacheDir(), which adb can't pull from a release build. The Mobile-Expensify copy uses getExternalCacheDir(). Can we drop the android/app changes, or make them match?

}
}
1 change: 1 addition & 0 deletions ios/NewExpensify/RCTBootSplash.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

+ (void)initWithStoryboard:(NSString * _Nonnull)storyboardName
rootView:(UIView * _Nullable)rootView;
+ (void)initializePGOProfileCollection;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Vit's agent) Nit: this puts about 100 lines of profiling code in the boot splash module. A small ExpensifyPGO.mm would keep it separate and make the #if EXPENSIFY_PGO_GENERATE gate simpler.

+ (void)hide:(BOOL)fade;
+ (void)bringSubviewToFrontIfInitialized;

Expand Down
124 changes: 124 additions & 0 deletions ios/NewExpensify/RCTBootSplash.mm
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,62 @@
#import <React/RCTSurfaceHostingView.h>
#import <React/RCTRootView.h>

#ifndef EXPENSIFY_PGO_GENERATE
#define EXPENSIFY_PGO_GENERATE 0
#endif

#if EXPENSIFY_PGO_GENERATE
#include <dlfcn.h>

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 <typename Function>
static Function HermesPGOFunction(const char *symbol) {
return reinterpret_cast<Function>(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;
static NSMutableArray<RCTPromiseResolveBlock> *_resolveQueue = [[NSMutableArray alloc] init];
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();
Expand All @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notifications are registered even when EXPENSIFY_PGO_GENERATE is disabled.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seconding this


#if EXPENSIFY_PGO_GENERATE
NSString *profilePattern = [[[self pgoDirectoryURL] URLByAppendingPathComponent:@"newdot-%m.profraw"] path];
__llvm_profile_set_filename(profilePattern.fileSystemRepresentation);
PGOSetFilename setHermesFilename = HermesPGOFunction<PGOSetFilename>("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<PGOWriteFile>("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<NSURL *> *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<PGOResetCounters>("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];
}
Expand Down
2 changes: 1 addition & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,5 +90,5 @@
"diff-so-fancy",
"@babel/plugin-proposal-class-properties"
],
"ignoreBinaries": ["metro-symbolicate", "mkcert", "openssl"]
"ignoreBinaries": ["metro-symbolicate", "mkcert", "openssl", "agent-device"]
}
31 changes: 30 additions & 1 deletion modules/ExpensifyNitroUtils/android/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Vit's agent) This hardcodes the aarch64 runtime, and so do the two copies in the RN patch. A generate build with the default ABIs will fail to link. Could Gradle reject any pgoMode other than off unless reactNativeArchitectures=arm64-v8a?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's fine because we always use arm64-v8a when building the app.

image

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
Expand All @@ -29,4 +58,4 @@ target_link_libraries(
${PACKAGE_NAME}
${LOG_LIB}
android
)
)
17 changes: 15 additions & 2 deletions modules/ExpensifyNitroUtils/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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())
}
}
Expand Down Expand Up @@ -126,4 +140,3 @@ dependencies {
// Add a dependency on NitroModules
implementation project(":react-native-nitro-modules")
}

Loading
Loading