ネコのために鐘は鳴る

寺院に住み着くパソコ〇好き

(C#) Span<T> を List<T>.AddRange したい

タイトル通りですが本記事執筆時点 (2020/4/15) の最新である .NET Core 3.1 ではList<T>AddRangeメソッドにダイレクトにSpan<T>およびReadOnlySpan<T>を突っ込むことはできません。というのもList<T>AddRangeメソッドは

public void AddRange(IEnumerable<T> items);

しかオーバーロードがないためです。Span<T>ReadOnlySpan<T>foreach列挙可能ではあるがIEnumerable<T>ではありませんし、そもそもref structの制約で interface にキャスト不可によりIEnumerable<T>になりえません。

理想としてはAddRangeReadOnlySpan<T>オーバーロードが欲しい。(Span<T>ReadOnlySpan<T>への暗黙的キャストが効く上、キャストのコストは実質0なので問題ない`)

Span<int> span = ...;
List<int> list = ...;

list.AddRange(span);    // これができるとうれしい

しかし、実際はできないので、現状の回避策としてはforeachAddを使うしかない。

Span<int> span = ...;
List<int> list = ...;

foreach(var item in span)
{
    list.Add(item);
}

が、まあ当然列挙毎にAddと列挙のオーバーヘッドが入るのでAddRangeより遅い。Span<T>のもとになっているのが配列 (例えばint[]) とかなら、Span<int>にせず配列のままAddRangeすればIEnumerable<T>に入ってくれるし、AddRange内でのICollection<T>による要素数既知の最適化にハマるので十分早い。しかし、Span<T>のもとになっているのがアンマネージメモリだと配列ではないし、そもそもIEnumerable<T>ですらないのでどうしようもない。 上記のように foreachAddする以外の選択肢がない。非常に悲しい。

 

個人的には非常に欲しい API なので .NET Core の github リポジトリの issue に同様の提案が上がっていないのか調べたら、案の定あった。それがこちら。

github.com

で、内容をまとめると、

  • この API が必要という根拠、具体的なサンプルや場面が欲しい。
  • 現状回避策として、自作の List<T> で置き換えるのが妥当

というようなコメントがついてましたね。。。API の追加は簡単ではない。とはいえ、重要なのはこのリポジトリは本記事執筆時点では close されていません。というかよく見ると、.NET 5 (.NET Core 3.1 の次のメジャーバージョンの .NET) のマイルストーンに 提案 API が入っているではないですか!!歓喜

.NET 5 のリリース予定は 2020/11 までと予定されているため、それまではないですが、.NET 5 ではおそらくReadOnlySpan<T>オーバーロードが入ると思われます。

.NET Core / .NET Framework ではどうするの

前述の通り、次期リリースでは入るんでしょうが .NET Core / .NET Framework で今私は使いたいんだ!!!と、当然話はそこに帰結する。なので unsafe なC# の暗黒面の力を借りて拡張メソッドで実装した。

static class ListExtension
{
    internal static void AddRange<T>(this List<T> list, ReadOnlySpan<T> span) => list.InsertRange(list.Count, span);

    internal static void InsertRange<T>(this List<T> list, int index, ReadOnlySpan<T> span)
    {
        if(list is null) { throw new ArgumentNullException(); }
        static void EnsureCapacity(List<T> original, ListDummy<T> dummy, int min)
        {
            if(dummy._items.Length < min) {
                const int DefaultCapacity = 4;
                int newCapacity = dummy._items.Length == 0 ? DefaultCapacity : dummy._items.Length * 2;

                // Allow the list to grow to maximum possible capacity (~2G elements) before encountering overflow.
                // Note that this check works even when _items.Length overflowed thanks to the (uint) cast
                const int MaxArrayLength = 0X7FEFFFFF;      // This size is compatible with Array.MaxArrayLength. (This is internal)
                if((uint)newCapacity > MaxArrayLength) newCapacity = MaxArrayLength;
                if(newCapacity < min) newCapacity = min;
                original.Capacity = newCapacity;
            }
        }

        var dummyList = Unsafe.As<ListDummy<T>>(list);
        if((uint)index > (uint)dummyList._size) {
            throw new ArgumentOutOfRangeException();
        }
        int count = span.Length;
        if(count > 0) {
            EnsureCapacity(list, dummyList, dummyList._size + count);
            if(index < dummyList._size) {
                Array.Copy(dummyList._items, index, dummyList._items, index + count, dummyList._size - index);
            }

            span.CopyTo(dummyList._items.AsSpan(index));
            dummyList._size += count;
            dummyList._version++;
        }
    }

    private abstract class ListDummy<T>
    {
        internal T[] _items = default!;
        internal int _size;
        internal int _version;
#if NETFRAMEWORK
        internal object _syncRoot = default!;
#endif

        private ListDummy() { }
    }
}

[2020/07/25 追記]

これ、気が付いてしまったんですけど、これ単体ならおそらく問題ないような気がしますが、 以前の記事List<T>からSpan<T>を 引っこ抜いてくるのと組み合わせるとちょっとマズイ気がします。 具体的には、List<T>の自分自身から引っこ抜いたSpan<T>を自身にこのAddRangeInsertRangeすると、 容量拡大時やメモリ移動時におかしなことが起こりそうな気がします。ちょっと詳しく挙動を見ないとわからない (すいません)

これでAddRangeSpan<T>をダイレクトに入れられます。ついでにInsertRangeも使えます。上記のソースコードを見て何をやってるかは把握してください。というか見て分からない人は使ってはダメです、Unsafeを悪用しているので問題が起こった時に困ると思います。

上記ソースコード中の#if NETFRAMEWORKですが、NETFRAMEWORK .NET Framework をターゲットにコンパイルしたときには自動で追加されるシンボルです。.NET Framework .NET Core ではList<T>の持つフィールドが異なるため、必要です。

この拡張メソッドを使うとAddRangeSpan<T>をダイレクトアタックできます。

最後にベンチマークベンチマークに使ったのは以下の3つのコードで 要素数 N=10000000

  • int[]IEnumerable<T>AddRangeしたもの
  • int[]Span<T>で自作のAddRangeしたもの
  • int[]を、foreach+Addしたもの

で、 BenchmarkDotNet で測定しました。

[MemoryDiagnoser]
public class ListPerformanceTest
{
    private const int N = 10000000;
    private static int[] _items;
    private List<int> _list;

    [GlobalSetup]
    public void GlobalSetup()
    {
        _items = new int[N];
    }

    [IterationSetup]
    public void Setup()
    {
        _list = new List<int>();
    }

    [Benchmark(Baseline = true)]
    public List<int> AddRange_IEnumerable()
    {
        IEnumerable<int> items = _items;
        _list.AddRange(items);
        return _list;
    }

    [Benchmark]
    public List<int> AddRange_Span()
    {
        ReadOnlySpan<int> items = _items;
        _list.AddRange(items);
        return _list;
    }

    [Benchmark]
    public List<int> Add_SpanIter()
    {
        ReadOnlySpan<int> items = _items;
        foreach(var item in items) {
            _list.Add(item);
        }
        return _list;
    }
}

結果は以下の通り。

Method Mean Error StdDev Ratio RatioSD Gen 0 Gen 1 Gen 2 Allocated
AddRange IEnumerable 18.23 ms 2.751 ms 8.111 ms 1.00 0.00 - - - 38.15 MB
AddRange Span 17.56 ms 2.609 ms 7.692 ms 0.97 0.11 - - - 38.15 MB
Add SpanIter 81.10 ms 1.584 ms 2.168 ms 5.68 2.55 - - - 128 MB

当然のごとくforeach+Addは遅い。AddRangeIEnumerable<T>と自作のReadOnlySpan<T>は誤差の範囲内で自作の方が速い。何回か測定してもうちょっと速い時もあったがそれはまあ誤差。対象がint[]なので、ICollection<T>の型チェックによる分岐処理にハマるのでIEnumerable<T>でも十分速い。差が出るのは、IEnumerable<T>の方は他にも微妙に別の型チェックや引数チェックを行ったり、ReadOnlySpan<T>の自作メソッドより余計なことを少ししているせいだろうか。どちらにせよReadOnlySpan<T>オーバーロードAddより十分速い。

参考文献

.NET Framework .NET Core のList<T>ソースコードは以下のリンク。

.NET Framework

github.com

.NET Core

github.com

(C#) List<T>からSpan<T>を引き抜いて高速化

List<T>GetEnumerator()を実装しているため当然foreachで回せる。foreachの速度を落とさないために具象型のEnumeratorを返したりEnumeratorを構造体実装していたりと工夫は凝らされているが、それでもSpan<T>T[]には数倍~10倍程度遅い。この点に関しては、List<T>は状態をバージョン管理しており、列挙中の変更を検知しているためどうしようもない。forで回したところで、ランタイムによってインデックスの境界値チェックが消える特殊最適化がかかるSpan<T>T[]には原理的に勝てない。

 

しかし、List<T>は柔軟で使いやすい。ただ列挙速度も落としたくない。そこで列挙時だけList<T>Span<T>にしてしまおうという話。

以下List<T>の実装を簡略化したもの。

public class List<T>
{
    private T[] _items;
    // ・
    // ・
    // ・
}

List<T>の中身はただの配列なので、System.Runtime.CompilerServices.Unsafeで中身を引き抜く。 ただし、.NET Framework .NET Core でList<T>の内部実装が異なるので注意。 上記のように、先頭にT[]があるのは共通なのでそこだけ利用する。(メモリレイアウトは必ずしもソースコードの定義順に並ぶわけではないが、今回T[]は先頭に来る)

ちなみに .NET Framework のList<T>ソースはこちら。 github.com

.NET Core のList<T>ソースはこちら。

github.com

 

List<T>からSpan<T>の引き抜き方は、ダミークラスを用意して拡張メソッドを用意するだけ。

class ListDummy<T>
{
    internal T[] Items;
}

static class ListExtensions
{
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    internal static Span<T> AsSpan<T>(this List<T> list)
    {
        return Unsafe.As<ListDummy<T>>(list).Items.AsSpan(0, list.Count);
    }
}

あとはこれでforeachを回すだけ。

var list = new List<int>();
foreach(var item in list.AsSpan())
{
    Console.WriteLine(item);
}

注意点は3つ。1つはList<T>の中身をすっぽ抜いているので、容量の拡大時に参照が変わるのでSpan<T>を保持してはいけない。これは一般的な可変長配列の実装を理解していれば分かると思う。上記のAsSpan()Unsafeの力によって実質無料みたいなものなのでその都度AsSpan()してください。

2つ目はこの方法はList<T>の内部実装に依存しているため、将来的にランタイムのバージョンが上がった時にList<T>のメモリレイアウトが変わると正しく動かない点。なのでpublicなライブラリとして公開するのは怖い。自分で管理できる範囲でどうぞ。

3つ目は、yield returnできない点。これはどちらかというとref structの制約。Span<T>.Enumeratorref structなのでインスタンスがヒープに乗ってしまうyield returnはできない。

 

List<T>Span<T>にしてforeachを回すと当然Span<T>と同速が得られるため、使いどころによっては速度改善につながるかもしれない。

[2020/12/31 追記]

.NET5 からは新たに追加されたSystem.Runtime.InteropServices.CollectionsMarshal.AsSpanメソッドを使うと、Unsafeクラスを使わず合法的にList<T>からSpan<T>を取り出せます。

var list = new List<int>();
Span<T> span = CollectionsMarshal.AsSpan(list);

(C#) ラムダ式による this のキャプチャ

ラムダ式による外部変数のキャプチャは、コンパイラによって暗黙的に匿名クラスが作られ、コールされるたびに匿名クラスのインスタンスnewされるため、ヒープアロケーションが発生する。

using System;
public class Class
{    
    public Action Method(int num)
    {
        return () => Console.WriteLine(num);
    }
}

上記のコードはコンパイラによって以下のコードと同義に解釈される。(コンパイラ生成コードの名前は見やすさの為に適当に変えてある)

public class Class
{
    [CompilerGenerated]
    private sealed class HiddenClass
    {
        public int num;
        internal void A() => Console.WriteLine(num);
    }

    public Action Method(int num)
    {
        var tmp = new HiddenClass();
        tmp.num = num;
        return tmp.A;
    }
}

外部変数のキャプチャは、暗黙的に、キャプチャされた変数をメンバに持つクラスインスタンスを生成し、そのインスタンスメソッドがデリゲートとなる。無駄なガベージを生むため、パフォーマンス優先の場所では可能ならキャプチャは避けるのが良い。

 

そして本題。キャプチャは必ず暗黙的クラスインスタンスを生むのかというと、実はそうならない場合もある。その例は以下の場合。

using System;
public class Class
{
    private int _num;    
    public Action Method()
    {
        return () => Console.WriteLine(_num);
    }
}

先ほどと異なるのは、クラスのインスタンスメンバをキャプチャしている点。この場合、キャプチャされているのはthisという考え方になる。このコードのコンパイラによる解釈は以下のようになる。

using System;
public class Class
{
    private int _num;

    public Action Method()
    {
        return A;
    }

    [CompilerGenerated]
    private void A() => Console.WriteLine(_num);
}

thisのみをキャプチャしているラムダ式は、自身のクラスインスタンスメソッドに置き換わるため、無駄なアロケーションが発生しない。よかったですねぇ~

(雑記) int の範囲チェック速度 小ネタ

int型の整数の引数の範囲チェックで、0 から Size の範囲外だったら例外を投げる、のような範囲チェックはする機会が多いが、

int valueint Sizeに対して

if(value < 0 || value >= Size) throw new ArgumentOutOfRangeException();

よりも

if((uint)value >= (uint)Size) throw new ArgumentOutOfRangeException();

の方が演算が1回に減るため速い。これは C# に限った話ではなく整数に2の補数表現を使う処理系全般で使える。整数のビット表現を考えれば当然そうなる。同ビット幅のintunsigned intのキャスト自体になんらかのコストが発生する言語は別。

知っている人は当たり前に使うようなので。私は .NET Framework のソースコード眺めている時に見て知った。

(C# 雑記) Linq の Count() と IReadOnlyCollection<T>

LinqCount()、つまりint Enumerable.Count(IEnumerable<T>)は原理的には全列挙による数え上げだが、配列ライクなものは初めから要素数を持っているのでO(N)を回す必要はない。そのため、配列ライクなものについては直接要素数を取るよう最適化が施されている。

それで、ここで言う「配列ライク」なものというのはICollectionICollection<T>。(.NET Core の場合はもう一つIIListProvider<T>も入っているが、こちらはinternalなのでユーザーにはあまり関係ない)

このあたりの継承関係は、ジェネリック版は

IList<T>ICollection<T>IEnumerable<out T>IEnumerable

ジェネリック

IListICollectionIEnumerable

の継承関係にある。Countを持っているのはICollection<T>ICollection

これとは別に、.NET Framework 4.5 の時点で後から追加されたインターフェースにIReadOnlyList<out T>IReadOnlyCollection<out T>があり、それぞれ

IReadOnlyList<out T>T this[int index] { get; }

IReadOnlyCollection<out T>int Count { get; }

のみを提供している。継承関係は

IReadOnlyList<out T>IReadOnlyCollection<out T>IEnumerable<out T>IEnumerable

になっている。

ここで、IReadOnlyCollection<out T>Countを持っているにもかかわらず、ICollectionICollection<T>のどちらでもないため、LinqCount()の最適化にヒットしない。

では、なぜIReadOnlyCollection<out T>に最適化が入っていないかだが、おそらくis演算子のコストがタダではないからだと思われる。(あくまで個人的推測) IReadOnlyCollection<out T>を継承しているクラスは高確率でICollection<T>を継承しているはずなので、そこでヒットするだろうという考えな気がする。

でも、ICollection<T>ICollectionは余計なメソッドがいっぱいある上、setter を提供してしまうのであまり ReadOnly なクラスにつけたくない。もちろん、いらないものは明示的実装で隠蔽しつつ、インターフェース経由で呼ばれたらNotSupportedExceptionでも投げればいいのだけど、Linq の内部実装の最適化ヒット読みでわざわざそんなことしないといけないなら、なぜIReadOnlyCollection<out T>なんていうインタフェースをわざわざ作ったのか……と思わなくもない。特に、本来静的エラーにできるものを実行時エラーで解決するのは何のための静的型付けだ!!と思ってしまう。

最適化が入ってないのは何か理由があるんですかね?

(C#) 共変性による参照型配列のパフォーマンス

C# の配列は共変性 (covariance) があり、以下のコードはコンパイルできます。

object[] array = new int[10];
array[2] = 5;

そして困ったことに、次のコードもコンパイルできます。

object[] array = new int[10];
array[2] = "hello";    // ← 代入できる!?!? (実行時エラー)

しかし、コンパイルできますが、実際に実行すると実行時エラーになります。arrayインスタンスint[]なのでstringを代入できるはずがありません。にもかかわらず、静的にはエラーになりません。

CLRC#1.0 の時点から配列をサポートしており、C# の型の中でも超特殊な扱いを受けているため、ランタイムの中の見えないところで色々とハックな実装がされています。 上記の共変性の問題はある意味C#の言語設計のミスで、C# の登場時にはジェネリックがなく、汎用型に使える設計にはobjectで受ける以外の選択肢がありませんでした。そして、C#が設計のもとにした Java が同様に配列の共変性を持っているのをそのまま持ってきたのが原因です。もちろん、当時の Java にもジェネリックはありません。

ジェネリック登場以降も、後方互換性のため、今更仕様を変更できずこのようなコードが書けてしまいます。 ジェネリック登場以降は汎用型の用途でobject[]を使うような行儀の悪いコードはまず書きませんが、配列の共変性自体はobjectに限らないため、例えば継承関係にあるクラスAnimalDogAnimal[] animals = new Dog[10];などのコードは今でも十分ありえます。

abstract class Animal { }
class Dog : Animal { }
class Cat : Animal { }

Animal[] animals = new Dog[10];
animals[2] = new Cat();    // runtime error !!!

この時、CLR から実行時エラーが出るということは、暗黙的に型チェックが行われているということです。 つまり、全ての参照型の配列はこの共変性の仕様のせいでパフォーマンスに影響が出ます。(配列変数の型とインスタンスの型が一致している or していないに関わらず発生します)

Animal[] _animals = new Dog[100000];
Dog _dog = new Dog();
int[] _intArray = new int[100000];
int _num = 0;

// 参照型の配列の場合
void M1()
{
    for(int i = 0; i < _animals.Length; i++)
    {
        _animals[i] = _dog;    // 毎回型チェックが入る
    }
}

// 値型の配列の場合
void M2()
{
    for(int i = 0; i < _intArray.Length; i++)
    {
        _intArray[i] = _num;    // 型チェックは発生しない
    }
}

上記のM1メソッドはM2メソッドより遅いです。M1は参照型配列の共変性の仕様により、代入できない型のインスタンスが代入されないように暗黙的に型チェックが毎ループ走ります。 一方で、C# の構造体は継承はないため、共変性・反変性とは無縁であり、値型の配列に型チェックは発生しません。

この型チェックを回避するための方法として構造体にラップするというのがあります。

struct Wrapper
{
    public readonly Animal animal;
    public Wrapper(Animal animal) => Animal = animal;
}
Wrapper[] _wrapperArray = new Wrapper[100000];
Wrapper _wrapper = new Wrapper(new Dog());

void M3()
{
    for(int i = 0; i < _wrapperArray.Length; i++)
    {
        _wrapperArray[i] = _wrapper;    // 型チェックは発生しない
    }
}

構造体にラップすると、値型の配列になり、共変性はないため型チェックが消えます。

 

これは CLR が変わらない限り (後方互換性のためまず変わらないと思うけど)、参照型の配列全てで発生する問題です。もちろん、よっぽどホットな高速化が必要な部分以外は気にする必要がない問題ですが、知っておいて損はないはずです。 とはいえ、気にし過ぎは可読性や実装コストの面で有害ですらあるので、必要な場面以外は忘れておきましょう。。。

参考文献

devblogs.microsoft.com

(C#) 参照型インスタンスのアドレスを取得する

C# では unsafe キーワードを使うことで値型のポインタや、unmanaged型配列の配列要素へのポインタを取得できますが、 参照型インスタンスへのポインタは取得できません。ガベージコレクション (GC) によるコンパクションでアドレスが移動する可能性があり、C# 側からはメモリ移動を検知する手段も用意されていないため言語仕様として当然です。

が、System.Runtime.CompilerServices.Unsafeクラスを使うと実は取得できます。 Unsafeクラスについては以前の記事にも少し書きましたが、かなり乱暴なことができます。unsafeキーワード以上にアンセーフです。

この記事の内容はたぶん言語としての動作保証仕様外。言語仕様の外に片足突っ込んてるそれなりにハックな内容。

手法

// 何かしらの参照型
class Sample()
{
}
unsafe struct Pointer
{
    public void* P;
}

// ~~~~~~~~~~~~~~~~~~~~~~~~

// 参照型インスタンスのポインタを取得
var sample = new Sample();
ref byte refSample
        = ref Unsafe.AsRef<byte>(Unsafe.As<Sample, Pointer>(ref sample).P);

Sampleはクラスです。クラスへのポインタは言語仕様としては取れませんが、強引に取得します。

まず、上記のようにvoid*型のフィールドを1つだけ持つ構造体を用意し、 Unsafe.As()をつかって参照型インスタンスをこの構造体に強制キャストします。この時、得られるvoid*型のフィールドが参照型インスタンスのアドレスです。

void* address = Unsafe.As<Sample, Pointer>(ref sample);

[2020/07/11 追記] よく考えると上記の「void*型のフィールドを1つだけもつ構造体」はIntPtrそのものなので、 わざわざ自分で用意するまでもなくIntPtrでできますね。

これだけで目的は達しているのですが、前述の通りGCのコンパクションで移動しうるので、 このポインタはいつどのタイミングで無効なポインタになってもおかしくないです。 そこで、このポインタをref byteに変換します。

ref byte refSample = ref Unsafe.AsRef<byte>(address);

refにするとGCによるトラッキングを受けられるため、コンパクションによるメモリ移動時に無効な参照になりません。 本来、参照型に対してrefなんて使えるものではないのに本当にGCにトラッキングされるのか?という疑問が当然出ますが、実際確認した結果、きちんとトラッキングされていました。 確認したコードは以下の gist に。

gist.github.com

安全性

本当にコンパクションによるメモリ移動で無効な参照を引かないのか。 正直なところ100%安全だと私は断言できませんでした。でもまあ十中八九大丈夫でしょう。

JITコンパイル結果を見ます。.NET Core で JITは64bitのリリースビルドを見ます。 JIT結果は sharplab で確認しました。リンクはこちら

参照型としてstring型を見てますが、別に参照型なら何でも同じです。参照型インスタンスref byteに変換するメソッドを用意しました。

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public unsafe ref byte GetRef(string obj)
{
    return ref Unsafe.AsRef<byte>(Unsafe.As<string, Pointer>(ref obj).P);
}

このメソッドの JIT コンパイル結果 (amd64) は以下の通り。

C.GetRef(System.String)
    L0000: mov [rsp+0x10], rdx
    L0005: mov rax, [rsp+0x10]
    L000a: ret

スタックに乗せて、乗せた値を返しているだけです。 当然と言えば当然ですが、引数を素通しで返すだけの虚無みたいな逆コンパイル結果が吐かれます。テンションが上がりますね。

Unsafe.AsRef(), Unsafe.As() のどちらも.NET Core のソースコード (AsRef(), As() ) を見てもらえば分かりますが (どちらも C# で書けないため IL で書かれてますが)、両者とも引数を素通しするだけの非常にクールでイカしたメソッドなので、 最適化によって何もしない虚無が生成されます。非常にテンション上がりますね。結果、上記のようになるわけですけど、これって GC が挟まる余地があるんでしょうか?

ref byteになってしまった後のコードは全て GC のトラッキングを受けられるため安全ですが、ILの型として一瞬ポインタを経由する瞬間に移動したら死亡しますが、その部分って最適化で跡形もなく消えてるのでは?と思うため個人的には安全だと思っています。

しかし、私自身が C#コンパイラ .NET のランタイムのソースコードを読んだりしていないため、何とも言えません。 あくまで言語仕様外のことを強引に行っているのをお忘れなく。。。

その他

コンパクションで移動しうる参照をrefでつかむとコンパクションに対して確実に安全なのだとしたら、マネージド配列に対するfixedっていらなくない?

同じようなことやっている記事を見かけたのだけれど。

azyobuzin.hatenablog.com

なんというか、MSからUnsafeクラスなんていう危険なオモチャが公式で提供されてるのですから、遊ばずにはいられないですよね。。。