2005年12月04日

From Structure To Bytes

今のところサブ機たるデスクトップPCで頑張っています。一番慣れないのはキーボード。ノートのペラいキータッチに慣れると指の動かす距離(左右奥手前以外に上下にも)が長くなって面倒な感じです。ノートなら左下がCtrlキーでも問題ないんですが、なるほど普通のキーボードだと左下って遠いですね。英数キーに割り当てたい人が多いのも納得。小指付け根で押す派の存在にも頷けます。ていうかなぜこのキーボードにはWinキーがないんだろう。

さて、まともな開発環境のない状況ですが細々と簡単な実験コードくらいならSDKすらなくても何とかなるもんです。今回のネタは、構造体へのポインタからByteの一次元配列へのキャスト方法です。

えー、あらかじめ言っておきますが、アンマネージドとやり取りするわけでもないのにMarshalクラスを使ったりunsafe構文を使ったりするのはお勧めしませんし柔軟性に欠けるため使いにくい部分も多く出るので、今回のは飽くまで参考程度に止めて下さいね。私のこれはまあ言ってしまえば趣味みたいなもので。

さて、構造体とバイト列。C/C++ならごくナチュラルに相互変換するものです。というか何であれデータはバイト列で表されるわけですから、両者に明確な区別はなく単にプログラマが分かりやすいようにそれっぽく形を与えているだけと極論してしまうこともできます。

が、C#やVB.NET、またその背景にあるCLIでは両者は厳密に区分され、それぞれは内部で異なった表現がなされます。端的に言えばそれぞれはクラス(型と言った方が総称的で良いですが。C#/VB.NETではクラスと構造体は峻別されるから)のインスタンスとして表現され、継承関係にあったり型変換が独自に定義されていない限り自由に型を変更することはできません。なによりも、メモリの管理にガベージコレクタを採用している関係もあって、「あるオブジェクトの存在するアドレスが一意である」とは限らないのが重要です。つまりオブジェクトは移動し得ます。

というわけで、基本的にC/C++的な変換はできません。.NET的には、それぞれ独自のToBytes/FromBytesメソッドなどを自前で実装するという方向が正しいでしょう。いや、実際のところ.NETで完結するのならBinaryFormatterとか使ったほうが良いと思いますが。

しかしそれでも、C/C++的な変換をしたいという要求はあります。

そのための手段として以下のような方法が考えられます。

  • unsafe構文でポインタを利用して型を強制的に変更し、強引に直で代入する方法。まさにCなやり方です。

    構造体ポインタをunsafeで扱おうとする場合、相当制約が厳しい点に注意しましょう。例えばunsafeポインタはMarshalAs属性を考慮しません。つまり.NET 1.0/1.1では固定長配列や固定長文字列を簡単に表現する手段が使えないということになります。.NET 2.0では構造体そのものにunsafeコンテキストを適用しフィールドにfixedキーワードを付けることで、unsafe構文内で固定長配列を表現できるようになりましたが、こちらは逆にそのfixedフィールドを非unsafeなところで扱えなくなります。

  • Marshal.StructureToPtr・PtrToStrucutureメソッドを使用する方法。この場合問題はどうやって引数になるIntPtrを確保するかという点ですね。

    • Marshal.AllocCoTaskMemメソッドでバッファを用意する。これで構造体とIntPtrをやり取りし、更にMarshal.Copyメソッドを使用してIntPtrとバイト配列とをやりとりします。

    • 間にバッファを入れるのは不効率ってもんだということで、直接バイト配列のアドレスをどうにか取得する。それには配列をGCの影響からはずす、つまりオブジェクトを固定しなければなりませんが、そのためには一般的にGCHandleクラスを使用します。GCHandleType.Pinnedを指定してAlloc静的メソッドを呼んでやれば、生成されたGCHandleインスタンスのFreeメソッドを呼ぶまでGCの管理対象から外されるのでアドレスを問題なく利用することができるようになります。そのアドレスを取得するのにはGCHandle.AddrOfPinnedObjectメソッド、またはMarshal.UnsafeAddrOfPinnedArrayElementメソッドを使用します。

      ところで、なぜUnsafeAddrOfPinnedArrayElementメソッドは引数に配列を取るんでしょうねぇ。PinnedでないといけないのならいっそGCHandleを引数にすれば良いのに。と、@ITのMarshal.UnsafeAddrOfPinnedArrayElementについてというスレッドのやり取りで思いました。

    • 上に加え、unsafe構文を使うことで、GCHandleを使わず直接fixedステートメントを使うことで配列のポインタを取得する。GCHandleを使わないことがわざわざunsafeにするほどのコストかどうかは後述。

  • マーシャリングを最大限に生かす。マーシャリングってのは元々アンマネージドとの相互運用のために存在するのですから、相互運用させましょう。つまり、Win32APIのCopyMemory関数を呼びます。マーシャラは配列をアンマネージドとやり取りする際、自動的に固定します。またポインタを意識する必要もありません。参照を渡せば勝手にポインタにしてくれます。

    Marshal.StructureToPtr・PtrToStructureを使うにせよ、CopyMemoryを使うにせよ、扱える構造体にはマーシャラによる制限が課されます。.NET 1.0/1.1において、メンバに構造体の固定長配列をMarshalAs属性を使って表現することができないなどが代表的なものでしょう。

