目次


OpenPOWER ABI で関数コールのパフォーマンスを向上させる

OpenPOWER ABI Supplement を使用した場合の関数コールの調査

Comments

はじめに

ABI (Application Binary Interface: アプリケーション・バイナリー・インターフェース) とは、2 つのプログラム・モジュールの間を取り持つインターフェースのことです。プログラム・モジュールのうちの一方が、マシン・コード・レベルでのライブラリーやオペレーティング・システムであることもよくあります。ABI は、作成した言語やコンパイルに使用したコンパイラーが異なるプログラム同士がそれぞれの関数を互いに呼び出せるようにするための一連の規則を規定します。

これまでの 64-bit PowerPC ELF Application Binary Interface Supplement 1.9 に代わるものとして、2014年に IBM PowerPC 64 ビット (リトル・エンディアン) システムと OpenPOWER システムを対象とした新しい ABI 定義が公開されました。この ABI は、全体的なアプリケーション・パフォーマンスを向上させるために、かなりの改善が加えられています。そのうち最も重要な改善は、関数記述子を排除していることです。ELF ABI v1 とは異なり、この ABI は呼び出される側に TOC (Table Of Contents: 目次) ポインター (TOC BASE) を設定させるとともに、モジュール内呼び出しでの冗長なオーバーヘッドを避けるためにデュアル・エントリーを導入しています。さらに、レキシカルにネストしている関数コールも、今までとは異なる手法でサポートするようになっています。この ABI が関数コールをどのように機能強化しているかに関心がある読者や、システム・ソフトウェア・エンジニア、ライブラリー作成者、OpenPOWER のアセンブリー・コード作成者などにとっては、この記事が興味深いものになるはずです。

以前の ABI での関数記述子


関数記述子の定義

関数記述子とは、3 つのダブルワードからなるデータ構造体です。それぞれのダブルワードには、以下の値が格納されます。

  • 1 番目のダブルワードには、関数のエントリー・ポイントのアドレスが格納されます。
  • 2 番目のダブルワードには、関数の TOC ベース・アドレスが格納されます。
  • 3 番目のダブルワードには、Pascal や PL/1 などといった言語の環境ポインターが格納されます。

上記の関数記述子の定義は、64-bit PowerPC ELF Application Binary Interface Supplement 1.9 仕様からの引用です。関数記述子は、関数の静的環境および動的環境に関する情報を保持します。1 番目のダブルワードは実際の関数エントリーであり、2 番目のダブルワードは PIC (Position-Independent Code) データ・エリアを参照する TOC のベースです。3 番目のダブルワードは、レキシカルにネストしている環境を提供するためにあります。

外部から見える関数の場合、その関数と同じ名前を持つシンボルの値が、関数記述子のアドレスとなります。ドット (.) で始まるシンボル名は、エントリー・ポイント・アドレスの保持用に予約されています。例えば、「.FN」という名前のシンボルがある場合、そのシンボルの値が、関数「FN」のエントリー・ポイントということになります。

関数記述子の例

これまでの ABI では、モジュール間呼び出しや関数ポインター呼び出しを行う際に、関数記述子を使用しなければなりません。呼び出される関数は、自身の TOC ポインターと環境ポインターを初期化しないことから、関数を呼び出すために関数記述子の値を設定するのは、呼び出す側の役目となります。以下の例を参照してください。

リスト 1. 関数記述子を使用したモジュール間呼び出し
$ cat func1.c
	int func1(int val)
	{
	   int ret = test() + val;
	   return ret;
	}

$ xlc -q64 -qpic -qmkshrobj func1.c -o func1.so

$ objdump -d func1.so
	...
	0000000000000700 <00000010.plt_call.test+0>:
		// Save the TOC pointer of the func1's module
	700:   f8 41 00 28     std     r2,40(r1)
		// Load the actual function entry of test into r11
	704:   e9 62 80 98     ld      r11,-32616(r2)
		// Move r11 into Count Register (function entry)      
	708:   7d 69 03 a6     mtctr   r11                
		// Load the environment pointer of test into r11
	70c:   e9 62 80 a8     ld      r11,-32600(r2)
		// Load the TOC pointer of test's module into r2
	710:   e8 42 80 a0     ld      r2,-32608(r2)
		// Jump as Count Register
	714:   4e 80 04 20     bctr
	...

上記では、静的リンカーが test の関数記述子に対して、アドレス $r2-32616 から 24 バイト分のストレージを割り当てます。それぞれに対応する値は、後で動的リンカーが設定します。関連する情報は、オブジェクト・ファイルの .opd セクションに保管されます。これは、関数記述子の配列です。詳細については、「Deeply understand 64-bit PowerPC ELF ABI - Function Descriptors」を参照してください。

