前端:如何让你的CPU和Memory更好的工作

27 阅读15分钟

背景

因为技术的战略规划,公司自研的小程序框架已经成熟。因此需要将过往的RN技术栈的业务开始向小程序的技术栈进行迁移。其中,扫地机机器人小程序就是其中的一环。扫地机的业务向来以高复杂度和高入门门槛著称。因此当从RN技术栈转向小程序时,面临了诸多的挑战。从开始开发这个小程序,到真正性能能够符合要求进行落地,这期间经历了非常多的一些"性能攻防"的实践。要提升扫地机小程序的整体体验,需要App容器层,App API,小程序的基础层,以及嵌入式设备层还有扫地机业务层的共同努力。

扫地机的业务具有一定的理解门槛,所以本次的分享,我们将围绕扫地机业务层在逻辑代码层面的一些通用体验提升小tips进行分享。内容主要会涵盖前端同学在日常开发中,经常使用的方法进行解析,来介绍这些不经意的细节是如何影响到小程序的性能的。

场景说明

在进行具体分享之前,我们先需要了解一下扫地机,才更容易理解为什么在业务层面,会有如此多的性能细节要求。

激光扫地机,在构建房屋户型的时候,通常采用的是激光雷达进行扫描,然后基于slam算法进行导航建图,同时生成了一系列的点云数据。点云数据,可以理解成是一系列的像素点,由这些带有特征的像素点,在屏幕上按照像素点进行绘制之后,得到了一张具有具体表示意义的激光地图。

扫地机的协议,是由一系列的16进制数按照约定的规则来描述一张地图的。每一个字节代表了一个像素点。在一个字节中的8个bit,按照高低位,又被表示成了特定的房间类型和房间号等。这种编码格式是一种极致的数据压缩,用于降低数据在通信通道中的传输成本和进行抗弱网传输。

在协议的设计上,设计思想比较偏向于音视频中的H.264的编码思想。但是扫地机的地图没有H.264 中的关键帧和参考帧的设计,所以就缺少了基于关键帧的参考预测的这种机制。极致的数据压缩之后,虽然带来了传输通道效率的提升,但也带来了解码层的负担。

举一个例子:比如扫地机清扫了一张600x500的地图,那么按照这种协议,也就是扫地机的地图数据有30万个像素点。我们需要遍历30万个像素,然后对每一个字节进行拆分,读取里面的数据之后再进行拼装。这就需要消耗大量的JS运算。同时每一次的地图,都会在3s以内进行更新,也就是说要在3s的时间循环窗口内,不停的进行30万次的循环操作,然后把30万个像素在渲染层上进行绘制。这对于JS这种单线程的语言框架来说,是致命的。 而小程序的双线程架构,分离了逻辑层和视图层,又导致了逻辑层处理的30万个像素的数据,需要经过App通道进行序列化后,才能到达视图层。因此小程序刚推出的时候,直接有RN的逻辑迁移过来的扫地机小程序面板,用一句话说,UI交互界面连动都动不了。

那么在这种如此恶劣的条件下,又是如何破局的?

JS编码性能巧思- 循环

1. 如何选择循环语法

在JS 中,有不少可以进行循环的语法。比如for循环,map, forEach 等。那么不同的循环语法之间,有没有什么差异呢?答案是肯定的。我们对上述的几个循环进行了测试。

每个循环都不进行语句操作,只进行基础循环测试

const width = 600;
const height = 500;
const array = new Array(width * height).fill(0);

const forLoop = () => {
    for(let i = 0; i < array.length; i++) {}
};

const mapLoop = () => {
    array.map(() => {});
};

const forEachLoop = () => {
    array.forEach(() => {});
};

timesLoop([forLoop, mapLoop, forEachLoop]);

【测试结果】:

循环名称50次测试平均耗时/ms
for0.164
map2.074
forEach1.694

从测试结果来看,for循环的效率最高,其次是forEach,最后才是map 循环。大家平时最喜欢用的forEach 和 map循环,成了性能的阻塞点。

2. for 循环需要注意什么

那for循环这样就是最佳实践了,不,不是的,就算简单写个for循环,照样可能掉进性能的坑里面去。

const width = 600;
const height = 500;
const array = new Array(width * height).fill(0);

const forLoop_1 = () => {
    for(let i = 0; i < array.length; i++) {}
};

const forLoop_2 = () => {
    for (let i = 0, length = array.length; i < length; i++) {}
};


