iOS性能解读——把时间当作朋友

保障良好性能三个层次

常用高效的工具和管理方法

刚开始我们的程序遇到性能问题时,我们会学习各种工具去分析程序性能,当性能出现明显问题时,这是最高效的解决方法,并且我们也能在这个过程中积累对代码性能的了解,这通常是点状的经验。后来我们发现性能问题还是层出不穷,于是我们开始对程序运行的核心链路做拆分,为拆分后的每个模块定义基线,数据跟踪,卡口流程。这和社会环保问题其实很像,性能出现问题时有高效的定位、解决问题的工具和能力,这相当于体育比赛后组织方有高效地清除垃圾方法和执行人员。性能基线和卡口流程相当于社会法律和执法体系,丢垃圾罚500、吐痰罚200.

了解本质是养成意识的前提

这通常都是最有效的管理方法,但却不是万能和高效的办法,通常只能在核心的业务做这种投入。建设管理设施和执行维护的成本非常高,就如识别谁丢了垃圾的成本就很高,而且很多时候你发现一片污染时,它可能是许多人一起作用的结果。要本质上去改善软件的性能,需要编写代码的人有良好的性能意识和习惯。就如新闻上常说的,日本的观众看完比赛,留下来的那片区域都非常收拾得很干净。想要真正形成意识、养成习惯,只喊口号和宣导是一种办法,但还不够。我们需要回归到本质,让大家真正认知到环保的好处,我们要把问题量化帮助大家理解, 让大家知道一套卖家的塑料饭盒具体处理的成本有多少。

对一砖一瓦要有明确认知

要形成具体地认知,我们得把性能问题拆解成基本元素,随着规模地上升,程序模块拆分越来越细、流程越来越交错,性能问题具体是哪个部件很容易被掩盖。幸好,程序再庞大也是一砖一瓦搭建出来的,归根结底是0和1两个数字的问题。当然0和1组合的可能性太多,超过了人脑的记忆能力。再上一层到了汇编,汇编对于稳定性问题来说是最确定性的协议,但对性能来说组合的复杂度还是过高了。
那再往上一层找,就到了高级语言最基础的运算和标准接口,这在iOS程序里是OC语言、C语言的基础操作。

保障良好性能三个层次

基础操作解析

下面首先基于OC和C语言的基本操作在iPhone6Plus设备的性能表现,弄清楚关键的性能代码的成本,再总结如何编写良好性能的代码。

测试用例:

复杂度为O(n^2),n在500到5000之间。

测试设备:

iPhone6Plus
1.85 GHz
Cores 2[7]
L1 cache Per core: 64 KB instruction + 64 KB data
L2 cache 3 MB shared[7]
L3 cache 4 MB shared[8]

算术运算

操作 Integer Arithmetic (n=5000) 平均操作时间(纳秒)
{} 3.316
{} 3.304
{} 3.291
k++ 0.185
k = i 0.397
k = i + j 0.598
k = i - j 0.655
k = i * j 0.685
k = i / j 0.736
k = i % j 0.524
k = i & j 0.763
k = i 或 j 0.716
k = i ^ j 0.407
k = ~i 0.139
k = i >> 2 0.189
k = i << 2 0.088

上图显示,每个空循环就需要3ns,这里的操作成本来自两层循环里变量自增、边界判断的消耗。因此,后面每项目操作测量的时间都减去了这个空方法的成本。

位运算毫无疑问是最快,只需要0.1ns左右。变量自增的成本比较低,算术运算差距都不大,其中除法运算和取模运算只比乘法要慢一点,这个有点不符合常识。在MacPro i7的模拟器上除法运算比乘法要慢5~10倍,应该是A9芯片对除法做了优化。

操作 Floating Point Arithmetic (n=5000) 平均操作时间(纳秒)
fj=j; 0.850
fj=j; fk = fi + fj; 1.037
fj=j; fk = fi - fj; 1.073
fj=j; fk = fi * fj; 1.049
fj=j; fk = fi / fj; 1.017

浮点变量的运算和整型相比差距不大。

操作 NSNumber Arithmetic (n=5000) 平均操作时间(纳秒)
numberJ = @(numberI.intValue + j) 13.717
numberJ = @(numberI.intValue - j) 13.405
numberJ = @(numberI.intValue * j) 13.377
numberJ = @(numberI.intValue / j) 13.465
numberJ = @(numberI.intValue % j) 14.792
numberJ = @(numberI.intValue & j) 13.401
numberJ = @(numberI.intValue 或 j) 13.436

NSNumber的算术运算比基础数据类型要慢了1个数量级,达到了10ns,装箱和拆箱的成本还是很高的,所以要尽量避免NSNumber的使用。

关系运算

操作 Comparisons (n=5000)| 平均操作时间(纳秒) |
|———————————|——|
| if (i < j) | 0.649 |
| if (i < j) k++ | 0.555 |
| if (x[i] < x[j]) k++ | 0.803 |

