保障良好性能三个层次
常用高效的工具和管理方法
刚开始我们的程序遇到性能问题时,我们会学习各种工具去分析程序性能,当性能出现明显问题时,这是最高效的解决方法,并且我们也能在这个过程中积累对代码性能的了解,这通常是点状的经验。后来我们发现性能问题还是层出不穷,于是我们开始对程序运行的核心链路做拆分,为拆分后的每个模块定义基线,数据跟踪,卡口流程。这和社会环保问题其实很像,性能出现问题时有高效的定位、解决问题的工具和能力,这相当于体育比赛后组织方有高效地清除垃圾方法和执行人员。性能基线和卡口流程相当于社会法律和执法体系,丢垃圾罚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。
|
|
例如上面这个case,只有一行的操作代码,就能达到100ms。而这个规模,在客户端的计算中是很容易出现的。所以我们要对嵌套的线性运算保持敏感,明白它的存在会有多大的风险。
被遗漏的二阶运算
即便是有控制复杂度地意识,我们很是会不小心在线性运算中嵌套线性运算,导致accidentally quadratic。要避免这种情况发生,我们需要做到一下两点,
第一:对常用系统方法的复杂度建立清晰的认知。
例如下面的Case。将对象添加到数组前使用indexOfObject进行去重判断,而indexOfObject是O(n)的,这直接导致整个操作复杂度升级到O(n^2)。
|
|
NSArray是有序的,它的查找、删除方法都需要遍历整个集合(containsObject,indexOfObject,removeObject),时间复杂度都是O(n)以上。
优化的办法是使用set,set使用了哈希值存储对象,containsObject复杂度是O(1),可以将整个操作恢复到线性。我们需要对常用集合每个方法的复杂度有清晰的认识,才能保证不会不小心写出O(n^2)以上的代码。
|
|
第二:注意方法嵌套代码,这很容易使我们忽略了整体的复杂度
在工程项目中,你的代码可能是像下面这样编写的,这就很难直观的看出有线性的嵌套。
|
|
要避免这种失误,我们得加强代码的敏感度,看到关键方法要有直觉的反应。同时编写底层方法时,对有复杂度风险的方法加上明确的注释,不要让调用方跳入泥坑。
总结
1 记住关键操作的性能公式
原始操作:消息传递:对象创建 : 键值访问 = 1 : 10 : 100 : 200
2 权衡利用C语言的速度和OC的面向对象设计,c语言的集合类型比起OC确实要快上一两个数量级。
3 尽量用对象替代动态的NSDictionary,用基础数据类型替代需要装箱的NSNumber。
4 如果有高频次的对象分配,达到了10万甚至100万的数量,可以使用预分配内存加复用的方式来加速。
5 保持敏感,除非必要不要写出O(n^2)的代码块
6 遇到必要的复杂计算或IO,合理使用多线程发挥CPU多核的计算能力,减少主线程的卡顿