LoginSignup
18
13

More than 1 year has passed since last update.

黒魔術で値型のボックス化を消滅させる

Posted at

ボックス化とは

C#には値型と参照型があり,ボックス化とは値型をobjectもしくはインターフェイスに変換することです.
参照型ではボックス化は発生しません.これの理由は値型と参照型のメモリ上での表現の違いです.
それぞれをインスタンス化したとき,値型は値をそのまま持ち,参照型は値をヒープ上に確保し値の参照=ポインタを持ちます.加えて参照型は値を確保するだけでなくヘッダとメタデータのポインタを値の前に確保します.このメタデータのポインタというのが肝で,メタデータには仮想関数テーブルが含まれています.仮想関数呼び出しにはこのテーブルを参照する必要があり,そのため値型は仮想関数呼び出しをするときに,メタデータのポインタを追加するボックス化が行われるわけです.

C#ではボックス化は自動で行われ,あまり気にすることはないかもしれません.ですがボックス化は結構重たい処理で気軽に行ってほしくはないもので,Javaなんかでは値型を特別扱いしており同じように扱えなかったりします.C#でもRustみたいにボックス化を明示的に行う必要があってほしかった気もします.でもジェネリクスの手間を考えるとこのままがよかったり.この辺はトレードオフですね.

参照型のメモリ表現

参照型のメモリ表現はドキュメント化されておらず,あくまで現状採用されているものについての解説です.変更される可能性もあるため参考までにとどめてください.

参照型はヘッダ,メタデータのポインタ,値を順に確保し,参照型のポインタはメタデータのポインタを指します.
例えばこんなクラスがあったとして,

class Reference
{
    int value;
}

変数とインスタンスの関係を図にするとこんな感じです.

layout.png

このように中間を指すポインタを使う理由は「慣例」とのことですが,下の記事を読んだ感じ

  • ヘッダのサイズは規定されておらず可変長である
  • ヘッダはランタイムによってのみ使用される

この辺りが理由でしょう..Net Compact Frameworkではオブジェクトのサイズによってヘッダのサイズも変わるそうですし,ヘッダはGCなどに必要な情報を持つだけで通常のC#のコードから使われることはほぼないみたいです.
後ほどこのレイアウトを参考にスタック上でボックス化を行うのですが,なんとヘッダ分を用意していなくとも全く問題なく動きます.

詳しくは以下の記事を見ていただきたいです.
https://devblogs.microsoft.com/premier-developer/managed-object-internals-part-1-layout/

ボックス化を消す黒魔術

上で参照型のレイアウトについて説明しました..Netにおいて参照はポインタと同じです.つまりIntPtr型と相互に変換可能です.
この黒魔術はスタック上にメタデータのポインタ,値を並べ,メタデータのポインタへの参照を無理やり参照型に変換することでボックス化を消滅させます.

UnsafeReference.cs
static class UnsafeReference
{
    static class StaticStore<T>
        where T : struct
    {
        unsafe static StaticStore()
        {
            //いったんボックス化してメタデータのポインタを取り出します.
            //この時ちょうどGCが動くと死ぬのでGCHandleで固定します.
            var handle = GCHandle.Alloc(default(T), GCHandleType.Pinned);
            //GCHandleが渡してくるポインタは値のポインタなので,その一つ手前がメタデータのポインタになります.
            Metadata = ((IntPtr*)handle.AddrOfPinnedObject())[-1];
            handle.Free();
        }

        public static IntPtr Metadata { get; private set; }
    }

    //メタデータのポインタと,値を並べて参照型を再現します.
    //ヘッダは無くても問題ないです.
    [StructLayout(LayoutKind.Sequential)]
    public struct Boxed<T>
        where T : struct
    {
        readonly IntPtr metadata;
        public T Value;

        public Boxed(T value)
        {
            this.metadata = GetMetadata<T>();
            this.Value = value;
        }
    }

    public static IntPtr GetMetadata<T>() where T : struct => StaticStore<T>.Metadata;

    public static Boxed<T> Box<T>(T value) where T : struct => new(value);

    public unsafe static TBase As<T, TBase>(ref Boxed<T> boxed)
        where T : struct, TBase
    {
        //boxedへの参照 = メタデータのポインタ
        var ptr = (IntPtr)Unsafe.AsPointer(ref boxed);
        //ポインタを参照型に変換
        return Unsafe.As<IntPtr, TBase>(ref ptr);
    }
}
Benchmark.cs
class Program
{
    static void Main(string[] args)
    {
        var summary = BenchmarkRunner.Run<Test>();
    }
}

//intを取得するだけのインターフェイス
interface IInterface
{
    public int GetValue();
}

//値を持つだけの型
readonly struct Struct : IInterface
{
    readonly int value;

    public Struct(int value) => this.value = value;

    //最適化で関数呼び出しが消滅してしまうのでインライン化を禁止
    [MethodImpl(MethodImplOptions.NoInlining)]
    public int GetValue() => this.value;
}



[MemoryDiagnoser]
public class Test
{
    Struct structure;
    IInterface boxed;

    public Test()
    {
        var rnd = new Random();
        this.structure = new Struct(rnd.Next());
        this.boxed = this.structure;
    }

    //最適化でボックス化も消えてしまうのでインライン化を禁止
    [MethodImpl(MethodImplOptions.NoInlining)]
    static int GetValue(IInterface interf) => interf.GetValue();


    [Benchmark]
    public int Direct() => this.structure.GetValue();

    [Benchmark]
    public int PreBoxed() => this.boxed.GetValue();

    [Benchmark]
    public int Boxed() => GetValue(this.structure/*ここでボックス化*/);

    [Benchmark]
    public int UnsafeBox()
    {
        var box = UnsafeReference.Box(this.structure);
        var interf = UnsafeReference.As<Struct, IInterface>(ref box);
        return GetValue(interf);
    }
}

ベンチマーク結果

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.19043.1288 (21H1/May2021Update)
AMD Ryzen 5 3600, 1 CPU, 12 logical and 6 physical cores
.NET SDK=6.0.100-rc.2.21505.57
[Host] : .NET 5.0.11 (5.0.1121.47308), X64 RyuJIT
DefaultJob : .NET 5.0.11 (5.0.1121.47308), X64 RyuJIT

Method Mean Error StdDev Gen 0 Allocated
Direct 0.9163 ns 0.0337 ns 0.0299 ns - -
PreBoxed 2.4359 ns 0.0517 ns 0.0484 ns - -
Boxed 5.9263 ns 0.1466 ns 0.2606 ns 0.0029 24 B
UnsafeBox 4.3889 ns 0.1047 ns 0.1285 ns - -

見事にアロケーションが消滅しています.ですが関数呼び出しが増えてしまう分そこまでの高速化とはなりませんでした.変なことをせずに普通にボックス化したものをキャッシュすることが効果的ですね.

さて,今までのコードでは平然とスタック上の変数の参照を参照型に変換しています.これをクラスのメンバなんかに渡してしまって変数の寿命を超えると当然死にます.大変危険なコードな訳ですがref構造体なんかで行われるescape analysisを行えば安全であるってことで,.NETでもオブジェクトをスタック上に確保する最適化を入れることが検討されていたりします.

18
13
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
13