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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,6 @@ FOUNDATION_EXTERN NSString *const kFPRSlowFrameCounterName;
/** Counter name for total frames. */
FOUNDATION_EXTERN NSString *const kFPRTotalFramesCounterName;

/** Slow frame threshold (for time difference between current and previous frame render time)
* in sec.
*/
FOUNDATION_EXTERN CFTimeInterval const kFPRSlowFrameThreshold;

/** Frozen frame threshold (for time difference between current and previous frame render time)
* in sec.
*/
FOUNDATION_EXTERN CFTimeInterval const kFPRFrozenFrameThreshold;

@interface FPRScreenTraceTracker ()

/** A map table of that has the viewControllers as the keys and their associated trace as the value.
Expand Down
66 changes: 54 additions & 12 deletions FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,21 @@
NSString *const kFPRSlowFrameCounterName = @"_fr_slo";
NSString *const kFPRTotalFramesCounterName = @"_fr_tot";

// Note: This was previously 60 FPS, but that resulted in 90% + of all frames collected to be
// flagged as slow frames, and so the threshold for iOS is being changed to 59 FPS.
// TODO(b/73498642): Make these configurable.
CFTimeInterval const kFPRSlowFrameThreshold = 1.0 / 59.0; // Anything less than 59 FPS is slow.
CFTimeInterval const kFPRFrozenFrameThreshold = 700.0 / 1000.0;
/** Frozen frame multiplier: A frozen frame is one that takes longer than approximately 42 times
* the current frame duration. This maintains backward compatibility with the old 700ms threshold
* at 60Hz (700ms ÷ 16.67ms ≈ 42 frames).
Comment on lines +29 to +30
Copy link
Contributor

Choose a reason for hiding this comment

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

Current documentation clearly states that frozen frame takes 700+ ms. This PR does not follow this definition. Are you going to update the Frozen Frame definition for Firebase Performance?

*
* Note: A "frozen" frame represents missing 42 consecutive frame opportunities,
* which looks and feels equally bad to users regardless of refresh rate.
*
* Formula: frozenThreshold = kFPRFrozenFrameMultiplier * actualFrameDuration
*
* Examples (all represent missing 42 frame opportunities):
* - 60Hz: 42 × 16.67ms = 700ms (same as original threshold)
* - 120Hz: 42 × 8.33ms = 350ms (missing 42 frames at higher refresh rate)
* - 50Hz: 42 × 20ms = 840ms (missing 42 frames at lower refresh rate)
*/
static const CFTimeInterval kFPRFrozenFrameMultiplier = 42.0;

/** Constant that indicates an invalid time. */
CFAbsoluteTime const kFPRInvalidTime = -1.0;
Expand Down Expand Up @@ -80,6 +90,8 @@ @implementation FPRScreenTraceTracker {

/** Instance variable storing the frozen frames observed so far. */
atomic_int_fast64_t _frozenFramesCount;

CFAbsoluteTime _previousTimestamp;
}