timesLoop([forLoop_1, forLoop_2]);

【测试结果】:

循环操作50次测试平均耗时/ms
不缓存数据length0.16199999928474426
缓存数组length0.15800000071525575

从不同的for 循环写法来看,第一种的执行效率最低,而第二种效率最高,那这又是为什么呢?因为每一次循环中,第一种写法,都会再次执行一次读取length 的操作,而第二种写法,length 被缓存,所以性能更高。

3. 如何数组遍历

当然,也还不仅仅只有上述这些,看官接着往下看。当我们想要从宽高中,得到对应像素的坐标点数据的时候

const width = 600;
const height = 500;

// 根据宽高双层循环来获取像素坐标
const forLoop = () => {
    const list = [];
    for (let i = 0; i < width; i++) {
        for (let j = 0; j < height; j++) {
            const vector = { x:i, y: j };
            list.push(vector);
        }
    }
};

// 按照取余和取模的方式,使用单层循环
const forLoop_2 = () => {
    const list = [];
    const length = width * height;
    
    for (let i = 0; i < length; i++) {
        const vector = { x: i % width, y: Math.floor(i / width) };
        list.push(vector);
    }
};

timesLoop([forLoop_1, forLoop_2]);

【测试结果】:

获取像素坐标50次测试平均耗时/ms
双层for循环5.195999999046325
单层for循环2.9940000015497206

从数据可以看到,很明显,同样的结果,使用双层循环的开销,比使用单层循环的开销更大,虽然单层循环中使用数学运算符号,但是总体优势高于双层循环。这是因为在汇编中执行机器代码的时候,CPU在双层for循环会涉及到寄存器指针的跳跃更多,而单层循环的跳跃少,在一次循环中,CPU可以按照指针顺序直接执行下一次的操作指令。

4. Duff' Device 循环算法

当然, 除了上述的操作,我们还有更加极致的循环算法,用来提高循环性能,那就是利用CPU在执行代码时的工作原理,来进行循环优化。


const forLoop_2 = () => {
    const list = [];
    const length = width * height;
    
    for (let i = 0; i < length; i++) {
        const vector = { x: i % width, y: Math.floor(i / width) };
        list.push(vector);
    }
};

const duffDevice = () => {
    const list = [];
    const length = width * height;
    
    let i = 0;
    let n = Math.floor(length / 8);
    const remainder = length % 8;
    
    // 先处理余数部分
    let caseNum = remainder;
    switch (caseNum) {
        case 7: list.push({ x: i % width, y: Math.floor(i / width) }); i++;
        case 6: list.push({ x: i % width, y: Math.floor(i / width) }); i++;
        case 5: list.push({ x: i % width, y: Math.floor(i / width) }); i++;
        case 4: list.push({ x: i % width, y: Math.floor(i / width) }); i++;
        case 3: list.push({ x: i % width, y: Math.floor(i / width) }); i++;
        case 2: list.push({ x: i % width, y: Math.floor(i / width) }); i++;
        case 1: list.push({ x: i % width, y: Math.floor(i / width) }); i++;
    }
    
    // 循环展开,每次处理8个元素
    while (n-- > 0) {
        const idx1 = i++, idx2 = i++, idx3 = i++, idx4 = i++;
        const idx5 = i++, idx6 = i++, idx7 = i++, idx8 = i++;
        
        list.push(
            { x: idx1 % width, y: Math.floor(idx1 / width) },
            { x: idx2 % width, y: Math.floor(idx2 / width) },
            { x: idx3 % width, y: Math.floor(idx3 / width) },
            { x: idx4 % width, y: Math.floor(idx4 / width) },
            { x: idx5 % width, y: Math.floor(idx5 / width) },
            { x: idx6 % width, y: Math.floor(idx6 / width) },
            { x: idx7 % width, y: Math.floor(idx7 / width) },
            { x: idx8 % width, y: Math.floor(idx8 / width) }
        );
    }
    
    return list;
};


timesLoop([forLoop_2, duffDevice]);

【测试结果】:

循环算法50次测试平均耗时/ms
for 循环0.38399999976158145
Duff‘Device0.06

Duff’Device 算法进行循环操作,主要利用的核心思想,也是减少循环的次数,利用CPU 在寄存器操作指令过程中,在一次循环中,处理更多的逻辑,避免CPU在指令集中反复横跳,来优化循环