さて色々手段が提示されました。これらの中で一体どれを採用すべきでしょうか。

まず、できればunsafeは避けたいものです。アセンブリごとunsafeになっちゃうし。ということで始めのは除外。

記述の手間はいずれもそう大差ないかと思います。毎回記述するのならできる限り短い方が好ましいですが、汎用関数化するのなら記述の長短は問題でなくなります。汎用か専用かも問題ですね。一般に汎用性を求めればパフォーマンスは犠牲になるものですし。

あとは実行コストの問題ということになります。これはCLRのバージョンや利用者のPCによっても影響されるので、特にこれならOKという記述を断言することはできません。そもそも何十万回も呼び出すのでない限り、コストが実動作に影響することは無いと言って良いでしょう。

とはいえベンチマークは趣味でやる分には楽しいものです。また様々なテストをすることで意外な事実を知ることもあったりとかも。

ところで、こういう場合もっと早く続きに書くようにして表に出る分を削減すべきなんでしょうかね。普段はコード部分だけこっちにもってきてますけど。

閑話休題。さて、汎用か専用か。専用なら正直その度に書けば良いじゃんと思ってしまうので、汎用性のあるコードを目指すことにします。

まず構造体からバイト配列への変換です。使用する構造体は2種類用意しました。ひとつはInt32を2つ持つだけの8バイトの構造体、もうひとつはDoubleを16個持つ128バイトの構造体です。ネーミングにまるでやる気がありません。

public struct A {
  public int a, b;
}
public struct B {
  public double a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p;
}

メソッドの紹介に入る前に、全てのメソッドに共通することをひとつ。Marshal.SizeOfは値そのものを引き取るものとType型を引き取るもの、二つのオーバーロードがあります。この両者で速度は違うのかというと、結構違います。ではどちらが速いのか。これは実行環境と型によって異なります。下に一覧にしてみました。時間は二百万回ループでの参考値です。.NET 1.0は傾向が.NET 1.1と同じために省略します。

単位はミリ秒
.NET構造体引数
1.1A182114
B373487
2.0A555188
B572403

結果を見れば、いずれにせよ型サイズが大きくなれば処理時間も長くなるのが分かりますが、例えば.NET 2.0で型を与えた場合はほとんど時間が変わりません。今回の結果では値を与えた方が型を与えるよりも速いことが多いですが、.NET 1.1ではB構造体で逆転しています。増加率も値を型を与える方が小さいですね。Marshal.SizeOfとはobject型でやり取りすることから、ボクシング&アンボクシングのコストの影響も大きいでしょう。いずれにせよ、構造体のサイズがさほど大きくない場合は値を渡す方が全体的に効率的なようです。元々.NETでは構造体は大きくしないようにというのがガイドラインにも挙げられていますしね。ちなみに、unsafeで使うsizeofは専用のOpcodeで処理されるようですが、ほぼコストなしの一桁ミリ秒でした。使えるのならこれが最も効率的ではあります。

