性能优化再思考:理解资源与计算的关键(计算篇)

735 阅读19分钟

写在前面

一提到性能优化,很多人就会想到 加并发,加机器,用缓存。诚然,这几种方法被熟悉是因为在生产环境中被验证有效且和业务逻辑关系不大,好像什么场景都可以用这几板斧试试。可是,当你手里只有锤子的时候,你是没办法完成一个复杂的任务的,手里只有锤子的人只会把所有像钉子的地方锤一遍。

本篇文章的写作目的是从另一种视角来看待性能优化,不去拘泥于技能、方法、技巧(当然也会讲这些),而是从更宏观的角度去理解性能优化的原理。

作者水平一般,能力有限,难免遗漏,见谅见谅,不足之处,多多指教。

性能优化的上一步是什么?

如果说性能优化是在做什么,很多人可能不假思索,节省时间,或者将一个系统单实例的吞吐量提高。或许有一些人能想到节省空间(当然在这个空间获取成本极低的情况下,绝大部分场景已经不需要再节省空间了)。

想弄明白性能优化的本质,就要知道自己优化的是什么,换一种表达方式,就是知道在自己性能优化之前,系统在干什么

这也就是我说的性能优化的上一步

有没有什么表达方式,可以“一言以蔽之”的概括出“系统在干什么”这个问题?

这里直接给出我的答案:

系统使用了一些资源,完成了一些计算

这里的资源和计算都是一个很宽泛的概念。计算并不仅仅是一些基于加法器的操作,举个例子,笔者之前部署的时候,由于环境包过大,以20MB/s的速度进行解压,需要40分钟,但是换成SSD后,使用20分钟就可以完成解压工作,如果一个任务中IO时间占比过高,那么仅仅通过使用更好资源,我们就能完成对性能的优化。

性能优化的方法

基于之前讨论,我们会基于“系统使用了一些资源,完成了一些计算”这个前提尝试优化系统的性能:

  • 思路1:减少计算
  • 思路2:增强资源

接下来会从这两个思路上阐述不同的性能优化方式

这里可以通过思维导图简略的看下后续文章的内容:

性能优化方法论.png

1.减少计算

当然计算不可能凭空减少,总不能请求过来直接返回一个ok。那么最容易想到的就是避免重复的计算。

1.1 避免重复的计算

1.1.1 不仅仅是加缓存

这里最容易想到的办法,就是之前提到的加缓存。

但其实不仅仅是加缓存,应该每个程序员都做过斐波那契数列的算法题,其中动态规划的思路就是在通过将f(n-1)的计算结果存起来,来减少重复的计算。否则递归的耗时就会很大,这些耗时大多数来源于重复的计算。

更详细的一些用缓存的方式可以看之前的文章: 性能优化利器——缓存(附三种常见缓存设计)

这个思路其实都是在首次计算的时候,把计算的结果存下来,然后避免重复的计算。

1.1.2 只在末次进行计算

linux 系统 fork进程时,子进程是父进程的复制品,子进程享有父进程的数据空间,堆,栈的复制品,需要注意的是,子进程和父进程并不是拥有同样的数据空间,理论上子进程针对数据做出的修改,对父进程来说应该是不存在、不可见的。

然而,fork之后很可能就是执行,如果每次fork都真的去复制一份所有资源,无疑是对性能影响很大的。因此大多数fork都会选择写时复制。

写时复制

写时复制的核心思想是当多个变量指向同一个内存区域时,只有其中一个变量发生写操作时,才会将该内存区域复制出一个副本,以确保每个变量都有自己的数据副本。

写时复制可以帮助减少因复制过多数据而导致的性能损失。当某个变量需要被修改时,系统会先检查该变量是否有其他引用,如果没有,则直接修改原始数据;如果有其他引用,则会先复制出一个副本,然后在副本上进行修改,确保原始数据不会被改变。这样,就避免了不必要的复制操作,提高了程序的执行效率。

注意:写时复制节省的重复计算其实发生在:

  1. fork时本该先复制全量数据,但是没有
  2. fork后,子进程本应该读取自己的数据,但是没有

1.2 通过提前存储计算的结果来避免计算

这个思路其实完全没有减少计算的成本,而是将计算的时间挪移到了更早的时间节点,就像是一个魔术师,当他说“接下来就是见证奇迹的时刻”时,他已经完成了所有布局。

预计算本身是一种很有效的计算方式,而且他和缓存很像,都是通过把计算结果存起来来避免计算,这里需要分清两者的区别:

  1. 缓存是在首次计算发生时,计算后将结果存起来,再之后同类计算请求到来时,直接获取存起来的计算结果,来避免重复的计算。
  2. 而预计算是人为的将计算挪到了更早的时间节点。比如doris的预聚合,就是在插入请求时,就已经计算好各个指标的聚合结果,然后提高查询的性能。

