
C++ の非 static メンバ参照はローカル変数へキャッシュした方が速いのか、MSVC で検証した
はじめに
C++ でループ処理を書くとき「非 static メンバ変数をループ内で毎回直接参照せず、先にローカル変数へ取り出してから使った方がよい」と言われることがあります。
例:
遅いと言われるコード
for (int i = 0; i < count; ++i) {
sum += this->member_;
}
修正後のコード
const int value = this->member_;
for (int i = 0; i < count; ++i) {
sum += value;
}
ところが、現代のコンパイラはかなり賢くなっています。MSVC では、この注意が常に妥当とは限りません。本記事では、MSVC でベンチマークを行い、生成されたアセンブリを確認しながら、次の疑問について検証します。
- 直接参照とローカルキャッシュで実行時間差は出るのか
- Debug と Release で傾向は変わるのか
- どのような条件なら差が出やすいのか
MSVC とは
MSVC は Microsoft Visual C++ Compiler の略です。Visual Studio に含まれる C++ コンパイラであり、Windows 環境で広く使われています。本記事では、C++ の一般論を広く扱うのではなく、MSVC が非 static メンバ変数の参照を実際にどのように最適化するかを確認します。
検証環境
- OS: Windows
- Compiler: MSVC 19.44.35223
- Architecture: x64
- Build Configurations: Debug / Release / AsmRelease
- 計測方法:
std::chrono::steady_clock
対象読者
- C++ のパフォーマンス最適化に関心がある方
- MSVC の最適化挙動を知りたい方
- 昔から言われる注意が、今でもそのまま通用するのか確かめたい方
参考
- MSVC Compiler Options
- Compiler options listed by category
- /FA, /Fa (Listing file)
- /Ob (Inline Function Expansion)
検証方針
今回の検証では、単にベンチマークの数値だけを見て結論を出さないようにしました。数値差があっても、それが本当にメンバ参照の差なのか、あるいは別のコード形状の差なのかが分からないためです。
実行時間の計測とアセンブリの確認を組み合わせて進める方針とします。まず、Build Configuration として Debug、Release、AsmRelease を用意しました。AsmRelease を用意したのは、Release 相当の最適化を維持したまま、アセンブリを確認するためです。
まずベースライン実験として単純なケースから測定し、その後、差が出やすそうな条件を追加実験として確かめました。実行時間を比較するだけではなく、AsmRelease のアセンブリ出力を見て、ループ内で毎回ロードが残っているのか、それともループ前に値が取り出されてレジスタ保持されているのかについても確認しました。
ベースライン実験では、以下 4 系統を比較しました。
- 単純ループ
- やや複雑なループ
- noinline 関数呼び出しあり
- エイリアス風ケース
また、追加実験として、以下のケースについても確認しました。
thisを外部関数へ渡して、コンパイラが保守的になりやすいケースthis->memberとobj.memberの比較- inline getter と noinline getter の比較
10,000,000 回の反復で 7 回測定し、最小値を観察しました。今回はノイズの影響を受けにくい下限寄りの傾向を見るため、7 回測定したうちの最小値を掲載しています。厳密な統計比較ではなく、アセンブリの差と整合するかを確認するための指標として扱います。
実験に使ったコードの抜粋
以下は検証コード全体から、各系統の本質部分だけを抜き出したものです。説明を簡潔にするため、関数名や周辺定義は一部省略しています。
// 1. 単純ループ
__declspec(noinline) [[nodiscard]] std::uint64_t direct_simple(std::uint64_t iterations) const {
std::uint64_t acc = 0;
for (std::uint64_t i = 0; i < iterations; ++i) {
acc += static_cast<std::uint64_t>(data_[static_cast<std::size_t>(i % data_.size())]) + member_;
}
return acc;
}
// 2. やや複雑なループ
__declspec(noinline) [[nodiscard]] std::uint64_t direct_complex(std::uint64_t iterations) const {
std::uint64_t acc = 0;
for (std::uint64_t i = 0; i < iterations; ++i) {
const auto value = static_cast<std::uint64_t>(data_[static_cast<std::size_t>(i % data_.size())]);
if ((i & 1U) == 0) {
acc += (value * 3U) + member_;
} else {
acc ^= (value + member_) << (i & 7U);
}
acc += (value ^ member_) & 0x1FU;
}
return acc;
}
// 3. noinline 関数呼び出しあり
__declspec(noinline) [[nodiscard]] std::uint64_t direct_with_call(std::uint64_t iterations) const {
std::uint64_t acc = 0;
for (std::uint64_t i = 0; i < iterations; ++i) {
const auto value = static_cast<std::uint64_t>(data_[static_cast<std::size_t>(i % data_.size())]);
acc += add_with_barrier(value, member_);
}
return acc;
}
// 4. エイリアス風ケース
__declspec(noinline) [[nodiscard]] std::uint64_t direct_alias_like(std::uint64_t iterations) const {
std::uint64_t acc = 0;
const auto* self = this;
for (std::uint64_t i = 0; i < iterations; ++i) {
const auto value = static_cast<std::uint64_t>(data_[static_cast<std::size_t>(i % data_.size())]);
touch_pointer(self);
acc += self->member_ + value;
}
return acc;
}
// 5. this を外へ逃がすケース
__declspec(noinline) [[nodiscard]] std::uint64_t direct_this_escape(std::uint64_t iterations) {
std::uint64_t acc = 0;
for (std::uint64_t i = 0; i < iterations; ++i) {
const auto value = static_cast<std::uint64_t>(data_[static_cast<std::size_t>(i % data_.size())]);
observe_nonconst(this);
acc += member_ + value;
}
return acc;
}
// 6. this->member と obj.member の比較: this->member
__declspec(noinline) [[nodiscard]] std::uint64_t direct_simple(std::uint64_t iterations) const {
std::uint64_t acc = 0;
for (std::uint64_t i = 0; i < iterations; ++i) {
acc += static_cast<std::uint64_t>(data_[static_cast<std::size_t>(i % data_.size())]) + member_;
}
return acc;
}
// 7. this->member と obj.member の比較: obj.member
__declspec(noinline) std::uint64_t direct_ref_simple(const BenchTarget& target, std::uint64_t iterations) {
std::uint64_t acc = 0;
for (std::uint64_t i = 0; i < iterations; ++i) {
acc += static_cast<std::uint64_t>(target.data_[static_cast<std::size_t>(i % target.data_.size())]) + target.member_;
}
return acc;
}
// 8. inline getter と noinline getter の比較: inline getter
__declspec(noinline) [[nodiscard]] std::uint64_t inline_getter_loop(std::uint64_t iterations) const {
std::uint64_t acc = 0;
for (std::uint64_t i = 0; i < iterations; ++i) {
acc += static_cast<std::uint64_t>(data_[static_cast<std::size_t>(i % data_.size())]) + inline_getter();
}
return acc;
}
// 9. inline getter と noinline getter の比較: noinline getter
__declspec(noinline) [[nodiscard]] std::uint64_t noinline_getter_loop(std::uint64_t iterations) const {
std::uint64_t acc = 0;
for (std::uint64_t i = 0; i < iterations; ++i) {
acc += static_cast<std::uint64_t>(data_[static_cast<std::size_t>(i % data_.size())]) + noinline_getter();
}
return acc;
}
ベースライン実験
まずは、もっとも基本的な比較から見ます。単純な this->member の直接参照が、それだけで本当に遅いのか観察します。
Release の結果
| 系統 | 直接参照 | ローカルキャッシュ | 差分 |
|---|---|---|---|
| 単純ループ | 23.145 ms | 22.616 ms | 0.529 ms |
| やや複雑なループ | 24.163 ms | 22.753 ms | 1.410 ms |
| noinline 関数呼び出しあり | 23.061 ms | 22.623 ms | 0.438 ms |
| エイリアス風ケース | 22.510 ms | 22.759 ms | -0.249 ms |
Release では、4 系統すべてで差はかなり小さく、どちらかが一貫して有利という結果にもなりませんでした。単純ループ、noinline 関数呼び出しあり、エイリアス風ケースではほぼ同等でした。やや複雑なループだけローカルキャッシュ側が少し有利でしたが、これも劇的な差ではありません。
Debug の結果
| 系統 | 直接参照 | ローカルキャッシュ | 差分 |
|---|---|---|---|
| 単純ループ | 61.166 ms | 59.519 ms | 1.647 ms |
| やや複雑なループ | 68.370 ms | 66.793 ms | 1.577 ms |
| noinline 関数呼び出しあり | 78.261 ms | 77.087 ms | 1.174 ms |
| エイリアス風ケース | 78.001 ms | 77.391 ms | 0.610 ms |
Debug では 4 系統すべてでローカルキャッシュ側がわずかに速くなりました。ただし差は数 % 程度で、ここでも直接参照が極端に遅いわけではありません。Release では差がほぼ消え、Debug では少し見えやすくなる、というのがベースライン実験の全体としての評価です。
アセンブリの観察
ベースライン実験では、数値だけではなく AsmRelease の出力も確認しました。ここでは、4 系統それぞれで何が起きていたのかを順に見ます。
単純ループ
単純ループでは、直接参照でもローカルキャッシュでもメンバはループ前に 1 回だけロードされ、その後はレジスタ値が使われていました。
; 直接参照
mov r11d, DWORD PTR [rcx]
...
add rax, r11
; ローカルキャッシュ
mov edi, DWORD PTR [rcx]
...
add rax, rdi
コード上は直接参照とローカルキャッシュで書き方が違っていても、アセンブリはほぼ同じです。少なくともこのケースでは、単純に「this->member をループ内で読んでいるから遅い」とは言えません。
やや複雑なループ
やや複雑なループでも、メンバはループ前ロードになっていました。分岐や追加演算があっても、今回の形では MSVC は値を保持できています。
; 直接参照
mov r10d, DWORD PTR [rcx]
...
lea eax, [r10+rax*3]
...
mov ecx, r10d
; ローカルキャッシュ
mov esi, DWORD PTR [rcx]
...
lea eax, [rsi+rax*3]
...
mov ecx, esi
この系統でも、直接参照だけが毎回メモリを読み直すような形にはなっていませんでした。「やや複雑なループだからといって即座に不利になるわけではない」ということが分かります。
noinline 関数呼び出しあり
noinline 関数呼び出しを入れたケースでも、メンバはループ前に取り出されていました。呼び出しごとにメンバを再ロードしているわけではありません。
; 直接参照
mov edi, DWORD PTR [rcx]
...
mov edx, edi
call add_with_barrier
; ローカルキャッシュ
mov esi, DWORD PTR [rcx]
...
mov edx, esi
call add_with_barrier
この程度の noinline 呼び出しだけでは、直接参照に不利なアセンブリにはなりませんでした。ここも直接参照とローカルキャッシュの差はかなり小さいです。
エイリアス風ケース
アセンブリ上の差がはっきり現れたのは、エイリアス風ケースです。touch_pointer(self) をまたいだ後に直接メンバ参照する方式では、ループ内再ロードが残りました。
; 直接参照
call touch_pointer
mov ecx, DWORD PTR [r11]
...
add rax, rcx
; ローカルキャッシュ
mov edi, DWORD PTR [rcx]
...
call touch_pointer
lea rcx, [rdi+r8]
このケースだけは、直接参照とローカルキャッシュでアセンブリの差がはっきり出ました。ただし、それでも実測差は限定的でした。アセンブリの差が見えたことと、実行時間差が大きいことは別だと分かります。
ベースラインの 4 系統を通して見ると、3 系統では直接参照とローカルキャッシュのアセンブリはほぼ同じで、差が明確に見えたのはエイリアス風ケースだけでした。少なくとも今回の MSVC では、差を議論するとき「どのケースでループ内再ロードが残るのか」まで見ないと判断を誤りやすいです。
追加実験
ベースライン実験だけを見ると、Debug ではローカルキャッシュがやや効果があるという傾向は見えるものの、まだ十分には分からないといった印象でした。そこで追加実験では、差が出そうな条件を意図的に作りました。
Release の結果
| 系統 | 分類 | 直接参照 | ローカルキャッシュ | 差分 |
|---|---|---|---|---|
this を外部関数へ渡して、コンパイラが保守的になりやすいケース |
- | 22.934 ms | 22.680 ms | 0.254 ms |
this->member と obj.member の比較 |
this->member |
23.774 ms | 22.847 ms | 0.927 ms |
this->member と obj.member の比較 |
obj.member |
23.073 ms | 22.499 ms | 0.574 ms |
| inline getter と noinline getter の比較 | inline getter | 22.461 ms | 22.590 ms | -0.129 ms |
| inline getter と noinline getter の比較 | noinline getter | 22.159 ms | 22.286 ms | -0.127 ms |
追加実験でも全体として差はかなり小さいままでした。this を外部関数へ渡すケースではコード生成上の差がありましたが、実測差は限定的です。this->member と obj.member の比較も getter 系も、この構成では大きな差になりませんでした。
Debug の結果
| 系統 | 分類 | 直接参照 | ローカルキャッシュ | 差分 |
|---|---|---|---|---|
this を外部関数へ渡して、コンパイラが保守的になりやすいケース |
- | 81.551 ms | 80.794 ms | 0.757 ms |
this->member と obj.member の比較 |
this->member |
60.696 ms | 62.371 ms | -1.675 ms |
this->member と obj.member の比較 |
obj.member |
60.916 ms | 59.882 ms | 1.034 ms |
| inline getter と noinline getter の比較 | inline getter | 82.071 ms | 60.007 ms | 22.064 ms |
| inline getter と noinline getter の比較 | noinline getter | 84.161 ms | 60.834 ms | 23.327 ms |
Debug では getter 系の差が目立ちました。inline getter と noinline getter はどちらもローカルキャッシュの方がかなり速く、ループ内で毎回 getter を呼ぶ形のコストがそのまま表に出ています。ただし、getter 系で見えている差は、非 static メンバ参照そのものの差というより、getter 呼び出しがループ内に残るかどうかの差として解釈するのが適切です。
アセンブリの観察
追加実験でも、数値差だけではなく AsmRelease の出力を確認しました。
this を外へ逃がすケース
このケースでは、直接参照だけループ内再ロードが残りました。observe_nonconst(this) をまたぐため、コンパイラがメンバを安全に保持しにくくなっていると考えられます。
; 直接参照
call observe_nonconst
mov ecx, DWORD PTR [r11]
...
add rax, rcx
一方で、ローカルキャッシュではループ前に値を取り出したままでした。
; ローカルキャッシュ
mov edi, DWORD PTR [rcx]
...
call observe_nonconst
lea rcx, [rdi+r8]
アセンブリ上では直接参照とローカルキャッシュの差がはっきりありますが、実測差はまだ小さく、アセンブリの差がそのまま大きな実行時間差になるわけではありませんでした。
this->member と obj.member の比較
obj.member の比較では、直接参照でもメンバはループ前ロードになっていました。ベースラインの this->member と同じ方向の最適化です。
; 直接参照
mov r11d, DWORD PTR [rcx]
...
add rax, r11
ローカルキャッシュでも同様で、ループ内再ロードはありません。
; ローカルキャッシュ
mov edi, DWORD PTR [rcx]
...
add rax, rdi
この結果から、今回の単純ケースでは this->member と obj.member の違いそのものが支配的ではないと分かります。問題は this か obj かではなく、コンパイラがその値をループ不変とみなせるかどうかだと
考えられます。
inline getter
inline getter も、最終的には直接参照とほぼ同じコードへ潰れていました。getter 呼び出しがループ内に残ることはありません。
; 直接参照
mov r11d, DWORD PTR [rcx]
...
add rax, r11
ローカルキャッシュでも、やはりループ前ロードになっています。
; ローカルキャッシュ
mov edi, DWORD PTR [rcx]
...
add rax, rdi
inline getter は今回の条件では単純なメンバ参照とほぼ同じ扱いでした。
noinline getter
もっとも分かりやすかったのは noinline getter です。 noinline_getter_loop では、ループ内に毎回 call noinline_getter が残りました。
; 直接参照
...
call noinline_getter
mov ecx, eax
add rax, rcx
noinline_getter 自体は単純で、実際にはメンバを読むだけの関数です。
; 直接参照
mov eax, DWORD PTR [rcx]
ret 0
一方でローカルキャッシュでは、ループ前に 1 回だけ getter を呼び、その戻り値を使い回していました。
; ローカルキャッシュ
call noinline_getter
mov edi, eax
...
add rax, rdi
アセンブリの差がかなり明確に出た一方、Release ではそれでも実測差が小さいままだったので、実行時間はアセンブリ差だけで決まらないということも分かります。
検証から分かったこと
まず、Release では昔の注意をそのまま一般化しにくいです。少なくとも今回の MSVC では、単純な this->member、obj.member、inline getter は最適化され、ローカルキャッシュとほぼ同じコードになることが多かったです。
一方で、Debug や、this を外へ逃がすケース、noinline getter のようにコンパイラが値を保持しにくい形では、差が出ました。本質はメンバ変数だから遅いではなく、コンパイラがその値をループ不変として扱えるかどうかにあると考えられます。実務上は、普段から可読性を犠牲にして毎回ローカル変数へ取り出す必要は薄いでしょう。
まとめ
今回の検証では、現代の MSVC において「非 static メンバ変数をループ内で直接参照すると常に遅い」とは言えないことが分かりました。Release では多くのケースで十分に最適化され、一方で、Debug や this エスケープ、noinline getter のような形では差が残りました。結局のところ、判断基準にすべきなのはメンバ変数かどうかではなく、コンパイラがその値を保持できるコード形状かどうかだと考えられます。