それでは変換メソッドを見ていきましょう。まずMarshal.AllocCoTaskMemを使ってMarshal.StructureToPtrとバイト配列の仲介をさせるメソッド。引数がValueTypeではなくobjectなのは、StructLayout属性でLayoutKind.SequentialまたはLayoutKind.Explicitを指定したクラスなら問題なくマーシャリングできるからです。逆に言えば、LayoutKind.Autoを指定していれば構造体であってもマーシャリングできません。

public static byte[] ToBytesByAlloc(object value) {
  int size = Marshal.SizeOf(value);
  IntPtr buffer = Marshal.AllocCoTaskMem(size);
  try {
    Marshal.StructureToPtr(value, buffer, true);
    byte[] data = new byte[size];
    Marshal.Copy(buffer, data, 0, size);
    return data;
  }
  finally {Marshal.FreeCoTaskMem(buffer);}
}

GCHandleを使ってバッファを無くすメソッド。

public static byte[] ToBytesByPinned(object value) {
  byte[] data = new byte[Marshal.SizeOf(value)];
  GCHandle handle = GCHandle.Alloc(data, GCHandleType.Pinned);
  try {
    Marshal.StructureToPtr(value, handle.AddrOfPinnedObject(), true);
    return data;
  }
  finally {handle.Free();}
}

GCHandleの代わりにunsafe/fixedを使ったメソッド。

unsafe public static byte[] ToBytesByFixed(object value) {
  byte[] data = new byte[Marshal.SizeOf(value)];
  fixed (byte* pdata = data) {
    Marshal.StructureToPtr(value, new IntPtr(pdata), true);
  }
  return data;
}

CopyMemoryを使用して、引数の型をobjectで受け入れるメソッド。

[DllImport("kernel32.dll", EntryPoint="CopyMemory")]
public static extern void CopyObj([Out] byte[] data,
                                  [MarshalAs(UnmanagedType.AsAny),
                                   In] object value,
                                  int size);
public static byte[] ToBytesByCopyObj(object value) {
  int size = Marshal.SizeOf(value);
  byte[] data = new byte[size];
  CopyObj(data, value, size);
  return data;
}

さて、ここから先は型を特定して行う専用メソッド群です。ボクシング/アンボクシングや値のコピーをできる限り避けるため、refキーワードで参照渡しすることにします。

まずはunsafeを使ってポインタ直変換。この部分はGenericにやらせるわけにもいかないところですしねー(T*型は認められません)。単純な構造体限定ですので、限定ついでにsizeofで更なる効率化を目指します。

unsafe public static byte[] ToBytesByStructPtr(ref A value) {
  byte[] data = new byte[sizeof(A)];
  fixed (byte* pdata = data) {
    *(A*)pdata = value;
  }
  return data;
}
unsafe public static byte[] ToBytesByStructPtr(ref B value) {
  byte[] data = new byte[sizeof(B)];
  fixed (byte* pdata = data) {
    *(B*)pdata = value;
  }
  return data;
}

CopyMemoryを使用してコピーするメソッドです。

[DllImport("kernel32.dll")]
public static extern void CopyMemory([Out] byte[] data,
                                     [In] ref A value, int size);
[DllImport("kernel32.dll")]
public static extern void CopyMemory([Out] byte[] data,
                                     [In] ref B value, int size);