预计算不可避免的会影响到插入性能,但是很多时候我们对插入性能并没有过多要求,所以很多业务场景完全可以用预计算提高性能。

更详细的使用预计算的方式也可以看看之前的文章: 性能优化利器——预计算(含报表场景实践)

避免计算可能不大容易适配到所有场景,那么有没有什么办法可以减少计算呢?

1.3 通过优化代码来减少计算

通过优化代码来减少计算,我认为可以从两个角度来考虑,一个是我们可以通过使用合适的数据结构或者算法来优化我们的计算量,就是说通过更改算法,我们可以用更少的计算获得相同的结果。另一个则是编码的过程中,我们可能出于易读性、复用率的原因让系统的性能下降。这个观点很多读者可能之前没有考虑过,毕竟宏观上对性能的影响可能不大,远远小于一次网络波动造成的影响。但是,如果想系统的提升自己性能优化的能力,却不了解自己编码带来的性能损失,这是不合逻辑的。

1.3.1 通过使用算法或数据结构减少计算复杂度

通过使用合适的算法来优化我们的计算量,这是一种很立竿见影的优化方式,因为通过优化降低的计算量都是可以明显的在时间复杂度上看出来的。比如说排序算法,我们使用选择排序,每次获取数组中最大的元素,那么时间复杂度是O(n2)O(n^2),但是如果使用快速排序的话,时间复杂度就只有O(nlogn)O(nlogn)了。

那么当我们对大型数据集进行排序时,使用快速排序或者归并排序就会比冒泡排序更有效率。而如果我们的需求是实时的获取排行榜中前N名,或者是维护一个待处理任务的优先队列,那么使用堆排序自然就是更好的选择了。

数据结构对我们的助力也很大,常见的场景是在数据库进行查询时,如果我们使用了索引,就可以直接找到对应的行,提高了查询效率。 很多时候换一种合适数据结构会有一种“顿觉天地宽”的感觉,同样是顺序表,如果使用链表表示,那么在插入的时候就会很容易,但数组插入就会占用大量的计算。还有一种常见的数据结构减少计算的套路是在稀疏图的情况下,将邻接矩阵转换成邻接表,将我们的视角从点的角度,转换为边的角度,这可以大大降低计算量。

1.3.2 优化代码减少创建和销毁对象的成本

面向对象带来的性能损耗

面向对象相较于面向过程有很多好处,比如可重用,更低的维护成本,以及灵活的可拓展性。但是不得不说面向对象会带来一定的性能损耗。我们可以从以下几个角度考虑面向对象带来的计算成本:

对象的创建

  • 堆内存分配:通过调用malloc来分配内存。malloc的执行包括找到适当大小的空闲块,可能会发生的内存块分割以及元数据维护
  • 构造函数的调用:对象创建时,构造函数会被调用,这其中包括参数传递、栈帧创建等,当然只要是函数都会面临这个问题,在下一小节会继续讨论“如何通过优化代码减少函数调用的成本”这个问题。

对象的销毁

  • 堆内存释放:在销毁对象时,我们通过free函数来完成操作,free函数需要将释放的内存块归还到空闲内存池,并可能通过合并来减少内存碎片
  • 析构函数的调用:创建时会调用构造函数,销毁时会调用析构函数,同样涉及函数调用的开销。
构造函数和析构函数

避免继承和组合 面向对象的核心是封装继承多态,我们通过继承和组合来提高我们代码的复用率,但这也为我们性能优化工作埋藏了隐患。

我们注意到,代码重用和性能之间是存在基本矛盾的,我希望代码被尽可能重用的同时,也代表代码很可能有一些无用部分也跟随着重用了。

那么怎么能既要让代码尽可能被重用,又要保证性能呢?

C++ 使用模板来解决这个问题,通过模板,代码可以在编译时生成针对不同数据类型的特定实现,从而提高代码的复用性和性能。使用模板可以解决组合和继承带来的性能问题,因为模板是在编译时生成具体类型的代码,不会造成运行时的开销。

避免一开始定义所有对象

很多人习惯在程序的开始处定义所有需要的对象,但有的时候可能由于程序提前退出,因此被创建的对象可能从未被使用过,那么这个对象的开销就被白白浪费了。在性能敏感的前提下,一开始定义所有对象无疑是一个坏习惯。

因此我们要尽量晚的创建对象,将对象的创建延迟到第一次使用前

编译器可能会替我们生成临时对象

在某些情况下,编译器会替我们生成临时对象,这让我们的程序额外的增加了构造函数和析构函数的成本。

某些情况: 编译器在需要中间值或转换值以满足语义要求时会生成临时对象。

