David Lin

David Lin

一個軟體工程師的隨意筆記

02 Feb 2020

C++ Exception 是效能殺手嗎?

前言

這個題目早就在 C++ 開發者圈裡吵了好幾年,尤其是需要注重效率優化的(ex:遊戲開發), 甚至有些開放原始碼專案(例如 Firefox 的 SpiderMonkey)禁止使用 C++ exception 永絕後患。

但是,C++ exception 真的不好嗎?那為什麼 C++ Standard Committee 還是會建議使用它呢,而且 C++ Standard Library 實作也是大量使用它。

目前我正在進行一個 C++ side project,正在煩惱是否該用 C++ exception,所以乾脆做一個實驗探討 C++ exception 對效能的影響。

設計實驗

我要做的測試很簡單,就是只要把以下程式丟給 quick-bench.com 去跑看看

// BENCH START
std::vector<uint32_t> v(10000);
for (uint32_t i = 0; i < 10000; i++) {
    v.at(i) = i;
}
// BENCH END

實驗一

此實驗有三個不同 cases,分別為:

  1. no_exception: 不使用 C++ exception
  2. with_exception:有加上 try-catch block 但不 throw
  3. throw_exception:有加上 try-catch block 而且故意寫入一個超出範圍去 throw
static void no_exception(benchmark::State &state) {
    for (auto _ : state) {
        std::vector<uint32_t> v(10000);
        for (uint32_t i = 0; i < 10000; i++) {
            v.at(i) = i;
        }
    }
}
BENCHMARK(no_exception);//----------------------------------------

static void with_exception(benchmark::State &state) {
    for (auto _ : state) {
        std::vector<uint32_t> v(10000);
        for (uint32_t i = 0; i < 10000; i++) {
            try {
                v.at(i) = i;
            } catch (const std::out_of_range &oor) {
            }
        }
    }
}
BENCHMARK(with_exception);//----------------------------------------

static void throw_exception(benchmark::State &state) {
    for (auto _ : state) {
        std::vector<uint32_t> v(10000);
        for (uint32_t i = 0; i < 10001; i++) {
            try {
                // it throws exception when i = 10000
                v.at(i) = i;
            } catch (const std::out_of_range &oor) {
            }
        }
    }
}
BENCHMARK(throw_exception);//----------------------------------------

然後把上面的 code 貼到一個網站 quick-bench.com 去跑看看。目前先暫時關閉 optimization (optim = None) 去看看效能如何。

恩..從這結果來看,效能看起來差不多啊。

但是身為一個 C++ dev 不使用 -O2 去加速就太對不起自己了(誤),故接下來就打開 -O2 optimization 看看結果…

哇, 這結果看起來真的猛爆了,光是只開 try-catch block 就超級慢多了,更別說 throw exception 的情況了。 但是,下結論之前,先看看 compiler 所生成的組合語言吧。

果然,compiler 在 no_exception 就很暴力的把 std::vector<uint32_t> 的 allocation (operator new) 給切掉了,還順便拿掉 v.at(i) = i;了,而 with_exception & throw_exception 還是保留 allocation,所以造成了效能上的巨大落差。雖然 compiler 很稱職的把無用運算砍掉了,但這也造成實驗的偏差。

以下是 no_exception 的生成組合語言

       push   %rbp
       push   %r14
       push   %rbx
       mov    %rdi,%r14
       mov    0x1a(%rdi),%bpl
       mov    0x10(%rdi),%rbx
       callq  211a90 # <benchmark::State::StartKeepRunning()>
       test   %rbx,%rbx
       je     21112c # <no_exception(benchmark::State&)+0x3c>
       test   %bpl,%bpl
       jne    21112c # <no_exception(benchmark::State&)+0x3c>
       xchg   %ax,%ax
       mov    $0x2710,%eax
       nopw   %cs:0x0(%rax,%rax,1)
       nop
       add    $0xffffffffffffffe7,%rax
       jne    211120 # <no_exception(benchmark::State&)+0x30>
       add    $0xffffffffffffffff,%rbx
       jne    211110 # <no_exception(benchmark::State&)+0x20>
       mov    %r14,%rdi
       pop    %rbx
       pop    %r14
       pop    %rbp
       jmpq   211b70 # <benchmark::State::FinishKeepRunning()>