JS编码性能巧思- 数组

在上述的操作过程中,我们为了从数据中解析出来像素点,根据循环的位置,把数据添加到数组中,但是这个过程,也是掉入了性能陷阱的坑。让我们来看下面的例子。

const forLoop = () => {
    const list = [];
    const length = width * height;
    
    for (let i = 0; i < length; i++) {
        const vector = { x: i % width, y: Math.floor(i / width) };
        list.push(vector);
    }
};

const forLoop_2 = () => {
    const length = width * height;
    const list = new Array(length);
    
    for (let i = 0; i < length; i++) {
        const vector = { x: i % width, y: Math.floor(i / width) };
        list[i] = vector;
    }
    
}

【测试结果】:

数组操作方法50次测试平均耗时/ms
push操作0.29200000047683716
emplace操作0.07

从测试结果中,我们可以看到,emplace 操作的性能远远高于了push操作。那这又是为什么呢?往数组中添加数据使用push 方法是我们最常用的方法。

这个数组的操作性能影响的原因,我们要从堆内存的分配开始说起。我们知道程序中有栈内存和堆内存。栈内存的使用和访问效率是高于堆内存的。JS 中的数组是存储在堆内存中,在堆内存中,当我们声明了一个数组的时候,计算机会预分配一段内存。举一个例子,比如一个int 类型的数据,占有4个字节,那么100个元素,就需要分配400个字节的内存。当我们没有指定数组大小的时候,计算机是不知道需要分配多少内存的,当每一次push 操作的时候,数组都需要被重新计算大小。(当然这里有预分配的大小,不需要每次都重新分配内存)但是当存放的元素超过预分配的内存的时候,计算机就需要在堆内存中重新寻找可以存放的内存空间,并把原来的数据从旧的内存空间复制到新的内存空间中去。这也就算是为什么我们先创建一个指定大小的堆数组空间,然后往内存地址中直接写入数据会比push来得快,因为它减少了内存空间的计算,分配和重新拷贝的操作。

JS编码性能巧思- 对象

还是上面的例子,我们在for 循环中,往数组中添加了对应的坐标点。没想到吧,坐标点的定义也会有问题。那么具体问题又是什么呢,让我们来看看。

const objectSize = () => {
    const length = width * height;
    const list = new Array(length);
    
    for (let i = 0; i < length; i++) {
        const vector = { x: i % width, y: Math.floor(i / width) };
        list[i] = vector;
    }
};

const vectorSize = () => {
    const length = width * height;
    const list = new Array(length);
    
    for (let i = 0; i < length; i++) {
        const vector = [ i % width, Math.floor(i / width) ];
        list[i] = vector;
    }
};

const getApproximateSize = (obj: Array<{x: number; y: number;}> | number[][]) {
     return new Blob([JSON.stringify(obj)]).size;
};

【测试结果】:

坐标容器30万个数据内存的大小
对象约5MB
数组约2.7MB

惊不惊喜,意不意外。 我们经常使用的对象,在性能中,居然如此之差。从结果中可以看到,使用数组表示的坐标,相比使用对象表示的坐标,内存大小的差距将近一倍。这是因为数组的内存结构比对象的内存结构更加紧凑。所以在大数据的表示时,我们需要尽量使用数组形式表示。

混合编码性能巧思- WASM

虽然经过了在JS 编码层面,我们把JS 的使用语法性能提升到了极致。但是JS 引擎在不同的平台之间,性能差异还是很大,而且非常容易受到当前操作系统任务的影响,导致整体的JS执行效率波动较大。那么为了解决这个问题,我们引入了使用C++ 和 JS 混合编码的形式来解决对应的问题。然而使用C++的操作不当,反而不会带来很好的结果,比如一开始,我们使用C++ 进行混编的时候,实际地图解码的效率还没有JS来得快。当我们深入C++之后,最后混编的执行效率比JS 更稳定,效率比优化后的JS提升了两倍。

1. WASM 混编之数据传递

从JS 中传递数据到C++层,因为中间的数据空间不一致,导致像字符串或者对象的传递,都需要进行序列话或者反序列化。想象一下一个压缩数据为53KB的字符串,在经过解码之后,得到一个2.7M大小的数组,再进行二次序列化带来的性能开销得有多大。那么如何来处理呢?

首先是考虑使用指针的形式来解决序列化的问题。

