CLR

ARM プロセッサ向けの .NET 開発

Andrew Pardoe

 

現代のテクノロジ市場を大きく動かしているのは消費者です。「IT のコンシューマライゼーション」という傾向が証明するように、バッテリ持続時間を長くしたり、メディアを駆使した常時接続型のエクスペリエンスは、テクノロジのあらゆる消費者を意識したものです。マイクロソフトは、バッテリ持続時間の長くして、デバイスで最高のエクスペリエンスを実現するために、現代のモバイル デバイスの多くを動かしている、低出力の ARM プロセッサを基盤とするシステムに Windows 8 を導入しようとしています。今回は、Microsoft .NET Framework と ARM プロセッサの詳細と、.NET 開発者が注意すべきことを取り上げます。また、私たちマイクロソフト (私は CLR チームでプログラム マネージャーを務めています) が ARM に .NET を導入するために行わなければならなかったことについても紹介します。

.NET 開発者であれば、さまざまなプロセッサで動作するアプリを作成するのがやっかいなのは容易に想像できるでしょう。ARM プロセッサの命令セット アーキテクチャ (ISA) と、x86 プロセッサの ISA とは互換性がありません。x64 プロセッサの ISA は、x86 ISA のスーパーセットなので、x86 でネイティブに動作するよう構築されているアプリは、x64 でも適切に動作します。ところが、ARM で動作するネイティブの x86 アプリにこれは当てはまりません。互換性のないアーキテクチャで動作させるためには、それらのアプリを再コンパイルする必要があります。さまざまなデバイスを選択できることは消費者にとってはありがたいことですが、開発者にとっては複雑な作業になります。

.NET 言語でアプリを作成すれば、既存のスキルとコードを再利用できるだけでなく、再コンパイルしないですべての Windows 8 プロセッサでアプリを実行することが可能になります。マイクロソフトは、.NET Framework を ARM に移植することで、ほとんどの Windows 開発者にとっては使い慣れていないアーキテクチャが持つ、独自の特徴を抽象化するサポートを行いました。それでも、ARM で動作するコードを作成する際には、注意すべき点がいくつかあります。

ARM への道: .NET の過去と現在

.NET Framework は既に ARM プロセッサで動作するようになりますが、この .NET Framework は、デスクトップで動作するバージョンの .NET Framework とまったく同じものではありません。マイクロソフトが初期バージョンの .NET に取り組み始めたころにさかのぼると、プロセッサ間で簡単に移植できるコードを作成できることは、開発者の生産性を高めるという価値提案を行ううえでの鍵であることがわかっていました。x86 プロセッサは、デスクトップ コンピューティング分野を支配しましたが、組み込み型の分野やモバイル型の分野には実に多様なプロセッサが存在します。開発者がこれらのプロセッサをターゲットにできるように、マイクロソフトは、メモリとプロセッサに制限のあるコンピューターで動作するバージョンの .NET Framework として .NET Compact Framework を作成しました。

.NET Compact Framework がサポートした最初のデバイスには、4 MB の RAM と 33 MHz の CPU という小さなものでした。.NET Compact Framework の設計は、(制限のあるデバイスで実行できるようにする) 効率的な実装と、(モバイルおよび組み込み分野に共通するさまざまなプロセッサでの実行を可能にする) 移植性の両方を重視していました。ところが、最も普及しているモバイル デバイスであるスマートフォンは、10 年前のコンピューターに匹敵する構成で動作するようになっています。デスクトップの .NET Framework は、最低でも 300 MHz のプロセッサと 128 GB の RAM が搭載された Windows XP コンピューターで動作するよう設計されていました。現在リリースされている Windows Phone デバイスには、最低でも 256 MB の RAM と、最新の ARM Cortex プロセッサが必要です。