リスト 1 を見るとわかるように、モジュール間呼び出しが行われると、PLT (Procedure Linkage Table) スタブがまず、呼び出される側の環境を準備します。その後、呼び出される側の関数記述子から抽出した情報を使用して、TOC ポインターと環境ポインターを設定します。これが終わると、呼び出される側の関数エントリーまでジャンプします。

リスト 2. 関数記述子を使用した間接呼び出し
$ cat func2.c
	int test();
	int func2(int val)
	{
	   int (*p)() = &test;
	   int ret = p() + val;
	   return ret;
	}
	
$ xlc -q64 -qpic func2.c -o func2.o -c
$ objdump -d func2.o
	
	0000000000000000 <.func2>:
	....
		// Load the function descriptor of test into r12
	18:   e9 82 00 00     ld      r12,0(r2)
		// Save it into automatic variable p
	1c:   f9 81 00 70     std     r12,112(r1)
		// Load the actual function entry of test into r0
	20:   e8 0c 00 00     ld      r0,0(r12)
		// Move r0 into Count Register (function entry)
	24:   7c 09 03 a6     mtctr   r0
		// Save the TOC pointer of func2's module (current module)
	28:   f8 41 00 28     std     r2,40(r1)
		// Load the environment pointer of test into r11
	2c:   e9 6c 00 10     ld      r11,16(r12)
		// Load the TOC pointer of test's module into r2
	30:   e8 4c 00 08     ld      r2,8(r12)
		// Jump as Count Register
	34:   4e 80 04 21     bctrl
		// Restore the TOC pointer of func2's module                     
	38:   e8 41 00 28     ld      r2,40(r1)          
	....

ご覧のように、間接呼び出しのプロセスは予想以上に複雑になっているように見受けられます。呼び出す側はまず、呼び出される側の関数記述子を取得した後、その関数記述子から重要な値 (TOC ポインター、関数エントリー、環境など) をロードし、その情報を実際のエントリー・アドレスとしてジャンプします。リスト 1 のモジュール間呼び出しと同じく、呼び出す側は呼び出すための環境を設定してからでないと、関数エントリーを通じて実際の呼び出しを行うことができません。

設計上の問題

上記の 2 つの例からわかるように、呼び出す側が呼び出される関数の環境を設定するにも、関数のエントリー・ポイントを計算するために値をロードする間の待ち時間にも、無視できないほどのコストがかかります。業界の現在のプラクティスと比べると、関数記述子を基にしたこの設計は時代遅れです。最近のプログラミングでは、以下の要求や変化が生じています。

  • 小さい関数を頻繁に呼び出すようになっていること: オブジェクト指向プログラミングが極めて一般的になってくるのに伴い、アプリケーション・プログラムの平均命令数は数百万個から、オブジェクト指向アプリケーションでの数十個にまで減ってきているため、関数を呼び出す度に生じる固定コストを減らすことが以前よりも重要になってきました。
  • 環境設定要件が軽減されていること: 関数記述子を使用する場合、呼び出す側が呼び出される関数の環境設定を行う必要がありますが、呼び出される関数のランタイムの振る舞いに関する情報はありません。そのため、保守的なやり方で完全な環境を設定しなければならなくなります。それよりは、どうしても必要な設定のみを行ったほうが賢明です。
  • 関数をネストするケースが少なくなっていること: プログラミング言語が急速に進化する中、最近の言語ではめったにレキシカルなネストが使われなくなっています。したがって、ほとんどの場合、環境ポインターを渡す必要はありません。
  • グローバル・データ・アクセスが少なくなっていること: 短い関数の多くはグローバル変数にアクセスしません。その場合、グローバル・アクセスのために TOC ポインターを設定する必要はありません。
  • ハードウェアが向上していること: PC 相対アドレッシングなど、今後のハードウェア・イノベーションを考慮してください。

このような状況を踏まえ、IBM Power Architecture 64-bit ELF V2 ABI (以降、OpenPOWER ABI と呼びます) では、呼び出される側に環境を初期化させることにしています。つまり、呼び出す側が関数記述子を使用して環境を設定する必要がなくなったのです。この改善は、パフォーマンスとプログラミングの向上という結果をもたらします。ここからは、OpenPOWER ABI の仕組みについて見て行きましょう。

TOC ポインターの初期化

まず、OpenPOWER ABI は、呼び出される関数に自身の TOC ポインターを初期化させ、TOC ポインターがその関数のエントリーを指すように設定させます。この仕組みをサポートするために、OpenPOWER ABI では、r12 レジスターを、関数のプロローグの先頭アドレスを格納するため専用にすることで、現在実行中の関数のエントリー・アドレスが保持されるようにすると同時に、TOC ポインターを表す .TOC. というシンボルを導入しています。リスト 3 に、初期化のコード・シーケンスを示します。

