优化高性能JS代码的几个要点,以及背后的原理
WEB前端开发社区 昨天
写在前面
作为一个前端开发人员,我一直难以理解学习计算基础知识的重要性,这是因为我难以想象这些知识会以何种方式应用到前端开发的工作上。
然而在csapp 优化程序性能 这一节,彻底的改变了我的想法,作者讲述了对于一个正确,良好编写的c语言程序,如何优化它的性能。
对一段相同功能的代码,优化后与优化前产生了巨大的差别,速度得到了几十倍的提升。然而,令我困惑的是,js与c这两种在语言层次上相差如此之大的语言,这样的优化是否有同样的效果?经过实践,答案是yes。
因此计算机基础知识并不是空中楼阁,它确实有用,让你写出更好的程序。为什么大厂喜欢考察基础,因为这些基础确实在某些方面体现了一个人编程的水平的高低,对于计算机科班人员来说,这也恰恰是可以和别人拉开差距的地方。
引子
先举个c语言的例子,以下这段代码的功能是 将字符串中的大写字母转换成小写
void lower(char *s) {size_t i;for (i = 0; i < strlen(s); i++) {if (s[i] >= 'A' && s[i] <= 'Z') s[i] -= ('A' - 'a'); }}
你能看出这段代码的问题在哪里吗?
如果你足够敏感,结合小标题的暗示,你会发现问题出现在for循环的判断条件中 strlen()函数,首先指出一点,本段代码在功能的正确性上毋庸置疑,但是性能上就堪忧了。
这里给不了解strlen函数的人一个提示:strlen函数是通过遍历字符串来得到其长度的,所以每一次for循环,都会遍历一次字符串s,因此这段代码的时间复杂度是o(n^2),但实际上我们只是在修改s的每个值,但不会移除或增加某个值,我们并不需要每次都计算s的长度。
你可能会疑问编译器难道识别不出这种模式,然后针对优化吗?答案是否定的,简单的讲是因为编译器无法确定是否存在 副作用 导致判断条件发生变化,这其中还有 内存别名使用 的原因,这里就不详解了,感兴趣的可以去看书。
当然针对这段代码的优化很简单,通过一个临时变量,在循环前事先计算调用一次strlen即可,然后使用那个变量当作判断条件。值得注意的是:仅仅这样一个简单的优化,时间复杂度就从o(n^2)变成o(n)了,这是巨大的提升。
有经验的编程者可能认为,这段代码的问题c语言上独有的,因为大部分高级语言的字符串的长度,是被语言本身作为一个常量维护的,不需要遍历就能得到。实际上,这个例子只是给你一个提醒,微小的变动,可能导致性能的巨大下降或提升
接下来,我们以 combine 函数为例 对一个对数组进行求和,看看一段功能相同的代码,经过优化后,其性能的差距。
combine1函数在for循环判断中调用 getLengthFromArray 得到数组长度,比起strlen,这个函数的时间复杂度是o(1),很可能有人会问?为什么要脱了裤子放屁,为什么不直接用 .length 属性,这里有以下几个考量:
模拟其他语言的行为
可以动态的计算数组的长度
提供错误检查
即使是o(1)的调用,也会有性能上的影响
至于为什么要用 getFromArray 函数获取数组元素,因为js的数组没有越界检查(越界访问不会报错),可能会发生程序运行了很长时间后,错误才显现出来。所以getFromArray的目的是提供越界检查,当越界时抛出错误。
版本1:
// 本代码可直接运行
function getFromArray(arr, index) { // 提供越界检查的数组getter
if (index < 0 || index >= arr.length) throw new Error('OUT OF INDEX' + index);
return arr[index]
}
function getLengthFromArray(arr) { // 得到数组长度
if (arr == null) throw new Error(arr + 'is not an Array.')
return arr.length; // 即使是常数级的函数调用,依旧产生开销
}
void function() {
console.log('start');
const numberOfElement = 9999999;
const arr = Array(numberOfElement).fill(2);
(function combine1() {
console.time('combine1');
let sum = 0;
for (let i = 0; i < getLengthFromArray(arr); i++) { // 每次循环都要重新计算长度
sum += getFromArray(arr, i); // 使用提供越界检查getter得到数组元素
}
console.log('sum', sum);
console.timeEnd('combine1');
}());
console.log('end');
}();
消除循环的低效率
函数combine1在 每一个for循环,都会调用getLengthFromArray作为循环的测试条件,根据我们函数的功能来看,我们只是访问数组中的每一个元素,并不会修改数组的长度,因此产生了大量的无效计算(调用),所以需要优化。
(function combine2() {// 可直接执行console.time('combine2');let sum = 0;const len = getLengthFromArray(arr); // 消除每次循环对数组长度的计算for (let i = 0; i < len; i++) { sum += getFromArray(arr, i); }console.log('sum', sum);console.timeEnd('combine2');}());
combine2 通过我个人电脑的测试,在消除for循环判断条件中的无用计算后,combine2确实比combine2要快个10ms左右,可能你觉得这点时间不算什么。
但是:无论这个优化的提升是否巨大,它都是低效率的一种来源,需要被消除,否则这有可能成为进一步优化时的瓶颈。
减少多余的函数调用
通过分析combine2函数,我们可以发现,getFromArray函数的提供的越界检查似乎是不必要的,如果我们正确的设置循环的终止条件。
对于每一次访问,getFromArray都会做判断,这种判断是不必要的,且每一次函数调用也是一种开销。
(function combine3() {/** * 现在消除了每次循环对数组长度的计算 * 并且确定访问不会越界,不需要越界检查,直接访问数组 */console.time('combine3');let sum = 0;const len = getLengthFromArray(arr);for (let i = 0; i < len; i++) { sum += arr[i]; // 直接访问 }console.log('sum', sum);console.timeEnd('combine3');}());
经过实践,combine3对比combine1性能几乎提升了一倍多,所以从性能上看,这种优化显然是需要的。
但是值得争论的一点在于,你如果把 arr 看作一个别人提供的数据结构,getFromArray作为这个数据的结构的getter,我们作为用户,不应该对arr的底层实现有任何的假设,我们不知道它的底层是数组还是链表,因此它违背了 黑盒原则,提高了程序的耦合性。
所以在程序优化时,你可能需要平衡好 高性能高耦合 或 低性能低内聚 的抉择
消除不必要的内存访问
为了理解这个优化点,你首先得明白:
1)对于一个函数,其内部变量一般存储在寄存器中,你可以把寄存器看成一个CPU和内存之间的一个缓存
2)程序访问寄存器的速度远快于访问内存
3)对于值为数组的变量,其在寄存器中的值是数组在内存中的地址,所以更改或访问其数据需要访问内存
在明确这几点以后,让我们来看一段负优化后的代码:
(function combine4() {let sum = [0]; // 负优化在这里,sum现在是个指针了console.time('combine4');const len = getLengthFromArray(arr);for (let i = 0; i < len; i++) { sum[0] += arr[i]; // 三次内存访问 }console.log('sum', sum[0]);console.timeEnd('combine4');}());
combine4与combine3唯一不同点在于,sum变成一个引用类型的变量,其引用一个存储在内存中的,长度为1的数组,此时我们分析下对于 sum[0] += arr[i] 要访问几次内存:
读取sum[0]
读取arr[i]
写回sum[0] 两次读,一次写,总共三次
为了得出更令人信服的结论,这里提供combine4的另一个正优化后版本进行对比。
(function combine4_anothor() {let sum = [0];let tmp = 0; // 通过设计临时变量,减少内存访问次数console.time('combine4_anothor');const len = getLengthFromArray(arr);for (let i = 0; i < len; i++) { tmp += arr[i]; // 一次内存访问 } sum[0] = tmp;console.log('sum', sum[0]);console.timeEnd('combine4_anothor');}());
现在我们分析下combine4_anothor在每一次for循环访问内存的次数,只有一次读arr[i]的操作,对比原来的combine4减少了两次的内存访问,比起combine4的每次循环写入内存,combine4 anothor选择在最后写入内存。实际上这个操作性能的提升也是巨大的,这里我给出自己电脑的上结果:
combine3: 15.298ms
combine4: 28.701ms
combine4_anothor: 17.618ms
本节核心在于:通过设置临时变量,复用变量,减少不必要的内存访问
这节中,你可能会疑惑:为什么内存速度会很慢?寄存器和内存什么关系?为什么局部变量存储在寄存器上?怎么做到的?同样的,这些问题在《CSAPP》都有解答。
使用循环展开
(function combine5() {/** * 利用循环展开 */let sum = 0; console.time('combine5');const len = getLengthFromArray(arr);const limit = len - 1; //防止越界let i;for (i = 0; i < limit; i += 2) { // 步长为2的展开 sum += arr[i]; // 直接访问 sum += arr[i + 1]; }for (; i < len; i++) { //处理剩余未访问的元素 sum += arr[i]; }console.log('sum', sum);console.timeEnd('combine5');}());
循环展开是怎么提升这段代码性能的?原因在于他消除了部分调用for循环的开销,这个特定了例子里,他减少了大概一半的for循环次数,因此也就减少一半的判断次数,所以性能会得到提升。
然而令人意外的时:但是当我在机器上实践的时候,采用步长为2的展开时,combine5并没有比combine4 anothor跑的快,相反还要慢一点,但当我逐渐增高循环展开的步数后,时间才逐渐逼近combine4 anothor,最后大概一致,这是为什么呢?
首先刚才提了,循环性能提升的原因在于减少for循环中判断的次数,当我们采用步长为1的循环时,现代的编译器可以识别出这种常用的循环模式,从而直接进行类似循环展开的优化,因此当我们提高循环展开的步长时,其实是在手动进行这种优化。
但值得注意的是,对于这段简单的代码,编译器可以识别出来,但是复杂点的,就不一定了,所以掌握这种展开的技术是必要的,并且这种通过循环展开优化后的 性能瓶颈来源 很值得我们思考。
再次分析性能瓶颈
现在我们分析下combine5性能的限制,或者说这个函数的运行时间主要依赖于哪个因素?
我们可以通过循环展开减少循环次数,从而节省每次循环后比较的开销,但是 sum += arr[i] 的开销没法省,无论怎么优化 我们都至少要访问arr数组 length次数,且计算机对于 sum += arr[i] 执行必须是按序的,无论是否采用循环展开,因为每一次sum的计算值都依赖于上一次的sum的计算值。
所以combine5函数的主要运行时间源于 sum += arr[i],如果arr数组长为100,计算机每次执行 sum += arr[i] 需要花费1s,那么combine5 无论怎么优化,每次执行都至少要花费100s。
这里,我们要引入一点现代CPU的知识,大家认为代码(指令)按序执行,实际上展示的效果也确实是这样的,但是在底层,cpu其实是乱序执行代码的。通过对指令的分析再排序,cpu可以并发、甚至并行的执行代码(通过利用多核),至于这样为什么正确?
考虑以下这段代码
let a = 1 + 1;let b = 2 + 2;let c = a + b;let d = c + c;
a,b是可以并行计算,因为它们并不相互依赖,而c就必须等到a,b执行完才能运行,d必须等到c后才能执行,所以c,d无法并行,只能按序执行。
现在我们再回顾下制约combine5性能的原因:
1)我们都至少要访问arr数组 length次数
2)计算机对于 sum += arr[i] 执行必须是按序的,无论是否采用循环展开,因为每一次计算sum都依赖于上一次sum的计算值
1)是无法避免的,但我们是否有机会对2)作出改进,考虑以下代码
提高代码并行性
(function combine5_another() {/** * 提高并行性 */console.time('combine5_another');let sum = 0; let tmp1 = 0;const len = getLengthFromArray(arr);const limit = len - 1; //防止循环展开后越界let i;for (i = 0; i < limit; i += 2) { sum += arr[i]; // 直接访问 tmp1 += arr[i + 1]; }for (; i < len; i++) { //处理剩余未访问的元素 sum += arr[i]; } sum += tmp2;console.log('sum', sum);console.timeEnd('combine5_another');}());
combine5_another通过设置一个临时变量tmp1来计算数组arr[2n - 1]的累计和,sum计算arr[2n - 2]的累计和,这使得cpu可以并行的计算tmp1和sum,因为tmp1的计算不依赖sum,反之亦然。
最后,循环结束后再合并结果,理论上100s的执行时间,就减少到50s了。
不过,在我实践时,并行优化并没有带来性能上的提升,甚至下降了,可能的原因有两个,一个是干扰,而另一个原因则非常重要。
这里先谈干扰,这是因为:由于并行计算使用循环展开,导致编译器无法识别其循环模式,进行优化。
另一个重要的原因就是 循环展开 和 提高并行性 两种优化是依赖于底层硬件的,就拿后者来说,cpu之所能进行并行计算,是因为底层设置了冗余计算功能单元,而单元的数量限制了同一时间并行计算的次数**,对于相同优化后的代码,在不同cpu上运行,其性能提升不一定,甚至可能会下降**。
所以这两种优化需要针对具体机器,具体分析,但在原则上是通用的。
同样的,《CSAPP》详细讲解了这两项优化背后的原理以及原因,有兴趣的可以去看书。
最后提供下未优化和优化后版本的对比:
优化前: 47.267ms
优化后: 12.731ms
大概提升了70%的性能
利用程序的局部性
本节重点是:你写的程序需要有良好的局部性
让我们先明确一个事实:操作系统会缓存经常使用的数据,通过将数据存储在内存与cpu之间的 高速缓存 中,即当一个程序请求一项数据时,操作系统会先去高速缓存中找,再去内存中找(虽然内存的传输速度比硬盘快的多,但比起cpu的处理速度,还是很慢,因此在与cpu中间又加了一层高速缓存),再去硬盘中找,这样一级级的向下找。找到就直接返回,且每一级的找寻速度越来越慢。
其次,通俗的讲,程序的局部性就是:刚刚 被访问的 数据 或 执行的 代码 其本身或其临近的数据(代码) 很可能会被(再次)访问(执行)。
其中局部性又分为:
时间局部性(已经被访问的数据(代码),很可能会被再次访问)
空间局部性(对于已经访问的数据(代码),其临近的数据(代码)很可能被访问)
举个例子:以下这段代码对一个10*10的矩阵进行求和。
const matrix = getMatrix(10, 10);let sum = 0;for (let row = 0; row < matrix.length; row++) {for (let col = 0; col < matrix[0].length; col++) { sum += matrix[row][col]; }}
对于变量sum,其很好的体现了时间局部性,在第一次访问后,以后每一次循环都会再次访问它,所以操作系统在第一次访问将它加入高速缓存后,每一次都sum的访问都会直接去高速缓存中去取,而非内存,从而减少了每次访问的内存的时间消耗。
对于martix[row][col]的访问,其体现了空间局部性,为了明白这点,你得先理解 二维数组在内存的存储方式,二维数组在内存中是以 行优先 存储,举个例子对于一个10*10的二维数组arr,假设其开始的内存地址是0,那么其每个元素对应的地址位:
arr[0][0] -> 内存地址 0
arr[0][1] -> 内存地址 1
....
arr[0][9] -> 内存地址 9
arr[1][0] -> 内存地址 10
arr[1][1] -> 内存地址 11
...
arr[9][0] -> 内存地址 90
arr[9][1] -> 内存地址 91
...
arr[9][9] -> 内存地址 99
即,如果我们以 先行再列(就是上面代码的方式) 的方式遍历,那么我们遍历的内存地址顺序就是一种线性连续递增顺序的方式:内存地址 0, 1, 2, ..., 50, 51, 52, ..., 90, 91, 92, ..., 99
这就体现了一种空间局部性,当我们访问arr[0][0](内存地址 0)时,操作系统根据 空间局部性原理 假设我们很可能会接着访问 arr[0][1],于是就将其装入高速缓存,当我们真的访问的时,就不需要经历等待取内存的时间,而是直接从高速缓存中拿到了。
作为对比我们介绍一个坏例子。
let sum = 0;for (let col = 0; col < matrix[0].length; col++) {for (let row = 0; row < matrix.length; row++) { sum += matrix[row][col]; }}
这段代码与上面那段代码的 功能完全一样,唯一不同的是其遍历的方式变成了先列再行,为了给你个直观的理解,此时遍历内存的地址顺序是: 0, 10, 20, ..., 80, 90, 1, 11, 21, ..., 81, 91, 2, 12, 22, ...。
比起刚才的连续递增遍历,这样的遍历多了些许 跳跃性。这样问题在于,它没法利用 系统以局部性为前提提供的缓存机制,当你访问arr[0][0]时,系统把arr[0][1]加入缓存,然后你直接跳到了arr[1][0],缓存没有命中,就需要经历等待取内存的时间,当这种操作大量的累积起来时,就会造成可观,低下的程序性能。
这里给你一个不严谨,但直观的矩阵求和示例代码结果:
startsum 99980001良好的局部性: 159.879mssum 99980001坏的局部性: 3420.815msend
void function() {
console.log('start');
const size = 9999;
const matrix = Array(size);
for (let i = 0; i < matrix.length; i++) {
matrix[i] = Array(size).fill(1);
}
(function goodVersion() {
console.time('良好的局部性');
let sum = 0;
for (let row = 0; row < matrix.length; row++) {
for (let col = 0; col < matrix[0].length; col++) {
sum += matrix[row][col];
}
}
console.log('sum', sum);
console.timeEnd('良好的局部性');
})();
(function worseVersion() {
console.time('坏的局部性');
let sum = 0;
for (let col = 0; col < matrix[0].length; col++) {
for (let row = 0; row < matrix.length; row++) {
sum += matrix[row][col];
}
}
console.log('sum', sum);
console.timeEnd('坏的局部性');
})();
console.log('end');
}();
再强调下重点是:你写的程序在保证正确的前提下,需要有良好的局部性
小结
碍于文章篇幅限制,本文对高性能代码背后原理的解释对于读者来说只能算是一种 浅尝辄止,有很多的东西没谈,一些优化牵扯计算机各个层次的知识,比如对于程序局部性原理的利用仅仅停留在宏观层面上,只是定性的分析,但对于局部性原理来说,其实质上是基于一套坚实的理论,依赖于计算机各个层次工作的良好分工,并且这种优化是可以定量分析的。