.NET Compact Framework は、Windows Embedded Compact 開発者にとってはいまだに大きな存在です。組み込みシナリオにおけるデバイスは、制限された構成 (ほとんどの場合たったの 32 GB RAM) で動作します。マイクロソフトは、RAM が 64 KB しかないプロセッサで動作するバージョンの .NET Framework として .NET Micro Framework も作成しました。つまり、.NET Framework には 3 つのバージョンが存在し、いずれも異なるクラスのプロセッサで動作します。ただし、マイクロソフトにとって最も重要な製品であるデスクトップの .NET Framework が、ARM プロセッサで動作する .NET Compact Framework と .NET Micro Framework の仲間に加わったのは、今回が初めてです。

ARM での動作

.NET Framework は、プラットフォームに依存しないように設計されてはいますが、誕生してから今まで、ほとんど x86 ベースのハードウェアで動作してきました。つまり、いくつかの x86 固有のパターンが、.NET プログラマの集団の考えに入り込んでいました。ARM で動作する .NET コードを作成する際は、プロセッサ アーキテクチャではなく、単にアプリの作成に集中することは可能ですが、いくつか念頭に置いておく必要がある点があります。たとえば、メモリ モデルが弱い、データ整列の要件が厳しい、関数パラメーターの扱いが異なるなどです。さらに、Visual Studio のプロジェクト構成手順の中には、デバイスをターゲットにする際に異なるものがいくつかあります。これから、それぞれについて説明します。

弱メモリ モデル: "メモリ モデル" とは、マルチスレッドのプログラムにおいて、グローバル状態に対して行われる変更の可視性を指します。2 つ (またはそれ以上) のスレッド間でデータを共有するプログラムは、通常、その共有データにロックをかけます。使用するロックによっては、1 つのスレッドがデータにアクセスしている場合、そのデータにアクセスしようとする別のスレッドは、1 つ目のスレッドが共有データを使い終わるまでブロック状態になります。ですが、共有データにアクセスするすべてのスレッドが、別のスレッドのデータへの可視性を妨害しないことがわかっていれば、ロックは必要ではありません。このような方法を用いるプログラミングでは、"lock-free" (ロックなしの) アルゴリズムを使用することになります。

"lock-free" アルゴリズムは、コードの正確な実行順序がわからない場合、問題になります。最新のプロセッサは、プロセッサがどのクロック サイクルでも進行できるように命令の実行順序を変更し、遅延を減らすためにメモリへの書き込みをまとめます。ほぼすべてのプロセッサがこのような最適化を行いますが、読み取りと書き込みがプログラムに行われる順序には違いがあります。x86 ベースのプロセッサは、プログラムが指定するのと同じ順番でほとんどの読み取りと書き込みが行われているように見えることを保証します。この保証を強いメモリ モデル、または厳密な書き込み順序付けといいます。これに対し、ARM プロセッサは、x86ベースのプロセッサほど多くの保証を行いません。命令を移動してもコードがシングル スレッドのプログラムで動作する方法が変わらない限り、通常自由に命令を移動できます。ARM プロセッサは、慎重に構築された lock-free のコードを許可する保証を行います。そのため "弱メモリ モデル" と言われます。

興味深いことに、.NET Framework CLR 自体は、弱メモリ モデルを持っています。CLR が対応するよう設計されている標準、ECMA 共通言語基盤 (CLI) 仕様 (bit.ly/1Hv1xw、PDF、英語) では、書き込み順序付けをすべて揮発性アクセスと表現しています。C# では、volatile というキーワードでマークされる変数へのアクセスを意味します (CLI 仕様のセクション 12.6 を参照)。しかし、ここ 10 年、ほとんどのマネージ コードは x86 システムで動作していて、CLR の just-in-time (JIT) コンパイラは、ハードウェアによって許可される実行順序の変更にあまり影響を受けなかったので、メモリ モデルが潜在的な同時実行のバグを発生するケースは比較的まれでした。これが問題となるのは、x86 ベースのコンピューターを対象として記述され、そのコンピューターでのみテストされているマネージ コードが、ARM システムでも同じように動作することを想定されている場合です。

