てくメモ

trivial な notes

【C#】xoshiro 法の Random にシード(状態)を与える

.NET 6.0 以降のRandomクラスは、シード指定時と未指定時で使用されるアルゴリズムが異なり、未指定時のみ xoshiro 法が用いられる。
参考:.NET 6 (Preview) における System.Random の実装変更 - 屋根裏工房改

シード指定では従来型のアルゴリズムが用いられるが、こちらはあまり良くないとされている。
参考:.NET System.Random の実装と欠陥について ~ 重箱の隅をつつきたおす ~ - 屋根裏工房改


品質もパフォーマンスも xoshiro 法が優れるため常にそちらを使いたいが、通常はシードを与えて使うことができない。
単純に優れている方があるのに(おそらく互換性のために)使えない…… なんとかならないか?


まず前提以前の話として、別の乱数生成器を使うという選択肢がある。
例えば Unity の乱数は Xorshift 法で、シード(というか、状態)を設定可能。


前提を標準ライブラリに置く。

普通なら内部状態の取得にはリフレクション。
リフレクションで値を取得しておいて、それで状態を復元することで擬似的にシード付き初期化になる。

Random rand = new();

var xoshiro = typeof(Random)?
    .GetField("_impl", BindingFlags.NonPublic | BindingFlags.Instance)?
    .GetValue(rand);
var fields = Type.GetType("System.Random+XoshiroImpl, System.Private.CoreLib")?
    .GetFields(BindingFlags.NonPublic | BindingFlags.Instance)!;
var s0Info = fields.First(v => v.Name == "_s0");
var s1Info = fields.First(v => v.Name == "_s1");
var s2Info = fields.First(v => v.Name == "_s2");
var s3Info = fields.First(v => v.Name == "_s3");

// 内部状態を保存
var state = ((ulong)s0Info.GetValue(xoshiro)!,
    (ulong)s1Info.GetValue(xoshiro)!,
    (ulong)s2Info.GetValue(xoshiro)!,
    (ulong)s3Info.GetValue(xoshiro)!);

// int を16回ほど出力
Span<int> nums1 = stackalloc int[16];
for (int i = 0; i < nums1.Length; i++)
    nums1[i] = rand.Next(256);
Console.WriteLine(nums1.ToListString()); // ToListString という拡張メソッドがあるものとする

// 復元
var (s0, s1, s2, s3) = state;
s0Info.SetValue(xoshiro, s0);
s1Info.SetValue(xoshiro, s1);
s2Info.SetValue(xoshiro, s2);
s3Info.SetValue(xoshiro, s3);

// もう一度 int を出力して確認
Span<int> nums2 = stackalloc int[16];
for (int i = 0; i < nums2.Length; i++)
    nums2[i] = rand.Next(256);
Console.WriteLine($"SequenceEqual: {nums1.SequenceEqual(nums2)}");
Console.WriteLine(nums2.ToListString());

// [140, 127, 218, 221, 180, 182, 73, 117, 228, 208, 43, 37, 209, 157, 142, 244]
// SequenceEqual: True
// [140, 127, 218, 221, 180, 182, 73, 117, 228, 208, 43, 37, 209, 157, 142, 244]

再現を無事確認。


さてここでリフレクション以外の手段として、以前学んだ書込み用ビューを用いる手法を実験する。
参考:【C#】Span を List<T>.AddRange する - てくメモ


次のようなビューを用意する。

// ✕適用不可: Random.Shared (中身は Random の継承クラス ThreadSafeRandom)
// ✕適用不可: シードを与えて new した Random
// ✕適用不可: .NET6.0 より前
internal sealed class RandomXoshiroView
{
    public XoshiroView _impl;
    internal sealed class XoshiroView
    {
        public ulong _s0, _s1, _s2, _s3;
    }
}

コメントで色々注記を添えたけれど、とりあえず脇に置いて進める。

new Random()したインスタンスをこのビューにUnsafe.Asすることで状態の読み書きを行うことができる。

状態を取るStateメソッドと、設定するInitStateメソッドを以下のとおり作成。

public static (ulong s0, ulong s1, ulong s2, ulong s3) State(this Random rand)
{
    var view = Unsafe.As<Random, RandomXoshiroView>(ref rand)._impl;
    return (view._s0, view._s1, view._s2, view._s3);
}

public static void InitState(this Random rand, in (ulong s0, ulong s1, ulong s2, ulong s3) state)
{
    var view = Unsafe.As<Random, RandomXoshiroView>(ref rand)._impl;
    view._s0 = state.s0;
    view._s1 = state.s1;
    view._s2 = state.s2;
    view._s3 = state.s3;
}


先ほどリフレクションで取得した状態を叩き込んで、同じ出力になることを確認してみる。

// 先ほど取得した state を設定
rand.InitState(state);

// 確認
Span<int> nums3 = stackalloc int[16];
for (int i = 0; i < nums3.Length; i++)
    nums3[i] = rand.Next(256);
Console.WriteLine($"SequenceEqual: {nums1.SequenceEqual(nums3)}");
Console.WriteLine(nums3.ToListString());

// SequenceEqual: True
// [140, 127, 218, 221, 180, 182, 73, 117, 228, 208, 43, 37, 209, 157, 142, 244]

OK!


また、状態を取得・設定できるということは、勝手に遷移させられる。

xoshiro 法のRandomはサンプリングでulongを得るが、それを取得する手段がない。
ということで、それをエミュレートしてみる。
参考:Source Browser

public static ulong NextUInt64(this Random rand)
{
    var view = Unsafe.As<Random, RandomXoshiroView>(ref rand)._impl;

    ulong s0 = view._s0, s1 = view._s1, s2 = view._s2, s3 = view._s3;

    ulong result = BitOperations.RotateLeft(s1 * 5, 7) * 9;
    ulong t = s1 << 17;

    s2 ^= s0;
    s3 ^= s1;
    s1 ^= s2;
    s0 ^= s3;

    s2 ^= t;
    s3 = BitOperations.RotateLeft(s3, 45);

    view._s0 = s0;
    view._s1 = s1;
    view._s2 = s2;
    view._s3 = s3;

    return result;
}


状態が正しく遷移するか確認する。
NextDouble()は内部的に一回だけ状態を消費するので、それと較べる。

Random rand = new();
var state0 = rand.State(); // 状態を保存

rand.NextDouble();
var state1 = rand.State(); // NextDouble 後の状態

rand.InitState(state0); // 復元
rand.NextUInt64();
var state2 = rand.State(); // 上記拡張メソッドの NextUInt64 後の状態

// 正しく遷移したら true のはず
Console.WriteLine($"state1 == state2: {state1 == state2}");

// state1 == state2: True

OK!


―― と、色々書いてきたけれど、この方法はやはり安全さが……

環境(バージョン)次第でダメ、Random.Sharedはダメ、シード付きで初期化してるとダメ。

注意すればいいのだけれど、注意が必要という時点でまさに Unsafe。

じゃあなぜその方法で記事を?
ビューをUnsafe.Asするという銃を握ったら…… 撃ちたくなって…… 😥


品質の良い乱数をシード付きで使いたいときは、素直に別の乱数生成器を素直に使うのが安全。