6

低效程序根源追溯——记一次刷题对性能的不满 - Hidea

 2 years ago
source link: https://www.cnblogs.com/huasyuan/p/16047121.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

低效程序根源追溯——记一次刷题对性能的不满

一次在Leetcode上遇到一道DP题,题号72。一般DP题的状态转移方程需要从多个候选中选出最小值,我用下面的代码实现了状态转移方程:

**第一种**
for (int i = size1-1; i >= 0; i--) {
    for (int j = size2-1; j >= 0; j--) {
        if (word1[i] == word2[j]) {
            min = ops[i+1][j+1];
        } else {
            min = 1 + ops[i+1][j+1];
        }
        if (min > 1 + ops[i+1][j]) {
            min = 1 + ops[i+1][j];
        }
        if (min > 1 + ops[i][j+1]) {
            min = 1 + ops[i][j+1];
        }
        ops[i][j] = min;
    }
}

但这种写法用时80ms,只击败了不到6%的提交。我对这种低效非常不满,将官方题解提交后发现耗时仅16ms,状态转移方程部分代码如下:

**第二种**
for (int i = 1; i < n + 1; i++) {
    for (int j = 1; j < m + 1; j++) {
        int left = D[i - 1][j] + 1;
        int down = D[i][j - 1] + 1;
        int left_down = D[i - 1][j - 1];
        if (word1[i - 1] != word2[j - 1]) left_down += 1;
        D[i][j] = min(left, min(down, left_down));

    }
}

我猜测是状态转移方程部分的代码实现低效导致程序整体性能偏差,为此我对这部分代码开展了研究。

分支对性能的影响

我的第一个想法和分支有关,重新翻阅了CSAPP后,摘出原书P358的下面一段话

现代处理器采用了一种称为分支预测的技术,处理器会猜测是否选择分支,同时还预测分支的目标地址。使用投机执行的技术,处理器会开始取出位于它预测的分支会跳到的地方的指令,甚至在它确定分支预测是否正确之前就开始执行这些操作。如果过后分支预测错误,会将状态重新设置到分支点的状态,并开始取出和执行另一个方向上的指令。
采用这种方法,如果分支预测失败,就必须抛弃已经执行的结果,转而重新执行一系列计算,这种方式会增加程序执行的时间。换句话说,为了更好的性能,编写程序时应当尽量避免使用分支。

在官方实现中,使用三个变量存下了可能值,对于需要判断当前字符是否相等的情况,必须使用一个分支。在我的实现中,将通过条件分支去获得三个可能值中最小值的方式改为了通过C++库函数min获得。如果min函数的实现没有用到分支,那么这种性能差异便可使用分支预测去解释,因此我又去cppreference上查了min函数的possible implementation,发现是通过三目运算符 ? :实现的,我将官方题解改为:

**第三种**
for (int i = 1; i < n + 1; i++) {
    for (int j = 1; j < m + 1; j++) {
        int left = D[i - 1][j] + 1;
        int down = D[i][j - 1] + 1;
        int left_down = D[i - 1][j - 1];
        if (word1[i - 1] != word2[j - 1]) left_down += 1;
        left_down = down < left_down ? down : left_down;
        D[i][j] = left < left_down ? left : left_down;
        // D[i][j] = min(left, min(down, left_down));
    }
}

两次执行,一次耗时12ms,一次耗时16ms,说明用min和三目运算符方式性能差异不大,我们可以直接讨论三目运算符。进一步,难道三目运算符不需要用到分支?为了解答这个问题,我们可以利用objdump反汇编可执行程序,去查看汇编码,但是我太懒了,直接借鉴了这位朋友的博客。我们可以看到,三目运算符可能会翻译成多种形式的汇编,如果两个操作数都是常数,是可以不使用分支的。但第三种实现中,操作数放在寄存器中,编译器无法根据编译时不确定的值去生成不用分支的汇编码。我将第一种实现用三目运算符改写了:

**第四种**
for (int i = size1-1; i >= 0; i--) {
    for (int j = size2-1; j >= 0; j--) {
        min = word1[i] == word2[j] ? ops[i+1][j+1] : 1 + ops[i+1][j+1];
        min = min > 1 + ops[i+1][j] ? 1 + ops[i+1][j] : min;
        min = min > 1 + ops[i][j+1] ? 1 + ops[i][j+1] : min;
        ops[i][j] = min;
    }
}

一次执行68ms,一次执行80ms,和第一种差异不大,这表明三目运算符最后还是被翻译成了使用分支的汇编码,使用if-else还是三目运算符对性能影响不大。

另一种解释

在第二种实现中将数组中元素值存在变量中,在汇编层面就是把值放在寄存器中,之后的运算只需使用寄存器,由于寄存器访问的快速,程序照理是会表现出更好的性能。为此,我将第一种实现又改写为:

**第五种**
for (int i = size1-1; i >= 0; i--) {
    for (int j = size2-1; j >= 0; j--) {
        int right_down = word1[i] == word2[j] ? ops[i+1][j+1] : 1 + ops[i+1][j+1];
        int right = 1 + ops[i][j+1];
        int down = 1 + ops[i+1][j];
        right_down = right_down < right ? right_down : right;
        ops[i][j] = right_down < down ? right_down : down;
    }
}

一次执行72ms,一次执行68ms,和第二种实现没有很大差异,很可能编译器已经帮我做了优化,操作数实际上已经被加载到寄存器中了。

第三种解释

我又把目光放在了边界值初始化上,我的版本是:

for (int i = 0; i < size1; i++) {
    ops[i][size2] = size1 - i;
}
for (int j = 0; j < size2; j++) {
    ops[size1][j] = size2 - j;
}

官方题解是:

for (int i = 0; i < n + 1; i++) {
    D[i][0] = i;
}
for (int j = 0; j < m + 1; j++) {
    D[0][j] = j;
}

我猜测是循环内部的减法导致我的版本性能更差,因此我将官方题解改为:

for (int i = 0; i < n + 1; i++) {
    D[n-i][0] = n-i;
}
for (int j = 0; j < m + 1; j++) {
    D[0][m-j] = m - j;
}

三次执行平均用时14ms,无明显变化我的猜测又被推翻了。

最后一次解释

排除了这么多可能,我将眼光放在了创建DP数组上,我的实现:

int ops[600][600] = {0};

官方实现:

if (n*m == 0) return n + m;
vector<vector<int>> D(n + 1, vector<int>(m + 1));

当n,m较小时,官方解法是能节省很大的空间。我将我的实现改为:

if (size1 * size2 == 0) return size1 + size2;
vector<vector<int>> ops(size1 + 1, vector<int>(size2 + 1));

两次执行平均用时12ms,这说明确实是数组初始化对性能的影响。

我一共提出了四种解释,四种从理论角度都是可能的,但只有第四种得到了实验的证实,绕了这么大弯路总算得到了答案。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK