ネコのために鐘は鳴る

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

(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クラスなんていう危険なオモチャが公式で提供されてるのですから、遊ばずにはいられないですよね。。。

(雑記) xorshiftって0出ないよね

疑似乱数生成アルゴリズムの Xorshift を実装してみて思ったんですが、これって何回生成しても0は出てこないですよね。

Xorshift のアルゴリズム元論文見るのが早いです。すごい短いし。解説サイトだとこのページが丁寧。

要約すると、32bit版はこれです。

// unsigned int を符号なし32bitとする
unsigned int seed;

unsigned int Xorshift()
{
    seed ^= (seed << 13);
    seed ^= (seed >> 17);
    seed ^= (seed << 5);
    return seed;
}

で、seedが最初0だったら永遠に0しか出ない。それはseedに0を入れなければいいだけかもしれないが、seedが0以外だと0は出ない。

暗号学とか乱数アルゴリズムとか全然詳しくないんですが、疑似乱数ってそういうものなんですかね?0も乱数列内に含んでほしい時ってどうすればいいんでしょうか。詳しい方いたら教えてほしいです