这些情况可能是因为类型不匹配,函数返回一个对象的值,以及运算符重载等。

如果可以避免编译器替我们生成临时对象,那么这些因临时对象所产生的性能损耗就会消失,也就达到了性能优化的目的。

关键字explicit

在 C++ 中,explicit 通常用于构造函数的声明中,用于防止隐式转换。 当将一个参数传递给构造函数时,如果构造函数声明中使用了 explicit 关键字,则只能使用显式转换进行转换,而不能进行隐式转换。

class Rational {
public:
    Rational(int a = 0, int b = 1): m(a), n(b) {}
private:
    int m;
    int n;
}

Rational r = 100;

这个时候本质上是先通过Rational(100, 1)生成了一个临时的对象,再将临时对象赋值给变量r,再销毁这个临时对象,这就造成了性能的损耗,而且这个隐式转换问题还可能影响代码的安全性,比如:

AfuncNeedRational(100);

这个函数并不打算使用int变量作为输入,但是在这里也会通过隐式转换,完成函数调用。

通过将构造函数声明为explicit,可以让编译器不要为我们做隐式转换,进而避免了临时对象的生成。

函数重载

除了将构造函数声明为explicit,还有一种办法可以消除临时对象,那就是通过函数重载,重载对应的运算符,以接收整数参数,来避免临时对象的生成

class Rational {
public:
    Rational(int a = 0, int b = 1): m(a), n(b) {}
    Rational& operator=(int a) {m = a; n = 1; return *this;}
private:
    int m;
    int n;
}

同理,函数调用也可以重载

void AfuncNeedRational(Rational r){ ... }
// 函数重载

void AfuncNeedRational(int n){ Rational r(n); ... }

在函数传递对象和返回对象时,使用引用代替对象拷贝

没什么说的,略

使用op+=() 代替op=()

一个常见的场景

string s1, s2, s3;
...
s3 = s1 + s2; // 这里会产生临时对象

为什么会产生临时对象呢?

当你执行 s1 + s2 时,操作符 + 会调用 std::string 类的重载函数。这个函数返回一个新的 std::string 对象,该对象包含了 s1 和 s2 的拼接结果。这个新对象是一个临时对象,因为它只在表达式的求值过程中存在。

之后,这个临时对象会被赋值给 s3,在赋值过程中又会调用 std::string 的赋值操作符,这可能会导致额外的拷贝或移动操作。

那么如何避免呢?

两种思路:

  1. 使用append 方法
string s1 = "Hello, ";
string s2 = "World!";
string s3 = s1;
s3.append(s2); // 直接在 s3 上进行拼接,没有临时对象
  1. 使用operator+=
string s1 = "Hello, ";
string s2 = "World!";
string s3 = s1;
s3 += s2; // 直接在 s3 上进行拼接,没有临时对象
编译器优化技术-返回值优化
class Box
{
public:
    int weight;
    Box() : weight(0) { std::cout << "创建成功!\n"; }
    Box(const Box& other) : weight(other.weight) { std::cout << "复制构造!\n"; }
    ~Box() { std::cout << "析构函数\n"; }
};

Box createBox() 
{
    return Box();
}

int main()
{
    Box b = createBox();
    return 0;
}

正常情况下,函数createBox()中的"return Box();"会创建一个临时的Box对象,然后这个临时对象会被复制到main()函数中的b对象:

  1. return Box()创建了一个临时的Box对象,这个时候调用了构造函数,打印“创建成功”
  2. 接下来这个临时对象会被复制给main函数中的b,这个时候需要调用复制构造函数,打印“复制构造”
  3. 最后,将临时对象释放,调用析构函数。

返回值优化后的代码是这样:

void createBox(Box& result) 
{
    result = Box();
}

int main()
{
    Box b;
    createBox(b);
    return 0;
}

createBox()函数中返回的临时对象直接在main()函数中的b对象的存储空间中构造,完全避免了额外的复制构造和临时对象的构造和析构。

显然的,返回值优化可以帮助我们节省临时对象的创建和销毁所带来的消耗。同时,返回值优化并不是我们的工作,而是编译器的优化手段,而我们能做的就是尽量在编译器无法进行返回值优化时,避免创建临时对象,进而进行性能优化。

一些常见的无法进行返回值优化的场景:

  1. 函数内部有多个返回点,返回对象不同
  2. 返回的对象取决于运行时的条件
Box createBox(bool isSquare) {
    if (isSquare) {
        return Box(10, 10);
    } else {
        return Box(10, 20);
    }
}
// 将上面代码转换成
Box createBox(bool isSquare) {
    int width = 10;
    int height = isSquare ? 10 : 20;

    // 使用计算性构造函数创建并返回Box对象
    return Box(width, height);
}

1.3.3 优化代码减少函数调用的成本