with_exception 做比較

       push   %rbp
       push   %r15
       push   %r14
       push   %r13
       push   %r12
       push   %rbx
       sub    $0x18,%rsp
       mov    %rdi,%r15
       mov    0x1a(%rdi),%bl
       mov    0x10(%rdi),%r12
       callq  211a90 # <benchmark::State::StartKeepRunning()>
       test   %r12,%r12
       je     21123c # <with_exception(benchmark::State&)+0xfc>
       test   %bl,%bl
       jne    21123c # <with_exception(benchmark::State&)+0xfc>
       mov    %rsp,%r14
       jmp    21118a # <with_exception(benchmark::State&)+0x4a>
       nopw   %cs:0x0(%rax,%rax,1)
       nopl   (%rax)
       add    $0xffffffffffffffff,%r12
       je     21123c # <with_exception(benchmark::State&)+0xfc>
       xorps  %xmm0,%xmm0
       movaps %xmm0,(%rsp)
       movq   $0x0,0x10(%rsp)
       mov    $0x9c40,%edi
       callq  23d510 # <operator new(unsigned long)@plt> # <------- allocation 發生的地方
       mov    %rax,%r13
       mov    %rax,(%rsp)
       lea    0x9c40(%rax),%rbp
       mov    %rbp,0x10(%rsp)
       mov    $0x9c40,%edx
       mov    %rax,%rdi
       xor    %esi,%esi
       callq  23d520 # <memset@plt>
       mov    %rbp,0x8(%rsp)
       xor    %ebx,%ebx
       nopl   (%rax)
       sub    %r13,%rbp
       sar    $0x2,%rbp
       cmp    %rbx,%rbp
       jbe    2111f9 # <with_exception(benchmark::State&)+0xb9>
       mov    %ebx,0x0(%r13,%rbx,4) # <---------------------------- 這個是 v.at(i) = i; 發生的地方
       cmp    $0x270f,%rbx
       je     211220 # <with_exception(benchmark::State&)+0xe0>
       add    $0x1,%rbx
       mov    (%rsp),%r13
       mov    0x8(%rsp),%rbp
       jmp    2111d0 # <with_exception(benchmark::State&)+0x90>
       mov    %r14,%rdi
       callq  23d530 # <std::__1::__vector_base_common<true>::__throw_out_of_range() const@plt>
       jmp    211253 # <with_exception(benchmark::State&)+0x113>
       mov    %rax,%r13
       cmp    $0x1,%edx
       jne    211256 # <with_exception(benchmark::State&)+0x116>
       mov    %r13,%rdi
       callq  23d540 # <__cxa_begin_catch@plt>
       callq  23d550 # <__cxa_end_catch@plt>
       jmp    2111e1 # <with_exception(benchmark::State&)+0xa1>
       nopw   0x0(%rax,%rax,1)
       mov    (%rsp),%rdi
       test   %rdi,%rdi
       je     211180 # <with_exception(benchmark::State&)+0x40>
       mov    %rdi,0x8(%rsp)
       callq  23d560 # <operator delete(void*)@plt>
       jmpq   211180 # <with_exception(benchmark::State&)+0x40>
       mov    %r15,%rdi
       callq  211b70 # <benchmark::State::FinishKeepRunning()>
       add    $0x18,%rsp
       pop    %rbx
       pop    %r12
       pop    %r13
       pop    %r14
       pop    %r15
       pop    %rbp
       retq
       mov    %rax,%r13
       mov    (%rsp),%rdi
       test   %rdi,%rdi
       je     211269 # <with_exception(benchmark::State&)+0x129>
       mov    %rdi,0x8(%rsp)
       callq  23d560 # <operator delete(void*)@plt>
       mov    %r13,%rdi
       callq  23d570 # <_Unwind_Resume@plt>

實驗一(修正版)

要解決這問題很簡單,只要在 std::vector<uint32_t> v 的宣告放一個 static 就可以強迫 compiler 保留 allocation 了,因為這對於 compiler 來說這是 side effect,無法判斷是否需要 stripping。(超 hack) 而且使用 static 也代表了每一個 case 都只有做 allocation 一次,行為上是與原先不同的,但是只要實驗時候每一個 case 的變因控制在 C++ exception 就好了,其他部份怎麼改並不重要。

static void no_exception(benchmark::State &state) {
    for (auto _ : state) {
        static std::vector<uint32_t> v(10000);
        for (uint32_t i = 0; i < 10000; i++) {
            v.at(i) = i;
        }
    }
}
BENCHMARK(no_exception);//----------------------------------------

static void with_exception(benchmark::State &state) {
    for (auto _ : state) {
        static std::vector<uint32_t> v(10000);
        for (uint32_t i = 0; i < 10000; i++) {
            try {
                v.at(i) = i;
            } catch (const std::out_of_range &oor) {
            }
        }
    }
}
BENCHMARK(with_exception);//----------------------------------------

static void throw_exception(benchmark::State &state) {
    for (auto _ : state) {
        static std::vector<uint32_t> v(10000);
        for (uint32_t i = 0; i < 10001; i++) {
            try {
                // it throws exception when i = 10000
                v.at(i) = i;
            } catch (const std::out_of_range &oor) {
            }
        }
    }
}
BENCHMARK(throw_exception);//----------------------------------------

好,再次丟上去 quick-bench.com 測試看看!要記得把 -O2 打開 XD