リスト 3. TOC ポインター初期化のコード・シーケンス
	l0:   addis r2, r12, (.TOC.-l0)@ha	// Calculate the upper 16 bits       
	      addi r2, r2, (.TOC.-l0)@l	        // Add the low 16 bits

上記では、ラベル l0 も関数のエントリー・アドレスを表します。この 2 行の命令によって、TOC ポインターの値は初期化されて r2 に格納されます。

上記の func1.c を OpenPOWER プラットフォーム上でコンパイルした場合、PLT スタブによって r12 が呼び出される側の関数エントリーとなることに注目してください。

リスト 4. TOC ポインターの初期化
$ xlc -qmkshrobj func1.c -o func1.so     # -qpic and -q64 are set implicitly on LE
$ objdump -d func1.so
	...	
	0000000000000650 <00000017.plt_call.test>:
		// Save the TOC pointer of the func1's module
	650:   18 00 41 f8     std     r2,24(r1)
		// Load the function entry of test into r12
		// (global entry, explain it later)      
	654:   50 80 82 e9     ld      r12,-32688(r2)    
		// Move r12 into Count Register (function entry)
	658:   a6 03 89 7d     mtctr   r12
		// Jump as Count Register
	65c:   20 04 80 4e     bctr                      
	...

リスト 1 と比べると、この PLT スタブのコード・シーケンスのほうがより簡潔に見えます。分岐命令のターゲット・アドレスが、実際の関数のエントリー・アドレスになっています。この呼び出しが関数 test にジャンプする際に、test が自身の TOC ポインターを設定します。リスト 5 を参照してください。

リスト 5. test.c による TOC ポインターの設定
$ cat test.c
    int t();
    int test(){
    return t();
}
		
$ xlc -q64 -c test.c
$ objdump -dr test.o
		
0000000000000000 <test>:
0:   00 00 4c 3c     addis   r2,r12,0
                0: R_PPC64_REL16_HA     .TOC.
4:   00 00 42 38     addi    r2,r2,0
                4: R_PPC64_REL16_LO     .TOC.+0x4
8:   a6 02 08 7c     mflr    r0
...

間接呼び出しの場合でも、コード・シーケンスは以前の ABI より短くなります。

リスト 6. 間接呼び出し
$ xlc func2.c -c
$ objdump -d func2.o

0000000000000000 <func2>:
		// Load the function entry of test into r12
		// (upper 16 bits)
	20:   00 00 82 3d     addis   r12,r2,0
		// Load the function entry of test into r12
	24:   00 00 8c e9     ld      r12,0(r12)
		// Save it into automatic variable p	
	28:   20 00 81 f9     std     r12,32(r1)
		// Move r12 into Count Register, note that
		// the r12 is holding the function entry too
	2c:   a6 03 89 7d     mtctr   r12
 		// save the TOC pointer of func2's module      
	30:   18 00 41 f8     std     r2,24(r1)
		// Jump as Count Register
	34:   21 04 80 4e     bctrl
		// Restore the TOC pointer of func2's module                
	38:   18 00 41 e8     ld      r2,24(r1)

デュアル・エントリー

ここで 1 つの疑問が浮かび上がってくることでしょう。これまでに記載した例に示されているように、TOC ポインターを設定するコード・シーケンスは、呼び出しが行われるたびに実行されます。呼び出す側が同じモジュールの関数だとしたら、TOC ポインターを設定するコストが冗長です。この点を改善することは可能なのでしょうか?

答えは可能です。この重複する TOC ポインターの初期化を回避するために、OpenPOWER ABI ではグローバル・エントリー・ポイントとローカル・エントリー・ポイントからなる「デュアル・エントリー」を導入しています。グローバル・エントリー・ポイントは、すべての呼び出す側から使用可能なエントリー・ポイントであり、これはプロローグの先頭を指しています。ローカル・エントリー・ポイントは、TOC ポインターの初期化コストを最適化するためにあります。同じモジュール内の関数は同じ TOC ベース値を共有することから、これらの関数に入るには、TOC ポインターを設定するコード・シーケンスをバイパスして、ローカル・エントリー・ポイントを使用することができます。モジュールをバインドする静的リンカーはローカル・エントリー・ポイントを使用し、実行時にシンボルを解決する動的ローダーはグローバル・エントリー・ポイントを使用します。そして、具体的な例として、関数ポインターはグローバル・エントリー・ポイントを指すことになります。これは、モジュール内呼び出しの場合もあれば、モジュール間呼び出しの場合もあるからです。

デュアル・エントリーを使用した場合、関数のプロローグはリスト 7 のようになります。

リスト 7. デュアル・エントリー
0000000000000000 <test>:
	// global entry of test  <-- inter module calls
                               OR indirect calls by function point
	0:   00 00 4c 3c     addis   r2,r12,0
	4:   00 00 42 38     addi    r2,r2,0
	// local entry of test   <-- intra module calls
	8:   a6 02 08 7c     mflr    r0
	...