操作 Array Comparisons and Swaps (n=5000) 平均操作时间(纳秒)
k = (x[i]<x[k]) ? -1:1 6.663
k = intcmp(x+i, x+j) 1.532
swapmac(i, j) 1.692
swapfunc(i, j) 2.425

操作 Max Function, Macro and Inline (n=5000) | 平均操作时间(纳秒) |
|———————————|——|
| k = (i > j) ? i : j | 0.910 |
| k = maxmac(i, j) | 0.851 |
| k = maxfunc(i, j) | 1.029 |

比较和算术运算成本差不多,使用for(i..n)每次循环都要判断一次。同样的交换逻辑,使用宏展开比使用函数要快800us。因此,对于一些简单高频的计算,可以使用宏进行展开优化。

数学函数

操作 Math Functions (n=1000) 平均操作时间(纳秒)
fk = j+fi; 1.725
k = rand(); 7.700
fk = sqrt(j+fi) 1.965
fk = sin(j+fi) 13.524
fk = sinh(j+fi) 5.069
fk = asin(j+fi) 2.333
fk = cos(j+fi) 13.948
fk = tan(j+fi) 18.347

数学函数里开平方根还是很快的,而随机数和三角函数都上了一个数量级。

集合操作

操作 NSMutableArray Handle (n=1000) 平均操作时间(纳秒)
[array addObject:@”a”] 31.316
[array objectAtIndex:0] 4.708
[array containsObject:@”a”] 17.681
[array removeObject:@”a”] 65.235

NSMutableArray的操作明显比数组存取要耗时很多,如果是性能关键代码是普通C数组的,但是这样扩展性会比较差,需要自己管理引用计数,也不能享受NSMutableArray排序、搜索等便捷方法,所以一般情况还是建议使用NSMutableArray。

操作 String Handle (n=500) 平均操作时间(纳秒)
[string isEqualToString:@”b”] 114.632
[string stringByAppendingString:@”b”] 689.016
[string substringFromIndex:0] 165.271
[string compare:@”b”] 138.748
strcmp(str1, str2) - 2.029
strcpy(str1, str2) 13.995
strncpy(dest, str2, 1) 14.412

NString的的操作耗时比C字符串也要高1两个数量级,但是涉及可读文本是这些开销是值得的,否则你得自己处理Unicode,这本身就很复杂而且也很耗性能。当然如果只是用做功能字符串,完全可以用C字符串替代。

操作 NSMutableDictionary Handle (n=500) 平均操作时间(纳秒)
[dic setObject:@”a” forKey:@”b”] 98.359
[dic objectForKey:@”a”] 198.459
obj.a = @”b” 12.444
NSString *b = obj.a 7.948

NSMutableDictionary的键值访问比起对象的赋值、取值耗时要高1个数量级,1万次操作就达到1ms,并且如果有数据类型要存储,字典必须用NSNumber,NSNumber的操作本身就性能很差。
字典在项目中使用频率是较高的,在不需要动态性的高频场景应该使用对象替代NSMutableDictionary。一般网络请求json格式回来我们都会先通过NSJSONSerialization转成NSDictionary。业务数据量大的时候,成本显然不小。

OC Core Method (n=500) 操作 平均操作时间(纳秒)
[self emptyMethod] 4.951
[obj autorelease] 6.020

一个空的OC消息发送是算术运算的3倍,虽然实际项目中消息数量很容易爆炸,但只要不是千万级别以上的数量,也不会有性能的隐患。

内存分配和读取

操作 Memory Allocation (n=500) 平均操作时间(纳秒)
free(malloc(16)); 57.301
free(malloc(100)); 60.835
free(maloc(2000)); 130.475
[NSObject alloc] 49.398

堆内存的分配是消息发送的50倍,比算术运算要高2个数量级以上。一次性多分配更多内存比多次分配小内存要快很多,因为单次操作主存成本很高。(PS:在X86的模拟器里,对象创建成本是键值访问的两倍,但是在iPhone6Plus的表现,键值访问要反而慢两倍。)
这是一个重要的优化点,在性能关键代码里,高频对象可以提前做内存预分配和复用,以优化运行的速度。

  • A8 (iPhone6)
数据集大小 Stride 平均单次读取耗时 ns
64KB 1 0.114195
64KB 64 0.115699
64KB 5678 0.163618
1MB 1 0.116082
1MB 64 0.153635
1MB 5678 0.186276
4MB 1 0.114724
4MB 64 0.153919
4MB 5678 0.324870
200MB 1 0.216536
200MB 64 2.808878
200MB 5678 9.092147
  • A9 (iPhone6S Plus)
数据集大小 Stride 平均单次读取耗时 ns
64KB 1 0.030707
64KB 64 0.033846
64KB 5678 0.053130
3MB 1 0.030772
3MB 64 0.035724
3MB 5678 0.062342
4MB 1 0.031756
4MB 64 0.038277
4MB 5678 0.092065
200MB 1 0.034641
200MB 64 0.106000
200MB 5678 0.404821