実行順序の変更に関するさらなる注意を必要とするパターンは、マネージ コードではほとんどありません。また、そのようなパターンのいくつかは、見かけによらず簡単です。以下に、バグがないように見えても、静的な要素が別のスレッドで変更されると、弱メモリ モデルのコンピューターでは機能しなくなるコードの例を示します。

static bool isInitialized = false;
static SomeValueType myValue;
if (!isInitialized)
{
  myValue = new SomeValueType();
  isInitialized = true;
}
myValue.DoSomething();

このコードを修正するには、以下のように、isInitialized フラグが揮発性 (volatile) であることを示すだけです。

static volatile bool isInitialized = false; // Properly marked as volatile

コードの実行順序が変更されない場合の動作を、図 1 の左側のブロックに示します。Thread 0 は、まず、そのローカル スタックの SomeValueType を初期化して、ローカルに作成された SomeValueType を、グローバルな場所である AppDomain にコピーします。Thread 1 は、isInitialized を確認して、SomeValueType も作成する必要があるかどうかを判断します。しかし、データはグローバルな場所である AppDomain に書き戻されるので問題ありません (ほとんどの場合、この例のように、DoSomething メソッドが起こす変化は何度やっても同じです)。

Write Reordering
図 1 書き込みの順序変更

右側のブロックでは、同じコードを、書き込みの順序変更をサポートするシステム (と、便宜上行われる実行中のストール) で実行しています。この場合、Thread 1 は isInitialized の値を読み取って、SomeValueType を初期化する必要はないと判断するため、正しく実行されません。DoSomething の呼び出しが、まだ初期化されていないメモリを参照します。SomeValueType から読み取られるすべての値は、CLR によって 0 に設定されます。

コードがこのような実行順序の変更が原因で不適切に実行されることはあまりありませんが、そのようなケースも確かに存在します。このような実行順序の変更自体はまったく問題ありません。1 つのスレッドで実行されるときは、書き込みの順序によって問題は生じません。同時実行コードを記述する際は、volatile 変数を volatile キーワードで適切にマークするのが最適です。

CLR は、ECMA CLI 仕様が要求するよりも厳しいメモリ モデルを公開することが可能です。たとえば x86 では、プロセッサのメモリ モデルが強いメモリ モデルなので、CLR も強いメモリ モデルになります。.NET チームは、ARM のメモリ モデルを、x86 のメモリ モデルと同じくらい強くすることが可能でしたが、可能な限り完ぺきな順序を確保すると、コード実行パフォーマンスに大きな影響を与える可能性があります。マイクロソフトは、ARM のメモリ モデルを強化するという目的を達成しました (具体的に言うと、タイプ セーフ性を保証するために、マネージ ヒープに書き込む際に重要なポイントにメモリ バリアを挿入します) が、これは、パフォーマンスに最小限の影響しか及ぼさない場合のみ実行するようにしました。チームは、ARM CLR に適用される手法が適切であることを確実にするために、専門家に複数の設計レビューを行ってもらいました。さらに、パフォーマンスのベンチマークは、.NET コードの実行パフォーマンスを x86、x64、および ARM で比較したときに、ネイティブの C++ コードと同じようにスケール変換されることを示しています。

コードが、(ECMA CLR 仕様ではなく) x86 CLR の実装に依存する lock-free アルゴリズムを使用している場合は、必要に応じて、関連する変数に volatile キーワードを追加することをお勧めします。共有状態を volatile とマークしておけば、CLR がすべてを代わりに実行してくれます。ほとんどの開発者は、共有データを保護するためにロックを使用して、volatile 変数を適切にマークし、ARM でアプリをテストした経験があるはずなので、ARM での実行を実現できます。