函数调用的成本主要来自于栈帧管理,函数调用时会在栈上分配新的栈帧(包括局部变量,返回地址,和一些状态信息),函数的参数可能被存到寄存器中,也可能被分配到栈帧中。在调用结束后,还需要进行上下文的切换,所以一次函数调用相较于一次普通的计算,还是一个性能损耗比较高的操作。当然,很多函数不只是一次普通的计算,可能包括很多对象生成等操作,因此只有在调用频繁、逻辑简单的函数上,我们才有必要思考是否需要减少函数调用的成本。

编译器优化技术-内联

内联类似于宏,在调用方法内部展开被调用方法,以此来代替方法的调用。

内联带来的性能优化:

  • 减少函数调用
  • 调用间优化:
// 内联后优化前
int method(int a){
    int b = 6;
    int m;
    {
        int _temp_q = 6;
        int _temp;
            if (_temp_q > WORD_SIZE) _temp = -1;
            else if (_temp_q > 0) _temp = (1 << q) -1;
            else _temp = 0;
            m = _temp;
    }
    int n = m + 1;
}
// 内联后优化后
int method(int a){
    int b = 6;
    int m = 0x3F;
    int n = m + 1;
}

直接量参数与内联结合使用,为编译器性能的大幅提升开辟了更为广阔的空间,这也是为什么调用间优化可以占据内联所带来的性能优化半边天的原因。不过,受限于使用场景,直接量参数与内联结合使用并不会在每次内联时发生。相较而言,避免方法调用获得的性能提升却是确定的,虽然有时效果并不尽如人意,但这种做法具有普遍性。

再谈函数调用的代价

为了方便读者更好的理解函数调用对性能的影响,必须说明函数调用过程中发生了什么。因此不可避免的需要插入一些计算机体系结构的知识

函数调用可能会涉及以下寄存器:

  • 指令指针IP(Instruction Pointer),也叫程序计数器(program Counter)存放下一条将要执行的指令地址。调用方法时,程序要跳转到被调用方法的指令并修改IP。但不能只是简单的重写IP,需要保存旧值,否则无法返回至原调用方法。
  • 链接寄存器(Link Register):存储某一方法的IP 的地址,该方法对当前方法进行了调用。 这个地址就是方法执行完牛后返回的地方。可以通过自动或显式地将调用方法IP押入程序进程堆栈中实现。
  • 栈指针(Stack Pointer):栈指针跟踪记录堆栈的使用情况。调用操作消耗堆栈空间,返回操作则会释放之前分配的堆栈空间。类似于调用者的IP和LR,调用返回之后,必须根据传递到堆栈的参数来进 行可能的调整以恢复堆栈。
  • 帧指针(Frame Pointer):标识堆栈中两个区域的边界,第一个区域供调用方法用来保存需要记录状态的寄存器。第二个区域为被调用方法的自变量分配内存。
  • 自变量指针(Argument Pointer):标记使用寄存器的数量。

函数调用时寄存器操作

  1. 调用方法整理需要传给被调用方法的参数,进行(倒序)压栈,所有参数入栈后,SP会指向第一个参数
  2. 将返回的指令地址压栈,然后调用指令跳转到被调用方法的第一条指令
  3. 被调用方法在堆栈中保存调用方法的SP、AP、FP,并调整每个内务寄存器以反映被调用方法的上下文环境。
  4. 被调用方法保存(压栈)其会用到的所有其他寄存器。

清除调用时寄存器操作

  1. 如果有返回值,则返回值放入寄存器0
  2. 将因方法调用而保存的寄存器从堆栈中恢复至起初始位置
  3. 将保存的调用者的FP和AP寄存器从堆栈中恢复至起初始位置
  4. 修改SP,使其只想将方法第一个参数压栈前的位置
  5. 从堆栈中找到返回地址并将其存入IP

内联优化的局限性

虽然内联优化通过减少函数调用开销,可以显著提高程序的执行效率。然而,内联优化也有其局限性,可能导致代码膨胀和调试困难。因此需要合理使用内联优化,可以在保持代码简洁和可维护的同时,获得显著的性能提升。

小结

本篇文章围绕系统使用了一些资源,完成了一些计算。,针对减少计算这个方向,阐述了在性能优化的过程中普遍思路。相较于加机器、加缓存、加并发这类拿着锤子找钉子的思路,我认为作为研发,在性能优化的工作当中,需要的是更系统级别的思路。

在写作的过程中,发现很难避免涉及到计算机体系结构的知识,笔者在易懂的文章结构和讲透知识点上反复摇摆内耗,故本篇文章仅去从减少计算这个方向提供性能优化的思路,而在后续的资源篇,会在讲解性能优化之前,先系统的讲解计算机体系结构的知识。