@dynamic totalFramesCount;
Expand Down Expand Up @@ -114,6 +126,7 @@ - (instancetype)init {
atomic_store_explicit(&_slowFramesCount, 0, memory_order_relaxed);
_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkStep)];
[_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
_previousTimestamp = kFPRInvalidTime;

// We don't receive background and foreground events from analytics and so we have to listen to
// them ourselves.
Expand Down Expand Up @@ -142,6 +155,10 @@ - (void)dealloc {
}

- (void)appDidBecomeActiveNotification:(NSNotification *)notification {
// Resume the display link when the app becomes active
_displayLink.paused = NO;
_previousTimestamp = kFPRInvalidTime;

// To get the most accurate numbers of total, frozen and slow frames, we need to capture them as
// soon as we're notified of an event.
int64_t currentTotalFrames = atomic_load_explicit(&_totalFramesCount, memory_order_relaxed);
Expand All @@ -160,6 +177,10 @@ - (void)appDidBecomeActiveNotification:(NSNotification *)notification {
}

- (void)appWillResignActiveNotification:(NSNotification *)notification {
// Pause the display link when the app goes to background to conserve battery
_displayLink.paused = YES;
_previousTimestamp = kFPRInvalidTime;

// To get the most accurate numbers of total, frozen and slow frames, we need to capture them as
// soon as we're notified of an event.
int64_t currentTotalFrames = atomic_load_explicit(&_totalFramesCount, memory_order_relaxed);
Expand All @@ -186,38 +207,59 @@ - (void)appWillResignActiveNotification:(NSNotification *)notification {
#pragma mark - Frozen, slow and good frames

- (void)displayLinkStep {
static CFAbsoluteTime previousTimestamp = kFPRInvalidTime;
CFAbsoluteTime currentTimestamp = self.displayLink.timestamp;
RecordFrameType(currentTimestamp, previousTimestamp, &_slowFramesCount, &_frozenFramesCount,
&_totalFramesCount);
previousTimestamp = currentTimestamp;

CFTimeInterval actualFrameDuration =
self.displayLink.targetTimestamp - self.displayLink.timestamp;

// Defensive: skip classification when frame budget is zero/negative (e.g., lifecycle/VRR edges)
if (actualFrameDuration <= 0) {
_previousTimestamp = currentTimestamp;
Copy link
Contributor

Choose a reason for hiding this comment

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

I think you should do some regression testing here. Especially with high frame rates - the number of times displayLinkStep gets called will be very high that the CPU/Memory usage will start spiking up. Especially since there can multiple FPRScreenTraceTrackers running at the same time.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

will do!

return;
}

// Dynamic thresholds: slow if frameDuration > frameBudget; frozen if frameDuration > 42 *
// frameBudget. frameBudget is derived from CADisplayLink targetTimestamp - timestamp and adapts
// to VRR/50/60/120 Hz.
RecordFrameType(currentTimestamp, _previousTimestamp, actualFrameDuration, &_slowFramesCount,
&_frozenFramesCount, &_totalFramesCount);
_previousTimestamp = currentTimestamp;
}

/** This function increments the relevant frame counters based on the current and previous
* timestamp provided by the displayLink.
*
* @param currentTimestamp The current timestamp of the displayLink.
* @param previousTimestamp The previous timestamp of the displayLink.
* @param actualFrameDuration The actual frame duration calculated from CADisplayLink's
* targetTimestamp and timestamp.
* @param slowFramesCounter The value of the slowFramesCount before this function was called.
* @param frozenFramesCounter The value of the frozenFramesCount before this function was called.
* @param totalFramesCounter The value of the totalFramesCount before this function was called.
*/
FOUNDATION_STATIC_INLINE
void RecordFrameType(CFAbsoluteTime currentTimestamp,
CFAbsoluteTime previousTimestamp,
CFTimeInterval actualFrameDuration,
atomic_int_fast64_t *slowFramesCounter,
atomic_int_fast64_t *frozenFramesCounter,
atomic_int_fast64_t *totalFramesCounter) {
CFTimeInterval frameDuration = currentTimestamp - previousTimestamp;
if (previousTimestamp == kFPRInvalidTime) {
if (previousTimestamp == kFPRInvalidTime || actualFrameDuration <= 0 || frameDuration <= 0) {
return;
}
if (frameDuration > kFPRSlowFrameThreshold) {

// Dynamic thresholds: classify against the runtime-derived frame budget.
// Slow: frameDuration > actualFrameDuration; Frozen: frameDuration > (42 * actualFrameDuration).
if (frameDuration > actualFrameDuration) {
atomic_fetch_add_explicit(slowFramesCounter, 1, memory_order_relaxed);
}
if (frameDuration > kFPRFrozenFrameThreshold) {

CFTimeInterval frozenThreshold = kFPRFrozenFrameMultiplier * actualFrameDuration;
if (frameDuration > frozenThreshold) {
atomic_fetch_add_explicit(frozenFramesCounter, 1, memory_order_relaxed);
}

atomic_fetch_add_explicit(totalFramesCounter, 1, memory_order_relaxed);
}

Expand Down
90 changes: 74 additions & 16 deletions FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@
#import <OCMock/OCMock.h>
#import "FirebasePerformance/Tests/Unit/FPRTestCase.h"

static inline CFTimeInterval FPRFrameBudgetForHz(double hz) {
return 1.0 / hz;
}
static inline CFTimeInterval FPRFrozenThresholdForBudget(CFTimeInterval budget) {
return 42.0 * budget;
}

/** Registers and returns an instance of a custom subclass of UIViewController. */
static UIViewController *FPRCustomViewController(NSString *className, BOOL isViewLoaded) {
Class customClass = NSClassFromString(className);
Expand Down Expand Up @@ -603,20 +610,38 @@ - (void)testAppDidBecomeActiveWillNotRestoreTracesOfNilledViewControllers {
* slow frame counter of the screen trace tracker is incremented.
*/
- (void)testSlowFrameIsRecorded {
CFTimeInterval frameBudget = FPRFrameBudgetForHz(60.0);
CFAbsoluteTime firstFrameRenderTimestamp = 1.0;
CFAbsoluteTime secondFrameRenderTimestamp =
firstFrameRenderTimestamp + kFPRSlowFrameThreshold + 0.005; // Buffer for float comparison.
CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + frameBudget + 0.005;

id displayLinkMock = OCMClassMock([CADisplayLink class]);
[self.tracker.displayLink invalidate];
self.tracker.displayLink = displayLinkMock;

// Set/Reset the previousFrameTimestamp if it has been set by a previous test.
OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp);
// Drive CADisplayLink with matched timestamp/targetTimestamp pairs per tick.
__block NSInteger tick = 0;
NSArray<NSNumber *> *timestamps =
@[ @(firstFrameRenderTimestamp), @(secondFrameRenderTimestamp) ];
NSArray<NSNumber *> *targets =
@[ @(firstFrameRenderTimestamp + frameBudget), @(secondFrameRenderTimestamp + frameBudget) ];

OCMStub([displayLinkMock isPaused]).andReturn(NO);

OCMStub([displayLinkMock timestamp]).andDo(^(NSInvocation *inv) {
CFTimeInterval v = timestamps[(NSUInteger)tick].doubleValue;
[inv setReturnValue:&v];
});
OCMStub([displayLinkMock targetTimestamp]).andDo(^(NSInvocation *inv) {
CFTimeInterval v = targets[(NSUInteger)tick].doubleValue;
[inv setReturnValue:&v];
});

// Tick 1 - prime previous timestamp (no counts expected).
[self.tracker displayLinkStep];
int64_t initialSlowFramesCount = self.tracker.slowFramesCount;

OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp);
// Tick 2 - slow frame: duration > budget.
tick++;
[self.tracker displayLinkStep];

int64_t newSlowFramesCount = self.tracker.slowFramesCount;
Expand All @@ -625,13 +650,16 @@ - (void)testSlowFrameIsRecorded {

/** Tests that the slow and frozen frame counter is not incremented in the case of a good frame. */
- (void)testSlowAndFrozenFrameIsNotRecordedInCaseOfGoodFrame {
CFTimeInterval frameBudget = FPRFrameBudgetForHz(60.0);
CFAbsoluteTime firstFrameRenderTimestamp = 1.0;
CFAbsoluteTime secondFrameRenderTimestamp =
firstFrameRenderTimestamp + kFPRSlowFrameThreshold - 0.005; // Good frame.
CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + frameBudget - 0.005;

id displayLinkMock = OCMClassMock([CADisplayLink class]);
[self.tracker.displayLink invalidate];
self.tracker.displayLink = displayLinkMock;
OCMStub([displayLinkMock targetTimestamp])
.andReturn(firstFrameRenderTimestamp + frameBudget)
.andReturn(secondFrameRenderTimestamp + frameBudget);

// Set/Reset the previousFrameTimestamp if it has been set by a previous test.
OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp);
Expand All @@ -651,13 +679,16 @@ - (void)testSlowAndFrozenFrameIsNotRecordedInCaseOfGoodFrame {

/* Tests that the frozen frame counter is not incremented in case of a slow frame. */
- (void)testFrozenFrameIsNotRecordedInCaseOfSlowFrame {
CFTimeInterval frameBudget = FPRFrameBudgetForHz(60.0);
CFAbsoluteTime firstFrameRenderTimestamp = 1.0;
CFAbsoluteTime secondFrameRenderTimestamp =
firstFrameRenderTimestamp + kFPRSlowFrameThreshold + 0.005; // Slow frame.
CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + frameBudget + 0.005;

id displayLinkMock = OCMClassMock([CADisplayLink class]);
[self.tracker.displayLink invalidate];
self.tracker.displayLink = displayLinkMock;
OCMStub([displayLinkMock targetTimestamp])
.andReturn(firstFrameRenderTimestamp + frameBudget)
.andReturn(secondFrameRenderTimestamp + frameBudget);

// Set/Reset the previousFrameTimestamp if it has been set by a previous test.
OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp);
Expand All @@ -675,17 +706,24 @@ - (void)testFrozenFrameIsNotRecordedInCaseOfSlowFrame {
* frames.
*/
- (void)testTotalFramesAreAlwaysRecorded {
CFTimeInterval frameBudget = FPRFrameBudgetForHz(60.0);
CFTimeInterval frozenThreshold = FPRFrozenThresholdForBudget(frameBudget);
CFAbsoluteTime firstFrameRenderTimestamp = 1.0;
CFAbsoluteTime secondFrameRenderTimestamp =
firstFrameRenderTimestamp + kFPRSlowFrameThreshold - 0.005; // Good frame.
firstFrameRenderTimestamp + frameBudget - 0.005; // Good frame.
CFAbsoluteTime thirdFrameRenderTimestamp =
secondFrameRenderTimestamp + kFPRSlowFrameThreshold + 0.005; // Slow frame.
secondFrameRenderTimestamp + frameBudget + 0.005; // Slow frame.
CFAbsoluteTime fourthFrameRenderTimestamp =
thirdFrameRenderTimestamp + kFPRFrozenFrameThreshold + 0.005; // Frozen frame.
thirdFrameRenderTimestamp + frozenThreshold + 0.005; // Frozen frame.

id displayLinkMock = OCMClassMock([CADisplayLink class]);
[self.tracker.displayLink invalidate];
self.tracker.displayLink = displayLinkMock;
OCMStub([displayLinkMock targetTimestamp])
.andReturn(firstFrameRenderTimestamp + frameBudget)
.andReturn(secondFrameRenderTimestamp + frameBudget)
.andReturn(thirdFrameRenderTimestamp + frameBudget)
.andReturn(fourthFrameRenderTimestamp + frameBudget);

// Set/Reset the previousFrameTimestamp if it has been set by a previous test.
OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp);
Expand All @@ -712,21 +750,41 @@ - (void)testTotalFramesAreAlwaysRecorded {
* frozen frame counter and slow frame counter of the screen trace tracker is incremented.
*/
- (void)testFrozenFrameAndSlowFrameIsRecorded {
CFTimeInterval frameBudget = FPRFrameBudgetForHz(60.0);
CFTimeInterval frozenThreshold = FPRFrozenThresholdForBudget(frameBudget);
CFAbsoluteTime firstFrameRenderTimestamp = 1.0;
CFAbsoluteTime secondFrameRenderTimestamp =
firstFrameRenderTimestamp + kFPRFrozenFrameThreshold + 0.005; // Buffer for float comparison.
firstFrameRenderTimestamp + frozenThreshold + 0.005; // Buffer for float comparison.

id displayLinkMock = OCMClassMock([CADisplayLink class]);
[self.tracker.displayLink invalidate];
self.tracker.displayLink = displayLinkMock;

// Set/Reset the previousFrameTimestamp if it has been set by a previous test.
OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp);
// Drive CADisplayLink with matched timestamp/targetTimestamp pairs per tick.
__block NSInteger tick = 0;
NSArray<NSNumber *> *timestamps =
@[ @(firstFrameRenderTimestamp), @(secondFrameRenderTimestamp) ];
NSArray<NSNumber *> *targets =
@[ @(firstFrameRenderTimestamp + frameBudget), @(secondFrameRenderTimestamp + frameBudget) ];

OCMStub([displayLinkMock isPaused]).andReturn(NO);

OCMStub([displayLinkMock timestamp]).andDo(^(NSInvocation *inv) {
CFTimeInterval v = timestamps[(NSUInteger)tick].doubleValue;
[inv setReturnValue:&v];
});
OCMStub([displayLinkMock targetTimestamp]).andDo(^(NSInvocation *inv) {
CFTimeInterval v = targets[(NSUInteger)tick].doubleValue;
[inv setReturnValue:&v];
});

// Tick 1 - prime previous timestamp (no counts expected).
[self.tracker displayLinkStep];
int64_t initialSlowFramesCount = self.tracker.slowFramesCount;
int64_t initialFrozenFramesCount = self.tracker.frozenFramesCount;

OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp);
// Tick 2 - frozen (also slow): duration > 42 * budget.
tick++;
[self.tracker displayLinkStep];
int64_t newSlowFramesCount = self.tracker.slowFramesCount;
int64_t newFrozenFramesCount = self.tracker.frozenFramesCount;
Expand Down