結果看起來合理多了,而且生成的組合語言顯示 allocation 沒有被切掉了,塞數字的 mov 指令也在。

照這樣的結果,可以下結論了嗎?還不行,還有一個因素要考慮:hotspot vs coldspot。

實驗二

考慮另一個實驗程式碼(其實只是換湯不換藥XD),這兩個 cases 看起來很類似,邏輯上做同樣的事情(而且不 throw exception)

差別只有:

  1. test_inner 是把 try-catch block 放在內層迴圈裡面(hotspot)
  2. test_outer 則是把 try-catch block 放在內層迴圈外面(coldspot)
static void test_inner(benchmark::State &state) {
    for (auto _ : state) {
        static std::vector<uint32_t> v(10000);
        for (uint32_t i = 0; i < 10000; i++) {
            try {
                v.at(i) = i;
            } catch (const std::out_of_range &oor) {
            }
        }
    }
}
BENCHMARK(test_inner);//----------------------------------------

static void test_outer(benchmark::State &state) {
    for (auto _ : state) {
        static std::vector<uint32_t> v(10000);
        try {
            for (uint32_t i = 0; i < 10000; i++) {
                v.at(i) = i;
            }
        } catch (const std::out_of_range &oor) {
        }
    }
}
BENCHMARK(test_outer);//----------------------------------------

好,再把上面的程式碼丟上去 quick-bench.com 測看看!(別忘了把 -O2 打開)

我的老天,邏輯上相同的程式碼的效能差這麼多,test_innertest_outer 慢上一倍… 可見 try-catch block 的位置會明顯影響效能。

實驗三

所以,把這些因素考慮進去,重新測試一下此實驗:

static void no_exception(benchmark::State &state) {
    for (auto _ : state) {
        static std::vector<uint32_t> v(10000);
        for (uint32_t i = 0; i < 10000; i++) {
            v.at(i) = i;
        }
    }
}
BENCHMARK(no_exception);//----------------------------------------

static void with_exception_inner(benchmark::State &state) {
    for (auto _ : state) {
        static std::vector<uint32_t> v(10000);
        for (uint32_t i = 0; i < 10000; i++) {
            try {
                v.at(i) = i;
            } catch (const std::out_of_range &oor) {
            }
        }
    }
}
BENCHMARK(with_exception_inner);//----------------------------------------

static void throw_exception_inner(benchmark::State &state) {
    for (auto _ : state) {
        static std::vector<uint32_t> v(10000);
        for (uint32_t i = 0; i < 10001; i++) {
            try {
                // it throws exception when i = 10000
                v.at(i) = i;
            } catch (const std::out_of_range &oor) {
            }
        }
    }
}
BENCHMARK(throw_exception_inner);//----------------------------------------

static void with_exception_outer(benchmark::State &state) {
    for (auto _ : state) {
        static std::vector<uint32_t> v(10000);
        try {
            for (uint32_t i = 0; i < 10000; i++) {
                v.at(i) = i;
            }
        } catch (const std::out_of_range &oor) {
        }
    }
}
BENCHMARK(with_exception_outer);//----------------------------------------

static void throw_exception_outer(benchmark::State &state) {
    for (auto _ : state) {
        static std::vector<uint32_t> v(10000);
        try {
            for (uint32_t i = 0; i < 10001; i++) {
                // it throws exception when i = 10000
                v.at(i) = i;
            }
        } catch (const std::out_of_range &oor) {
        }
    }
}
BENCHMARK(throw_exception_outer);//----------------------------------------

由實驗三的結果來看:

  1. try-catch block 放在 hotspot 之外,且不 throw exception 的情況下,效能是與 no exception 差不多的(甚至微乎其微)
  2. throw exception 的確會讓效能掉很多
  3. try-catch block 放在 hotspot 會明顯影響效能

討論

統整以上三個觀點,我可以說:

  1. C++ exception 本來設計是用來處理 未預期情況發生而導致無法正常完成 的,不應該用在程式邏輯的控制(像是 long jmp 之類的),發生 throw exception 頻率不應該太高,所以偶爾的效能降低還在可接受範圍內
  2. 同理,try-catch block 本身應該放在程式 hotspot 的外圍,就不會太大影響效能

順便一提,Python 就是用 StopIteration exception 去跳出 iterator 迴圈的,我個人覺得這樣的設計很容易誤導人,因為 exception 照字面來看就是程式發生意外未能完成的意思,但是正常跑完一個迴圈根本不算 exception… XD

總結來說,如果正確使用 C++ exception,它就不會傷害整體的效能。(蓋章)

參考資料

這位仁兄早就有做測試效能了(我也是借用一下他的測試程式),但可惜他沒有進一步研究 compiler optimization 的狀況,也沒有考慮到 coldspot vs hotspot 情況。所以我打算自己測試看看,就有了這篇文章 XD

comments powered by Disqus