Skip to content

Instantly share code, notes, and snippets.

@clementgenzmer
Last active September 18, 2023 23:02
Show Gist options
  • Save clementgenzmer/4ff6c51224089cc65e9b to your computer and use it in GitHub Desktop.
Save clementgenzmer/4ff6c51224089cc65e9b to your computer and use it in GitHub Desktop.
FBAnimationPerformanceTracker
/*
* This is an example provided by Facebook are for non-commercial testing and
* evaluation purposes only.
*
* Facebook reserves all rights not expressly granted.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
* OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL
* FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*
*
* FBAnimationPerformanceTracker
* -----------------------------------------------------------------------
*
* This class provides animation performance tracking functionality. It basically
* measures the app's frame rate during an operation, and reports this information.
*
* 1) In Foo's designated initializer, construct a tracker object
*
* 2) Add calls to -start and -stop in appropriate places, e.g. for a ScrollView
*
* - (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
* [_apTracker start];
* }
*
* - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
* {
* if (!scrollView.dragging) {
* [_apTracker stop];
* }
* }
*
* - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate {
* if (!decelerate) {
* [_apTracker stop];
* }
* }
*
* Notes
* -----
* [] The tracker operates by creating a CADisplayLink object to measure the frame rate of the display
* during start/stop interval.
*
* [] Calls to -stop that were not preceded by a matching call to -start have no effect.
*
* [] 2 calls to -start in a row will trash the data accumulated so far and not log anything.
*
*
* Configuration object for the core tracker
*
* ===============================================================================
* I highly recommend for you to use the standard configuration provided
* These are essentially here so that the computation of the metric is transparent
* and you can feel confident in what the numbers mean.
* ===============================================================================
*/
struct FBAnimationPerformanceTrackerConfig
{
// Number of frame drop that defines a "small" drop event. By default, 1.
NSInteger smallDropEventFrameNumber;
// Number of frame drop that defines a "large" drop event. By default, 4.
NSInteger largeDropEventFrameNumber;
// Number of maximum frame drops to which the drop will be trimmed down to. Currently 15.
NSInteger maxFrameDropAccount;
// If YES, will report stack traces
BOOL reportStackTraces;
};
typedef struct FBAnimationPerformanceTrackerConfig FBAnimationPerformanceTrackerConfig;
@protocol FBAnimationPerformanceTrackerDelegate <NSObject>
/**
* Core Metric
*
* You are responsible for the aggregation of these metrics (it being on the client or the server). I recommend to implement both
* to limit the payload you are sending to the server.
*
* The final recommended metric being: - SUM(duration) / SUM(smallDropEvent) aka the number of seconds between one frame drop or more
* - SUM(duration) / SUM(largeDropEvent) aka the number of seconds between four frame drops or more
*
* The first metric will tell you how smooth is your scroll view.
* The second metric will tell you how clowny your scroll view can get.
*
* Every time stop is called, this event will fire reporting the performance.
*
* NOTE on this metric:
* - It has been tested at scale on many Facebook apps.
* - It follows the curves of devices.
* - You will need about 100K calls for the number to converge.
* - It is perfectly correlated to X = Percentage of time spent at 60fps. Number of seconds between one frame drop = 1 / ( 1 - Time spent at 60 fps)
* - We report fraction of drops. 7 frame drop = 1.75 of a large frame drop if a large drop is 4 frame drop.
* This is to preserve the correlation mentionned above.
*/
- (void)reportDurationInMS:(NSInteger)duration smallDropEvent:(double)smallDropEvent largeDropEvent:(double)largeDropEvent;
/**
* Stack traces
*
* Dark magic of the animation tracker. In case of a frame drop, this will return a stack trace.
* This will NOT be reported on the main-thread, but off-main thread to save a few CPU cycles.
*
* The slide is constant value that needs to be reported with the stack for processing.
* This currently only allows for symbolication of your own image.
*
* Future work includes symbolicating all modules. I personnaly find it usually
* good enough to know the name of the module.
*
* The stack will have the following format:
* Foundation:0x123|MyApp:0x234|MyApp:0x345|
*
* The slide will have the following format:
* 0x456
*/
- (void)reportStackTrace:(NSString *)stack withSlide:(NSString *)slide;
@end
@interface FBAnimationPerformanceTracker : NSObject
- (instancetype)initWithConfig:(FBAnimationPerformanceTrackerConfig)config;
+ (FBAnimationPerformanceTrackerConfig)standardConfig;
@property (weak, nonatomic, readwrite) id<FBAnimationPerformanceTrackerDelegate> delegate;
- (void)start;
- (void)stop;
@end
#import "FBAnimationPerformanceTracker.h"
#import <dlfcn.h>
#import <map>
#import <pthread.h>
#import <QuartzCore/CADisplayLink.h>
#import <mach-o/dyld.h>
#import "execinfo.h"
#include <mach/mach_time.h>
static BOOL _signalSetup;
static pthread_t _mainThread;
static NSThread *_trackerThread;
static std::map<void *, NSString *, std::greater<void *>> _imageNames;
#ifdef __LP64__
typedef mach_header_64 fb_mach_header;
typedef segment_command_64 fb_mach_segment_command;
#define LC_SEGMENT_ARCH LC_SEGMENT_64
#else
typedef mach_header fb_mach_header;
typedef segment_command fb_mach_segment_command;
#define LC_SEGMENT_ARCH LC_SEGMENT
#endif
static volatile BOOL _scrolling;
pthread_mutex_t _scrollingMutex;
pthread_cond_t _scrollingCondVariable;
dispatch_queue_t _symbolicationQueue;
// We record at most 16 frames since I cap the number of frames dropped measured at 15.
// Past 15, something went very wrong (massive contention, priority inversion, rpc call going wrong...) .
// It will only pollute the data to get more.
static const int callstack_max_number = 16;
static int callstack_i;
static bool callstack_dirty;
static int callstack_size[callstack_max_number];
static void *callstacks[callstack_max_number][128];
uint64_t callstack_time_capture;
static void _callstack_signal_handler(int signr, siginfo_t *info, void *secret)
{
// This is run on the main thread every 16 ms or so during scroll.
// Signals are run one by one so there is no risk of concurrency of a signal
// by the same signal.
// The backtrace call is technically signal-safe on Unix-based system
// See: http://www.unix.com/man-page/all/3c/walkcontext/
// WARNING: this is signal handler, no memory allocation is safe.
// Essentially nothing is safe unless specified it is.
callstack_size[callstack_i] = backtrace(callstacks[callstack_i], 128);
callstack_i = (callstack_i + 1) & (callstack_max_number - 1); // & is a cheap modulo (only works for power of 2)
callstack_dirty = true;
}
@interface FBCallstack : NSObject
@property (nonatomic, readonly, assign) int size;
@property (nonatomic, readonly, assign) void **callstack;
- (instancetype)initWithSize:(int)size callstack:(void *)callstack;
@end
@implementation FBCallstack
- (instancetype)initWithSize:(int)size callstack:(void *)callstack
{
if (self = [super init]) {
_size = size;
_callstack = (void **)malloc(size * sizeof(void *));
memcpy(_callstack, callstack, size * sizeof(void *));
}
return self;
}
- (void)dealloc
{
free(_callstack);
}
@end
@implementation FBAnimationPerformanceTracker
{
FBAnimationPerformanceTrackerConfig _config;
BOOL _tracking;
BOOL _firstUpdate;
NSTimeInterval _previousFrameTimestamp;
CADisplayLink *_displayLink;
BOOL _prepared;
// numbers used to track the performance metrics
double _durationTotal;
double _maxFrameTime;
double _smallDrops;
double _largeDrops;
}
- (instancetype)initWithConfig:(FBAnimationPerformanceTrackerConfig)config
{
if (self = [super init]) {
// Stack trace logging is not working well in debug mode
// We don't want the data anyway. So let's bail.
#if defined(DEBUG)
config.reportStackTraces = NO;
#endif
_config = config;
if (config.reportStackTraces) {
[self _setupSignal];
}
}
return self;
}
+ (FBAnimationPerformanceTrackerConfig)standardConfig
{
FBAnimationPerformanceTrackerConfig config = {
.smallDropEventFrameNumber = 1,
.largeDropEventFrameNumber = 4,
.maxFrameDropAccount = 15,
.reportStackTraces = NO,
.reportLegacyMetrics = NO,
};
return config;
}
+ (void)_trackerLoop
{
while (true) {
// If you are confused by this part,
// Check out https://computing.llnl.gov/tutorials/pthreads/#ConditionVariables
// Lock the mutex
pthread_mutex_lock(&_scrollingMutex);
while (!_scrolling) {
// Unlock the mutex and sleep until the conditional variable is signaled
pthread_cond_wait(&_scrollingCondVariable, &_scrollingMutex);
// The conditional variable was signaled, but we need to check _scrolling
// As nothing guarantees that it is still true
}
// _scrolling is true, go ahead and capture traces for a while.
pthread_mutex_unlock(&_scrollingMutex);
// We are scrolling, yay, capture traces
while (_scrolling) {
usleep(16000);
// Here I use SIGPROF which is a signal supposed to be used for profiling
// I haven't stumbled upon any collision so far.
// There is no guarantee that it won't impact the system in unpredicted ways.
// Use wisely.
pthread_kill(_mainThread, SIGPROF);
}
}
}
- (void)_setupSignal
{
if (!_signalSetup) {
// The signal hook should be setup once and only once
_signalSetup = YES;
// I actually don't know if the main thread can die. If it does, well,
// this is not going to work.
// UPDATE 4/2015: on iOS8, it looks like the main-thread never dies, and this pointer is correct
_mainThread = pthread_self();
callstack_i = 0;
// Setup the signal
struct sigaction sa;
sigfillset(&sa.sa_mask);
sa.sa_flags = SA_SIGINFO;
sa.sa_sigaction = _callstack_signal_handler;
sigaction(SIGPROF, &sa, NULL);
pthread_mutex_init(&_scrollingMutex, NULL);
pthread_cond_init (&_scrollingCondVariable, NULL);
// Setup the signal firing loop
_trackerThread = [[NSThread alloc] initWithTarget:[self class] selector:@selector(_trackerLoop) object:nil];
// We wanna be higher priority than the main thread
// On iOS8 : this will roughly stick us at priority 61, while the main thread oscillates between 20 and 47
_trackerThread.threadPriority = 1.0;
[_trackerThread start];
_symbolicationQueue = dispatch_queue_create("com.facebook.symbolication", DISPATCH_QUEUE_SERIAL);
dispatch_async(_symbolicationQueue, ^(void) {[self _setupSymbolication];});
}
}
- (void)_setupSymbolication
{
// This extract the starting slide of every module in the app
// This is used to know which module an instruction pointer belongs to.
// These operations is NOT thread-safe according to Apple docs
// Do not call this multiple times
int images = _dyld_image_count();
for (int i = 0; i < images; i ++) {
intptr_t imageSlide = _dyld_get_image_vmaddr_slide(i);
// Here we extract the module name from the full path
// Typically it looks something like: /path/to/lib/UIKit
// And I just extract UIKit
NSString *fullName = [NSString stringWithUTF8String:_dyld_get_image_name(i)];
NSRange range = [fullName rangeOfString:@"/" options:NSBackwardsSearch];
NSUInteger startP = (range.location != NSNotFound) ? range.location + 1 : 0;
NSString *imageName = [fullName substringFromIndex:startP];
// This is parsing the mach header in order to extract the slide.
// See https://developer.apple.com/library/mac/documentation/DeveloperTools/Conceptual/MachORuntime/index.html
// For the structure of mach headers
fb_mach_header *header = (fb_mach_header*)_dyld_get_image_header(i);
if (!header) {
continue;
}
const struct load_command *cmd =
reinterpret_cast<const struct load_command *>(header + 1);
for (unsigned int c = 0; cmd && (c < header->ncmds); c++) {
if (cmd->cmd == LC_SEGMENT_ARCH) {
const fb_mach_segment_command *seg =
reinterpret_cast<const fb_mach_segment_command *>(cmd);
if (!strcmp(seg->segname, "__TEXT")) {
_imageNames[(void *)(seg->vmaddr + imageSlide)] = imageName;
break;
}
}
cmd = reinterpret_cast<struct load_command*>((char *)cmd + cmd->cmdsize);
}
}
}
- (void)dealloc
{
if (_prepared) {
[self _tearDownCADisplayLink];
}
}
#pragma mark - Tracking
- (void)start
{
if (!_tracking) {
if ([self prepare]) {
_displayLink.paused = NO;
_tracking = YES;
[self _reset];
if (_config.reportStackTraces) {
pthread_mutex_lock(&_scrollingMutex);
_scrolling = YES;
// Signal the tracker thread to start firing the signals
pthread_cond_signal(&_scrollingCondVariable);
pthread_mutex_unlock(&_scrollingMutex);
}
}
}
}
- (void)stop
{
if (_tracking) {
_tracking = NO;
_displayLink.paused = YES;
if (_durationTotal > 0) {
[_delegate reportDurationInMS:round(1000.0 * _durationTotal) smallDropEvent:_smallDrops largeDropEvent:_largeDrops];
if (_config.reportStackTraces) {
pthread_mutex_lock(&_scrollingMutex);
_scrolling = NO;
pthread_mutex_unlock(&_scrollingMutex);
}
}
}
}
- (BOOL)prepare
{
if (_prepared) {
return YES;
}
[self _setUpCADisplayLink];
_prepared = YES;
return YES;
}
- (void)_setUpCADisplayLink
{
_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(_update)];
[_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
_displayLink.paused = YES;
}
- (void)_tearDownCADisplayLink
{
[_displayLink invalidate];
_displayLink = nil;
}
- (void)_reset
{
_firstUpdate = YES;
_previousFrameTimestamp = 0.0;
_durationTotal = 0;
_maxFrameTime = 0;
_largeDrops = 0;
_smallDrops = 0;
_histogram = FBAnimationFrameTimeHistogramZero;
}
- (void)_addFrameTime:(NSTimeInterval)actualFrameTime singleFrameTime:(NSTimeInterval)singleFrameTime
{
_maxFrameTime = MAX(actualFrameTime, _maxFrameTime);
NSInteger frameDropped = round(actualFrameTime / singleFrameTime) - 1;
frameDropped = MAX(frameDropped, 0);
// This is to reduce noise. Massive frame drops will just add noise to your data.
frameDropped = MIN(_config.maxFrameDropAccount, frameDropped);
_durationTotal += (frameDropped + 1) * singleFrameTime;
// We account 2 frame drops as 2 small events. This way the metric correlates perfectly with Time at X fps.
_smallDrops += (frameDropped >= _config.smallDropEventFrameNumber) ? ((double) frameDropped) / (double)_config.smallDropEventFrameNumber : 0.0;
_largeDrops += (frameDropped >= _config.largeDropEventFrameNumber) ? ((double) frameDropped) / (double)_config.largeDropEventFrameNumber : 0.0;
if (frameDropped >= 1) {
if (_config.reportStackTraces) {
callstack_dirty = false;
for (int ci = 0; ci <= frameDropped ; ci ++) {
// This is computing the previous indexes
// callstack - 1 - ci takes us back ci frames
// I want a positive number so I add callstack_max_number
// And then just modulo it, with & (callstack_max_number - 1)
int callstackPreviousIndex = ((callstack_i - 1 - ci) + callstack_max_number) & (callstack_max_number - 1);
FBCallstack *callstackCopy = [[FBCallstack alloc] initWithSize:callstack_size[callstackPreviousIndex] callstack:callstacks[callstackPreviousIndex]];
// Check that in between the beginning and the end of the copy the signal did not fire
if (!callstack_dirty) {
// The copy has been made. We are now fine, let's punt the rest off main-thread.
__weak FBAnimationPerformanceTracker *weakSelf = self;
dispatch_async(_symbolicationQueue, ^(void) {
[weakSelf _reportStackTrace:callstackCopy];
});
}
}
}
}
}
- (void)_update
{
if (!_tracking) {
return;
}
if (_firstUpdate) {
_firstUpdate = NO;
_previousFrameTimestamp = _displayLink.timestamp;
return;
}
NSTimeInterval currentTimestamp = _displayLink.timestamp;
NSTimeInterval frameTime = currentTimestamp - _previousFrameTimestamp;
[self _addFrameTime:frameTime singleFrameTime:_displayLink.duration];
_previousFrameTimestamp = currentTimestamp;
}
- (void)_reportStackTrace:(FBCallstack *)callstack
{
static NSString *slide;
static dispatch_once_t slide_predicate;
dispatch_once(&slide_predicate, ^{
slide = [NSString stringWithFormat:@"%p", (void *)_dyld_get_image_header(0)];
});
@autoreleasepool {
NSMutableString *stack = [NSMutableString string];
for (int j = 2; j < callstack.size; j ++) {
void *instructionPointer = callstack.callstack[j];
auto it = _imageNames.lower_bound(instructionPointer);
NSString *imageName = (it != _imageNames.end()) ? it->second : @"???";
[stack appendString:imageName];
[stack appendString:@":"];
[stack appendString:[NSString stringWithFormat:@"%p", instructionPointer]];
[stack appendString:@"|"];
}
[_delegate reportStackTrace:stack withSlide:slide];
}
}
@end
@bsjcho
Copy link

bsjcho commented May 21, 2016

@tettoffensive did you ever solve your issue? i'm encountering the same.

@neils4fun
Copy link

i had success getting this to build by changing the objc class that used it from .m to .mm. I had some other issues using the code in this gist after that:

  1. FBAnimationPerformanceTrackerConfig struct was missing a member for reportLegacyMetrics
  2. _histogram and FBAnimationFrameTimeHistogramZero were not defined (see _reset method)

For now I commented that out of the _reset function just to get it to build. After building I'm still struggling to figure out how to assemble the results into something meaningful, but I thought I'd post this here in case it helps others.

If anyone has a sample project that uses this to measure their own scrolling behavior I'd love to see it to get a better idea of how to apply this to my situation. I have complex cells that I'm having trouble getting to scroll smoothly. My issues appear to be related to using NSAttributedStrings in text views in the tableview cells.

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