低效程式根源追溯——記一次刷題對效能的不滿

Hidea發表於2022-03-23

問題來由

一次在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,這說明確實是陣列初始化對效能的影響。

總結

我一共提出了四種解釋,四種從理論角度都是可能的,但只有第四種得到了實驗的證實,繞了這麼大彎路總算得到了答案。

相關文章