データ整列の要件: ARM プロセッサは、一部のデータが整列していることを要求するという点も、プログラムに影響を与える場合があります。整列の要件は、64 ビット境界に揃えられていない 64 ビット値 (つまり、int64、uint64、または double) があるときに適用されます。CLR は自動整列を行いますが、境界に揃えられないデータ型を強制的に揃える方法が 2 つあります。[ExplicitLayout] カスタム属性で構造体のレイアウトを明示的に指定する方法と、マネージ コードとネイティブ コードの間で受け渡される構造体のレイアウトを不適切に指定する方法です。

ガベージと共に戻される P/Invoke 呼び出しに気付いたら、マーシャリングされる構造体を確認することをお勧めします。.NET チームの例について紹介します。チームは、COM インターフェイスが、32 ビット フィールドを 2 つ含んだ POINTL 構造体を、64 ビットの double 型をパラメーターとして受け取るマネージ コードの関数に渡している .NET ライブラリをいくつか移植しながら、バグを修正しました。この関数は、2 つの 32 ビット フィールドを取得するためにビット演算を使用しています。以下に、バグのある関数を簡略化したバージョンを示します。

void CalledFromNative(int parameter, long point)
{
  // Unpack native POINTL from long point
  int x = (int)(point & 0xFFFFFFFF);
  int y = (int)((point >> 32) & 0xFFFFFFFF);
  ...  // Do something with POINTL here
}

ネイティブ コードは、32 ビット フィールドを 2 つ含んでいるため、POINTL 構造体を 64 ビット境界に揃える必要はありませんでした。しかし、ARM は、64 ビットの double 型が、マネージ関数に渡されるときに、64 ビット境界に揃えられていることを求めます。マネージかつネイティブの呼び出しの両面で、型が確実に同じになるよう指定するのは、型で整列を必要とされている場合に重要です。

Visual Studio の内部: ほとんどの開発者は、これまで説明した相違点に気付くことはないでしょう。というのも、.NET コードは設計上、どのプロセッサ アーキテクチャにも依存しないためです。ただし、Visual Studio は ARM デバイスでは動作しないため、ARM デバイスの Windows 8 アプリをプロファイルまたはデバッグする際にはいくつか違いがあります。

Windows Phone 向けにアプリを作成した経験があれば、クロスプラットフォーム開発プロセスにはなじみがあると思います。Visual Studio が x86 開発者用コンピューターで実行されている場合は、アプリをデバイス上でリモートに起動するか、エミュレーターで起動します。アプリは、IP 接続を通じて開発者用コンピューターと通信するために、デバイスにインストールされているプロキシを使用します。初期セットアップ手順を除けば、デバッグとプロファイルは、すべてのプロセッサで同じように機能します。

Visual Studio プロジェクトの設定が、ターゲット プロセッサの選択肢として x86 と x64 のほかに ARM を追加する点も注意が必要です。通常、Windows on ARM 向けに .NET アプリを作成するときは、AnyCPU をターゲットにし、アプリがすべての Windows 8 アーキテクチャで実行されるようにします。

ARM のサポートについての詳細

ここまでくると、ARM について多くのことを理解できたのではありませんか。ここからは、ARM をサポートするために .NET チームが行った作業に関して、興味深く技術的な面を詳しく紹介します。開発者がこのような作業を .NET コードで実行する必要あるわけではありません。単に、チームが行った作業の内情を簡単に示すだけです。

CLR は複数のアーキテクチャ間で移植可能になるように設計されているため、CLR 自体の内部に行われた変更の大半はわかりやすいものです。チームは、ARM のアプリケーション バイナリ インターフェイス (ABI: Application Binary Interface) に従うために、いくつか変更を加える必要がありました。また、ARM をターゲットとするために CLR のアセンブリ コードを書き直し、ARM Thumb 2 命令を出力するように JIT コンパイラを変更する必要がありました。

