新C言語教室 - 第28回 最適化 その6 inline の罠
既に最適化のひとつとして、関数を呼び出すのではなく埋め込むことでパフォーマンスを上げる例が出てきましたが、これをコンパイラに指示する inline について調べておきたいと思います。
元々は関数呼び出しのオーバーヘッドを減らしたいという要求に応えるために gcc や msvc で独自のキーワードとして __inline__ が追加されたのですが、これが実装されていないこともあるので、このキーワードを #define で有効にするかどうかを切り替えられるように使うのが一般的でした。
つまりC言語における inline とは、関数を呼び出す箇所にその関数の本体を直接埋め込む(インライン展開)よう、コンパイラに促すための関数指定子です。あくまで促すだけなので実際に関数が展開して埋め込まれるのか、普通の関数のままになるかはコンパイラ次第です。インライン展開のメリットとしては、通常の関数呼び出しで発生する、引数の受け渡しや戻り先情報の保存といったオーバーヘッドを排除できるほか、呼び出し元と呼び出し先のコードが一体化されることにより、さらなる最適化を図ることも可能となります。デメリットとしては呼び出される場所ごとに同じコードが埋め込まれるわけですから、全体としてコードサイズが大きくなります。
インライン関数
もちろんC言語の場合は、プリプロセッサでコードブロック自体を埋め込むことも不可能は無いのですが(この場合は絶対にインライン化されます)、あくまで文字列を埋め込むに過ぎないので、慎重にコードを書かないと意図しない変数の衝突や意図しない解釈が行われる可能性が残ります(まだ inline というものが無かった時代には使われていた)。
これがC99で正式に inline キーワードが採用されたのですが、他のファイルに存在する関数を inline 化するのが難しいので、例外的にヘッダファイルで実体を書くという(二重定義を避けるために static を併用する)形が使われることになりました。
もう少し丁寧に説明すると、コンパイルとリンクの仕組みに関わるのですが、コンパイルはソースファイル一つに対して一つの中間ファイルを生成し、最後にすべての中間ファイルをリンクして実行ファイルを作るのですが、この仕組みの制限として inline 指定が活きるのは同じファイルの中だけになってしまいます。他のファイルにある関数が inline 指定してあってもコンパイラはその関数のソースコードにアクセスすることは無いのでインライン展開することはありません。ですから複数のファイルから呼び出されるような関数をインライン展開したいのであれば、関数をヘッダファイルに書いてインクルードして使うという形を取ります。
インクルードという仕組みは単にファイルの内容を文字列的に埋め込むというものに過ぎないので、何を書いても構わないのですが、一般論として定義や宣言のみを書いて実体を生成するようなコードは書きません。これは実体があると、それをインクルードするファイルすべてに、その実体が置かれることとなり、これがグローバルに有効な名前であれば二重定義としてエラーになるからです。ここで困るのが先の inline な関数をヘッダに置いた場合、もしコンパイラがインライン展開を行わず実体を生成した場合には、この二重定義エラーが発生してしまうことです。これを避ける巧妙なテクニックとしてヘッダに書く inline な関数を static 関数にすることです。もともと static 関数は同じファイルのみで有効な関数なので他のファイルに同じ名前の関数があっても影響しません。ですからインクルードしたファイル全てで同じ static 関数の実体が生成されるのですが、エラーとならずに意図したとおりに動くはずです。
そういえば最適化の説明で使った今までの例では、特に inline 指定を行わなくても呼び出す関数が単純なものであれば最適化の指定により自動的にインライン展開されます。面白いのが呼び出し先の関数が static ではない為に、他のファイルから extern される可能性に備えて、そのファイル内で呼び出されることが無くなった展開前の関数「も」中間コードが生成されていることです。この辺りからもう少し深堀りしてみましょう。
#include <stdio.h>
int square(int x) {
return x * x;
}
int main() {
int result = square(5);
printf(”%d\n”, result);
return 0;
}※inline1.c
$ gcc -g -O3 -o inline1 inline1.c
$ ./inline1
25
デバッガでコードを覗いてみると、実行ファイルにおいても呼び出されない square 関数のコードは残っていました。
Dump of assembler code for function main:
0x0000000000001060 <+0>: endbr64
0x0000000000001064 <+4>: sub $0x8,%rsp
0x0000000000001068 <+8>: mov $0x19,%edx
0x000000000000106d <+13>: mov $0x1,%edi
0x0000000000001072 <+18>: xor %eax,%eax
0x0000000000001074 <+20>: lea 0xf89(%rip),%rsi # 0x2004
0x000000000000107b <+27>: call 0x1050 __printf_chk@plt
0x0000000000001080 <+32>: xor %eax,%eax
0x0000000000001082 <+34>: add $0x8,%rsp
0x0000000000001086 <+38>: retDump of assembler code for function square:
0x0000000000001180 <+0>: endbr64
0x0000000000001184 <+4>: mov %edi,%eax
0x0000000000001186 <+6>: imul %edi,%eax
0x0000000000001189 <+9>: ret最近のコンパイラでは、LTO(リンク時最適化)という機能が実装されるようになったために、inline はあくまでヒントであって基本的にはインライン展開するかどうかはコンパイラに任せるというのが基本となりました。LTOの効果を確認します。
int square(int x) {
return x * x;
}※square.c
#include <stdio.h>
extern int square(int);
int main() {
int result = square(5);
printf(”%d\n”, result);
return 0;
}※inline2.c
$ gcc -g -O3 -o inline2 square.c inline2.c
$ ./inline2
25
Dump of assembler code for function main:
0x0000000000001060 <+0>: endbr64
0x0000000000001064 <+4>: sub $0x8,%rsp
0x0000000000001068 <+8>: mov $0x5,%edi
0x000000000000106d <+13>: call 0x1180 <square>
0x0000000000001072 <+18>: lea 0xf8b(%rip),%rsi # 0x2004
0x0000000000001079 <+25>: mov $0x1,%edi
0x000000000000107e <+30>: mov %eax,%edx
0x0000000000001080 <+32>: xor %eax,%eax
0x0000000000001082 <+34>: call 0x1050 [__printf_chk@plt](mailto:__printf_chk@plt)
0x0000000000001087 <+39>: xor %eax,%eax
0x0000000000001089 <+41>: add $0x8,%rsp
0x000000000000108d <+45>: ret
Dump of assembler code for function square:
0x0000000000001180 <+0>: endbr64
0x0000000000001184 <+4>: mov %edi,%eax
0x0000000000001186 <+6>: imul %edi,%eax
0x0000000000001189 <+9>: retよく使う -O3 の最適化では、異なるファイルにある関数をインライン展開していません。これをLTOを行うことを指定してコンパイルしてみます。
$ gcc -g -O3 -flto -o inline2 square.c inline2.c
Dump of assembler code for function main:
0x0000000000001060 <+0>: endbr64
0x0000000000001064 <+4>: sub $0x8,%rsp
0x0000000000001068 <+8>: mov $0x19,%edx
0x000000000000106d <+13>: mov $0x1,%edi
0x0000000000001072 <+18>: xor %eax,%eax
0x0000000000001074 <+20>: lea 0xf89(%rip),%rsi # 0x2004
0x000000000000107b <+27>: call 0x1050 [__printf_chk@plt](mailto:__printf_chk@plt)
0x0000000000001080 <+32>: xor %eax,%eax
0x0000000000001082 <+34>: add $0x8,%rsp
0x0000000000001086 <+38>: ret
(gdb) disassemble square
No symbol "square" in current context.main にあった関数呼び出しはインライン化され、呼び出し先の square 関数は跡形も無くなっていました。
このように inline キーワードの役割がいわゆる「ヒント」にやや格下げになったのですが、inline といったコンパイラに対する指示に関しては、キーワード指定だけではなく標準化された機能以上に詳細な指定を行う必要も出てくるので、コンパイラや環境に固有のプリプロセッサレベルで #pragma を指定することもあります。例えば msvc の場合は #pragma inline depth(8) とすることで、インライン展開の階層を制限することも出来ます。ただあまりに汎用性が無く、これを見落とすことも起こりやすいので、gcc などでは関数の「属性」として記述することが行われます。
例えば、これは最適化レベルによらず展開して欲しい関数には
__attribute__ ((always inline)) int add(int a, int b) { return a+b; }のように記述します。これとは逆に展開を禁止したければ、
__attribute__ ((noinline)) int add(int a, int b) { return a+b; }と書けば良いわけです。これはプログラマの意図とは異なる選択をコンパイラがした場合に、意図を強制させるのに使うわけです。もちろん、いずれもコンパイラ依存ですので、直接、これらをコードに書き込むのではなく、マクロなどで環境を判断して適切な文字列になるようにして使うのが基本です。

NotebookLMによるスライド資料
今や特に inline といった指定をする必要はなく、コンパイラの判断に任せるのが基本ですが、繰り返しループされるコードの中であるとか、特に速度が必要となる部分に関しては、こういった指定の効果は馬鹿になりませんし、インライン化されることで何らかのトラブルが起きる場合に、最適化自体を抑制するのではなく、インライン展開のみ禁止することで解決することは出てくるでしょう。
このインラインの話は C++ でより大事になる話ではあるんですよね。最適化のアレコレはまだ数多くあるのですが、かなりややこしい話にもなってきたので、ここいらで一旦、区切りを付けたいと思います。
Soraによるダイジェスト動画
NotebookLMによる解説動画
NotebookLMによる音声解説
ヘッダ画像は、Geminiによるもの。
いいなと思ったら応援しよう!
頂いたチップは記事を書くための資料を揃えるために使わせていただきます!