public static byte[] ToBytesByCopy(ref A value) {
  int size = Marshal.SizeOf(value);
  byte[] data = new byte[size];
  CopyMemory(data, ref value, size);
  return data;
}
public static byte[] ToBytesByCopy(ref B value) {
  int size = Marshal.SizeOf(value);
  byte[] data = new byte[size];
  CopyMemory(data, ref value, size);
  return data;
}

比較用として、バッファを使用するToBytesByAllocも型ごとに独自メソッドを用意してみましょう。

public static byte[] ToBytesByAllocTyped(ref A value) {
  int size = Marshal.SizeOf(value);
  IntPtr buffer = Marshal.AllocCoTaskMem(size);
  try {
    Marshal.StructureToPtr(value, buffer, true);
    byte[] data = new byte[size];
    Marshal.Copy(buffer, data, 0, size);
    return data;
  }
  finally {Marshal.FreeCoTaskMem(buffer);}
}
public static byte[] ToBytesByAllocTyped(ref B value) {
  int size = Marshal.SizeOf(value);
  IntPtr buffer = Marshal.AllocCoTaskMem(size);
  try {
    Marshal.StructureToPtr(value, buffer, true);
    byte[] data = new byte[size];
    Marshal.Copy(buffer, data, 0, size);
    return data;
  }
  finally {Marshal.FreeCoTaskMem(buffer);}
}

さて、それでは実行結果です。100万回ループの時間です。

単位はミリ秒
メソッド.NET 1.1.NET 2.0
構造体 構造体
AB AB
Alloc 603 881 774 973
Pinned 521 808 892 1123
Fixed 150 437 281 498
CopyObj 1036160212781736
StructPtr 33 117 34 115
CopyTyped 238 509 242 392
AllocTyped601 987 796 1047

制限が強いだけあってToBytesByStructPtrは圧倒的なスピードを見せています。それに次ぐのは、型指定のCopyMemoryとfixedを使用したMarshal.StructureToPtr。両者いずれが優れるかは.NETのバージョンによって逆になってますね。最も遅いのはCopyMemoryでobjectを使った奴。これはAsAnyのマーシャリングに時間がかかっているとみるのが妥当でしょうか。

さて、型指定は汎用性が無いですから、objectで引き受ける、かつunsafeでないとなると上二つ、ByAllocとByPinnedしか残りません。バッファリングするか、GCHandleで固定するかです。意外なことに、思ったほどの差は出ません。そして更に驚くことに、.NET 2.0ではバッファリングを行った方がむしろ速いということになっています。それだけ.NET 2.0と固定というのが相性が悪いということでしょうか。.NET 2.0ではfixedを使ったメソッドも成績がかなり悪化していますしね。

それからAllocCoTaskMemで型指定した場合はどうなるか、のテストですが、これは変わらないどころかむしろ悪化する要因になっていますね。

ということで、あえて私は結論しません。これと思ったものを使ってください。

気が向けば、逆方向の変換についても記事にしようかな。

posted by Hongliang at 23:41| Comment(0) | TrackBack(1) | C# | このブログの読者になる | 更新情報をチェックする
この記事へのコメント
コメントを書く
お名前:

メールアドレス:

ホームページアドレス:

コメント:

認証コード: [必須入力]


※画像の中の文字を半角で入力してください。

この記事へのトラックバック

SU3200 VB2005移行注意リスト
Excerpt: SU-3000 SU3200 VB2005移行注意リスト SU-3000 ポータル もくじ † もくじ このページは何? 必要な知識・環境 移行順序 参考ページ ..
Weblog: PukiWiki/TrackBack 0.1
Tracked: 2008-11-10 18:49

ここ(hongliang.seesaa.net)で公開しているものについて、利用は自由に行って頂いて構いません。改変、再頒布もお好きになさって下さい。利用に対しこちらが何かを要求することはありません。

ただし、公開するものを使用、または参考したことによって何らかの損害等が生じた場合でも、私はいかなる責任も負いません。

あ、こんなのに使ったってコメントを頂ければ嬉しいです。

×

この広告は1年以上新しい記事の投稿がないブログに表示されております。