デュアル・エントリーを使用すれば、TOC ポインターを必要なだけ賢く初期化することができます。特定の関数で、TOC ポインターがすでに有効であることが既知であればローカル・エントリー・ポイントを使用し、その関数の TOC ポインターを設定する必要がある場合はグローバル・エントリー・ポイントを使用するといった具合です。OpenPOWER ABI はシンボルの st_other フィールドの MSB 3 ビットを使用して、関数のグローバル・エントリー・ポイントとローカル・エントリー・ポイントの間にある命令の数を指定します。readelf を使用したリスト 8 の例を参照してください。

リスト 8. デュアル・エントリーのシンボル・テーブル・サポート
$ readelf -s test.o

	Symbol table '.symtab' contains 6 entries:
		Num:    Value          Size Type    Bind   Vis      Ndx Name
	0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
	1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS test.c
	2: 0000000000000000    68 SECTION LOCAL  DEFAULT    4 .The_Code
	3: 0000000000000000    68 FUNC    GLOBAL DEFAULT [<localentry>: 8]     4 test
	4: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND .TOC.
	5: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND t

[<localentry>: 8] は、関数のグローバル・エントリー・ポイントとローカル・エントリー・ポイントの間に 8 バイト (2 つの命令) があることを意味します。グローバル・エントリー・ポイントとローカル・エントリー・ポイントを使用する関数を含めた 1 つのアセンブリー・ファイルを作成する場合、リスト 9 のように、対応するディレクティブ .localentry を使用することができます。

リスト 9. デュアル・エントリーのアセンブリー例
test.s
	...
	.globl  test
	.type   test,@function
	.localentry test,8
	...
	test:
	0:  addis 2,12,.TOC.-0b@ha
		addi 2,2,.TOC.-0b@l
		...; function definition
		blr

環境ポインターの初期化

ここまでで、OpenPOWER ABI で呼び出される関数の TOC ポインターの初期化をサポートする方法はわかりましたが、別の疑問が出てきます。それは、レキシカルにネストしている環境の環境ポインターを扱う方法に関する疑問です。最近のプログラミング言語でレキシカルなネストが使用されることはほとんどありませんが、ABI ではレキシカルなネストを実装する必要がありました。それは、完全を期すため、そして必要に応じてレキシカルなネストを実装するスキームもまだ残っているためです。OpenPOWER ABI で提供されているソリューションは、trampoline です。基本的な発想は、実行時にネストされた関数のアドレスが取得されたときに、実行可能コード (trampoline) を生成するというものです。trampoline の目的は、実際の環境ポインターをロードして設定し、ネストされた関数の実際の関数エントリーをロードしてそこにジャンプすることにあります。trampoline を示す図 1 を参照してください。

図 1. trampoline
trampoline を示す図
trampoline を示す図

図 1 では、1 つのネストされた関数のアドレスが取得されると、ルーチン trampoline_setup がスタック上に領域を割り当ててから、命令、環境ポインターおよび関数エントリーをスタックに取り込みます。そして最後に、trampoline の開始アドレスによって、ネストされた関数を指す関数ポインターが割り当てられます。この関数ポインターでネストされた関数を呼び出すときは、trampoline の先頭から実行を開始する必要があります。trampoline は環境ポインターを設定してから、実際の関数エントリーにジャンプするためです。

まとめ

この記事では、新しい OpenPOWER ABI で関数記述子を排除するために機能強化されたスキームについて説明しました。この機能強化により、関数記述子を使わずに関数を呼び出せるようになっています。その結果、呼び出された関数が必要に応じて環境設定を行うため、呼び出す側が保守的なやり方で完全な環境を設定する必要がなくなります。そして最後に、モジュール内呼び出しとネストされた関数のサポートの最適化について検討しました。表 1 に、OpenPOWER ABI で機能強化された処理を記載します。

表 1. OpenPOWER ABI で機能強化された処理の一覧
機能強化された処理以前の ABIOpenPOWER ABI
TOC ポインターの初期化関数記述子を使用デュアル・エントリー
グローバル・エントリー・ポイント:
addis r2, r12, (.TOC.-l0)@ha
addi r2, r2, (.TOC.-l0)@l
ローカル・エントリー・ポイント:
...
環境ポインターの初期化関数記述子を使用trampoline を使用

IBM XL C/C++ V13.1.2 for Linux と IBM XL Fortran V15.1.2 for Linux では、この OpenPOWER システム ABI を十分にサポートしています。無料の試用版をダウンロードするには、「参考文献」セクションを参照してください。

参照情報


ダウンロード可能なリソース


関連トピック


コメント

コメントを登録するにはサインインあるいは登録してください。

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Linux
ArticleID=1015292
ArticleTitle=OpenPOWER ABI で関数コールのパフォーマンスを向上させる
publish-date=09242015