#JS中先把字符串编码成ArrayBuffer

    cosnt str = "xxxx";
    const encoder = new TextEncoder();
    const data = encoder.encoder(str);

#把数据写入内存,并提供指针给到C++

    const totalSize = data.length;
    const ptr = wasm._malloc(totoalSize);
    
    const inputView = new Uint8Array(wasm.HEAPU8.buffer, ptr, totoalSize);
    inputView.set(data)

#C++从指针地址,读取指定的内存数据


    const char* data = reinterpret_cast<const char*>(ptr);
    
    std::string = text(data, data->length);

2. WASM 混编之C++中的String

为什么我们单独提到了String 呢?那是因为,在我们的案例中,主要就是对字符串数据进行操作。然而C++中的字符串又是C++中的性能天坑。只要稍微一不注意,就会让你的程序性能下降得非常明显。

#String的拷贝问题

    
    void* operator new(size_t size) {
          std::cout << "Operator new called" << std::endl;
          return malloc(size);
    };
    
    int main(int argc, char* argv[]) {
    
        std:string s = "1234567890123456789012345678901234567890";
        
        std::string sub = s.substr(0, 25);
        
        return 0;
    }

****当我们有一串解压后的字符串数据时,我们想要根据协议解析截取对应的内容进行操作,会使用到substr,但是当我们使用substr 后,会发现在我们重写new 操作符之后,构造函数被执行了两次。那是因为,这两个操作,都在堆内存上进行了初始化的操作。

其实在JS 的代码中,我们很少会关心到关于字符串的引用的问题。但是C++中,就不允许你这么随意了,因为字符串是性能的天坑,拷贝和不拷贝都需要由你自己来决定,才能达到极致的性能。

那么如何来解决这个问题呢?其实在C++中,根据不同的编译器,也有不同的小字符串优化策略。

举个例子,在Clang的编译器环境中,如果你把substr改为s.substr(0, 22); 你会发现,字符串的new 只执行了一次。在GCC的编译器环境中,substr改为s.substr(0, 15)也只执行了一次。这是因为编译器进行了小字符串的优化。避免了创建新的内存空间,而小字符串的内存空间,统一放在了一个缓冲区当中。

那如果你截取的字符串需要超过22个字符呢。那其实你应该考虑使用const char* 和string_view的字符串表达方式。

在同样换了一种写法之后,你会发现,new 函数一次都没有被执行。那这又是为什么?因为const char* p 变成了一个常量,而p 是指针地址,存储在栈上。然后string_view 只是创建了一个映射视图,并没有真正的创建新的内存空间来存储新的数据。

当然,在另外的一种case 中,比如你想要将一个字符串传递给一个函数,然后在函数中进行操作,那这个时候,又会是什么样的呢?


    void print(std::string s) {
        std::cout << s << std:endl;
    };
    
    int main(int argc, char* argv[]) {
    
        std::string s = "1234567890123456789012345678901234567890";
        
        print(s);
        
        return 0;
    };

为什么只是传递一个字符串进行打印,又执行了两次内存分配,那是因为,在函数调用的时候,使用的是拷贝,而不是引用。

只需要把print(std::string s) 改成print(std::string& s) 就可以使用引用传递,从而减少拷贝的消耗,当然你也可以使用指针传递,然后进行解引用操作。

3. WASM 混编后的测试结果

当我们使用优化后的JS解码函数,和使用WASM 混编后的解码函数进行测试,可以看到以下的数据

测试函数平均耗时/ms最快耗时/ms最慢耗时/ms
decodeMap15.66ms13.30ms57.10ms
decodeUseWasm5.51ms4.90ms13.60.ms

使用WASM的混编解码效率差不多是JS的3倍,而且WASM的整体解码效率更加稳定,JS的解码效率受到任务系统调度影响更大。

从测试数据中,我们把解码的耗时压缩到了16.6ms 以内,在JS中如果函数执行超过16.6ms,那么就会出现卡顿和掉帧,透过压榨CPU和Memory 来为你更好的工作,解决了这样的问题。

结语

上述的一些编码技巧,是利用了计算机操作系统原理,来让CPU和Memory更好的为你工作。这里面的内容,也只是小程序在性能优化上最基础的一小部分。要让你的产品达到最佳体验状态,需要从编码,方案,链路上进行全方位的设计与思考,才能达到最佳状态。 而这里的内容,只是适用于绝大产品的体验优化的tips,希望能给大家带来借鉴和参考。