ABI は、プロセッサのプログラミング可能なインターフェイスの構造を指定します。これは、プログラムから使用可能な OS の関数を指定する API に似ています。この ABI がチームの作業に及ぼす影響は、関数の呼び出し規約、レジスタ規約、およびコール スタックのアンワインド情報の 3 つの側面です。この 3 つの側面それぞれについて説明します。

関数の呼び出し規約: 呼び出し規約とは、関数を呼び出すコードと、呼び出される関数の間の合意事項です。この規約では、パラメーターと戻り値がメモリ内にどのようにレイアウトされるかと、呼び出しが行われている間どのレジスタの値を保持しておく必要があるかを指定します。関数呼び出しが境界を超えて機能するためには (プログラムから OS への呼び出しなど)、コード ジェネレーターは、プロセッサが定義する規約 (64 ビット値の整列など) を満たす関数呼び出しを生成する必要があります。

ARM は、マネージ ヒープ上のパラメーターやオブジェクトを CLR が 64 ビット境界に揃えなければならない、最初の 32 ビット プロセッサでした。すべてのパラメーターを境界に揃えてしまうのが最も簡単な解決策ですが、ABI は、パフォーマンスが低下しないように、整列を必要としない場合はコード ジェネレーターがスタック内にバブルを残さないことを求めます。そのため、スタックに多くのパラメーターをプッシュするというシンプルな操作でも、ARM プロセッサではより細かい注意を必要とします。ユーザー構造体には int64 が含まれている可能性があるため、CLR の解決策は、型ごとに整列が必要かどうかを示すビットを使用するというものでした。これによって、64 ビット値を含む関数呼び出しがコール スタックを誤って破損することがないように、CLR に十分な情報が渡されるようになります。

レジスタ規約: 構造体が ARM のレジスタを完全または部分的に使用するときは、データ整列の要件が引き継がれます。つまり、データが必ず正しいレジスタを使用するように、頻繁に使用するデータをメモリからレジスタに移動する CLR 内のコードを変更する必要があります。この作業は、64 ビット値が必ず偶数番号のレジスタから始まるようにするために、および同種浮動小数点集合体 (HFA) を正しいレジスタに配置するために行う必要があります。

コード ジェネレーターが int64 に ARM のレジスタを使用する場合、偶数番号と奇数番号のレジスタ ペア (R0 とR1、R2 と R3 など) に格納する必要があります。HFA のプロトコルは、同種の構造体内に倍精度浮動小数点値または単精度浮動小数点値を 4 つまで許可します。これらがレジスタを使用する場合、汎用の R レジスタではなく、S (単一) レジスタまたは D (倍精度) レジスタのいずれかに格納される必要があります。

アンワインド情報: アンワインド情報は、関数呼び出しがスタックに及ぼす影響や、関数呼び出しの間非揮発性レジスタが保存される場所を記録します。x86 では、ハンドルされない例外が発生したときに、Windows が FS:0 を確認して、各関数の例外登録情報のリンク リストを表示します。64 ビット Windows では、ハンドルされない例外が発生したときに Windows がスタックをクロールできるようにするアンワインド情報という考え方が導入されました。ARM の設計では、このアンワインド情報が 64 ビットの設計から拡張されました。したがって、CLR コード ジェネレーターは、新しい設計に対応するために変更を余儀なくされました。

アセンブリ コード: CLR ランタイム エンジンの大部分は C++ で記述されていますが、新しいプロセッサが追加されるたびに移植しなければならないアセンブリ コードがあります。このようなアセンブリ コードのほとんどは、"関数スタブ" または "スタブ" と呼ばれるものです。スタブは、ランタイムの C++ 部分と JIT でコンパイルされる部分を束ねる、インターフェイスの "接着剤" として機能します。CLR 内のアセンブリ コードのその他の部分は、パフォーマンスを考慮したアセンブリで記述されています。たとえば、ガベージ コレクターの書き込みバリアは、(オブジェクト参照が、マネージ ヒープのオブジェクトに書き込まれるたびに) 頻繁に呼び出されるので、非常に高速である必要があります。