L1、L2、L3缓存的数据集,顺序读取和跨缓存行读取性能基本相同,随机读取会慢50%~200%。由此看出,数据集比较集中并且在高速缓存大小范围内,访问的模式影响不太大,速度比较稳定。
当数据集超过L3时,访问到主存的概率会越来越大,这个时候访问的模式就有较大的差异。顺序访问时大部分的操作还是可以命中缓存行,耗时和前面差不多。步伐大于缓存行后,性能开始明显变差。在A8处理器下,最差到了顺序访问的50倍以上。A9处理器优化很明显,最差也就是顺序访问的10倍.

复杂度的陷阱

性能瓶颈离我们并不远

时间复杂度的几种规模,相信大家都很熟悉了。但我们要明确,什么样的复杂度在OC的世界里是有风险的。

下面这个表格可以直观看出各种复杂度的差距

运行时间(微秒) n^3 n^2 nLog2^n n Log2^n
10 1ms 100us 30us 10us 3.3us
当n为右侧规模时所需要的运算时间 10^2 1s 10ms 660us 100us 6.6us
10^3 16分钟 1s 10ms 1ms 9.9us
10^4 69天 1分钟 40秒 132ms 10ms 13.2us

假设我们编写的代码块里有NSDictionary的键值操作或者字符串拼接操作,操作耗时很容易到了微秒级别。

此时如果你方法的复杂度到了O(n^2),当n到了10^2时,整个运算就超过10ms,n到了10^3时,运算时间会到1s。

1
2
3
4
5
6
7
8
int n = 1000;
NSMutableDictionary *dic = [NSMutableDictionary new];
for (int i = 0; i < n; i ++) {
for (int j = 0; j < n; j ++) {
[dic setObject:@(j) forKey:@(i)];
}
}

例如上面这个case,只有一行的操作代码,就能达到100ms。而这个规模,在客户端的计算中是很容易出现的。所以我们要对嵌套的线性运算保持敏感,明白它的存在会有多大的风险。

被遗漏的二阶运算

即便是有控制复杂度地意识,我们很是会不小心在线性运算中嵌套线性运算,导致accidentally quadratic。要避免这种情况发生,我们需要做到一下两点,

第一:对常用系统方法的复杂度建立清晰的认知。

例如下面的Case。将对象添加到数组前使用indexOfObject进行去重判断,而indexOfObject是O(n)的,这直接导致整个操作复杂度升级到O(n^2)。

1
2
3
4
5
6
7
- (void)addObjects:(NSArray *)objects {
for (id object in objects) {
if (NSNotFound != [array indexOfObject:object]) {
[array addObject:object];
}
}
}

NSArray是有序的,它的查找、删除方法都需要遍历整个集合(containsObject,indexOfObject,removeObject),时间复杂度都是O(n)以上。

优化的办法是使用set,set使用了哈希值存储对象,containsObject复杂度是O(1),可以将整个操作恢复到线性。我们需要对常用集合每个方法的复杂度有清晰的认识,才能保证不会不小心写出O(n^2)以上的代码。

1
2
3
4
5
6
7
8
- (void)addObjects:(NSArray *)objects {
NSSet *set = [NSSet setWithArray:array];
for (id object in objects) {
if (![set containsObject:object]) {
[array addObject:object];
}
}
}

第二:注意方法嵌套代码,这很容易使我们忽略了整体的复杂度

在工程项目中,你的代码可能是像下面这样编写的,这就很难直观的看出有线性的嵌套。

1
2
3
4
5
6
7
8
9
10
11
- (void)addObjects:(NSArray *)objects {
for (id object in objects) {
[self addAbsentObject:object];
}
}
- (void)addAbsentObject:(id)object {
if (NSNotFound != [array indexOfObject:object]) {
[array addObject:object];
}
}

要避免这种失误,我们得加强代码的敏感度,看到关键方法要有直觉的反应。同时编写底层方法时,对有复杂度风险的方法加上明确的注释,不要让调用方跳入泥坑。

总结

1 记住关键操作的性能公式
原始操作:消息传递:对象创建 : 键值访问 = 1 : 10 : 100 : 200

2 权衡利用C语言的速度和OC的面向对象设计,c语言的集合类型比起OC确实要快上一两个数量级。

3 尽量用对象替代动态的NSDictionary,用基础数据类型替代需要装箱的NSNumber。

4 如果有高频次的对象分配,达到了10万甚至100万的数量,可以使用预分配内存加复用的方式来加速。

5 保持敏感,除非必要不要写出O(n^2)的代码块

6 遇到必要的复杂计算或IO,合理使用多线程发挥CPU多核的计算能力,减少主线程的卡顿

Blacktea wechat
ex. subscribe to my blog by scanning my public wechat account
记录生活于感悟,您的支持将鼓励我继续创作!