スタブの一例として、"シャッフル サンク" が挙げられます。"シャッフル サンク" という名前は、レジスタ間のパラメーター値をシャッフルすることから来ています。ときに CLR は、関数呼び出しが行われる直前で、レジスタにおけるパラメーターの配置を変更する必要があります。CLR は、デリゲートを呼び出すときにこれを行うために、シャッフル サンクを使用します。

概念的には、デリゲートを呼び出す際は Invoke メソッドを呼び出しますが、実際には、CLR が、名前によるメソッド呼び出しを行うのではなく、デリゲートのフィールドを通じて間接呼び出しを行います (リフレクションを使用して Invoke を明示的に呼び出す場合を除く)。このメソッドは、ランタイムが、(ターゲット ポイントから取得された) デリゲートのインスタンスを、関数呼び出しにおけるデリゲートと交換できるため、名前によるメソッド呼び出しよりもはるかに高速です。つまり、デリゲート d のインスタンス foo の d.Member メソッド呼び出しは foo.Member メソッドにマップされます。

クローズ インスタンス デリゲート呼び出しを行う場合、this ポインターが、パラメーターを渡すのに使用される最初のレジスタ (R0) 内に格納され、最初のパラメーターが、次のレジスタ R1 に格納されます。しかしこれは、インスタンス メソッドにバインドされるデリゲートがある場合のみ機能します。静的なオープン デリゲートを呼び出す場合はどうなるでしょう。この場合は、最初のパラメーターが R0 に格納されることが想定されます (this ポインターがないため)。シャッフル サンクは、最初のパラメーターを R1 から R0 に移動して、2 つ目のパラメーターを R0 に移動します。以降も同様に続きます (図 2 参照)。このシャッフル サンクの目的は、レジスタ間で値を移動することなので、プロセッサごとに具体的に書き直す必要があります。

A “Shuffle Thunk” Shuffles Value Parameters Across Registers
図 2 レジスタ間で値パラメーターをシャッフルする "シャッフル サンク"

コードの記述だけに集中

振り返ってみると、.NET Framework を ARM に移植するのは興味深いプロジェクトで、.NET チームもたいへん楽しい時間を過ごすことができました。.NET の開発者には、ARM の .NET Framework で動作する .NET アプリを作成することを楽しんでいただけるはずです。作成した .NET コードが、x86 ベースのプロセッサと ARM では異なる動作をする場合もありますが、.NET Framework の仮想実行環境は通常、これらの相違点を抽象化します。つまり、.NET アプリがどのプロセッサ アーキテクチャで動作するかを懸念する必要はありません。コードの記述だけに集中できます。

ARM で Windows 8 を実行できるようにすることは、開発者とエンド ユーザーの両方にとって有益だと考えています。ARM プロセッサは、バッテリ持続時間を延ばすのに特に適しているため、軽量でポータブルな常時接続のデバイスが実現されます。アプリを ARM に移植する際の最も大きな問題は、デスクトップ プロセッサ間のパフォーマンスの違いです。コードが ARM で実際に動作すると断言する前に、ARM でそのコードを実行するようにしてください。x86 での開発で十分という過信は禁物です。ほとんどの開発者に必要なことはこれですべてです。何か問題に直面したら、もう一度読み直して、どこから調査すればよいのか確認してください。

Andrew Pardoe は、CLR チームのプログラム マネージャーです。あらゆる種類のプロセッサで Microsoft .NET Framework を提供することをサポートしています。彼の個人的なお気に入りは Itanium です。連絡先は Andrew.Pardoe@microsoft.com です。

この記事のレビューに協力してくれた技術スタッフの Brandon Bray、Layla Driscoll、Eric Eilebrecht、および Rudi Martin に心より感謝いたします。