てくメモ

trivial な notes

索引


C#

言語機能

その他

Godot Engine

【Godot C#】R3.Godot のUI用便利拡張メソッド

現在プレビューリリースされている Rx ライブラリR3。Unity の R3.Unity にはUI用便利拡張メソッド群の UnityUIComponentExtensions が用意されているが、Godot にはなかった。

ver.0.1.6 までは。

PRを出して無事マージしてもらい、Godot でも同じような感じのものが利用できるようになった(ver. 0.1.7~)。動作確認をとったものがショーケースに流用できると思ったので、以下に動画とコードを貼って記事に。

https://i.imgur.com/C76OmhE.png (apng なので表示・再生できないことがあるかもしれない)

public partial class UiShowcase : Node2D
{
    private readonly CancellationTokenSource cts = new();

    public override void _Ready()
    {
        var button = GetNode<Button>("Button");
        var label = GetNode<Label>("Label");
        var toggleButton = GetNode<Button>("ToggleButton");
        var label2 = GetNode<Label>("Label2");
        var checkButton = GetNode<CheckButton>("CheckButton");
        var label3 = GetNode<Label>("Label3");
        var slider = GetNode<HSlider>("HSlider");
        var label4 = GetNode<Label>("Label4");
        var spinBox = GetNode<SpinBox>("SpinBox");
        var label5 = GetNode<Label>("Label5");
        var lineEdit = GetNode<LineEdit>("LineEdit");
        var label6 = GetNode<Label>("Label6");
        var lineEdit2 = GetNode<LineEdit>("LineEdit2");
        var label7 = GetNode<Label>("Label7");
        var textEdit = GetNode<TextEdit>("TextEdit");
        var label8 = GetNode<Label>("Label8");
        var optionButton = GetNode<OptionButton>("OptionButton");
        var label9 = GetNode<Label>("Label9");

        var countSubscriptionsButton = GetNode<Button>("CountSubscriptionsButton");
        var labelSubscriptionCount = GetNode<Label>("LabelSubscriptionCount");
        var cancelAndGcButton = GetNode<Button>("CancelAndGcButton");
        var labelCanceled = GetNode<Label>("LabelCanceled");

        SubscriptionTracker.EnableTracking = true;
        //SubscriptionTracker.EnableStackTrace = true;

        // OnPressedAsObservable
        var count = 0;
        button.OnPressedAsObservable(cts.Token)
            .Select(_ => ++count)
            .SubscribeToLabel(label);

        // OnToggledAsObservable
        toggleButton.OnToggledAsObservable(cts.Token)
            .SubscribeToLabel(label2);
        checkButton.OnToggledAsObservable(cts.Token)
            .SubscribeToLabel(label3);

        // OnValueChangedAsObservable
        slider.OnValueChangedAsObservable(cts.Token)
            .SubscribeToLabel(label4);
        spinBox.OnValueChangedAsObservable(cts.Token)
            .SubscribeToLabel(label5);

        // OnTextSubmittedAsObservable
        lineEdit.OnTextSubmittedAsObservable(cts.Token)
            .SubscribeToLabel(label6);

        // OnTextChangedAsObservable
        lineEdit2.OnTextChangedAsObservable(cts.Token)
            .SubscribeToLabel(label7);
        textEdit.OnTextChangedAsObservable(cts.Token)
            .Select(_ => textEdit.Text)
            .SubscribeToLabel(label8);

        // OnItemSelectedAsObservable
        optionButton.OnItemSelectedAsObservable(cts.Token)
            .SubscribeToLabel(label9);

        // for debug
        countSubscriptionsButton.Pressed += () =>
        {
            var count = 0;
            SubscriptionTracker.ForEachActiveTask(state =>
            {
                ++count;
                if (SubscriptionTracker.EnableStackTrace) GD.Print(state);
            });

            labelSubscriptionCount.Text = count.ToString();
        };
        cancelAndGcButton.Pressed += () =>
        {
            GD.Print("cancel & GC");
            cts.Cancel();
            GC.Collect();

            labelCanceled.Text = "CANCELED!";
        };
    }

    protected override void Dispose(bool disposing)
    {
        cts.Cancel();
        cts.Dispose();
    }
}

比較的頻出のUIイベントをシンプルに利用できる。イベント処理をベタ書きしようとするとObservable.FromEvent利用となるが、少しカロリーの高い書き方になるので、手軽に Rx するために便利拡張メソッドがあるのは良い。

キャンセルトークンについて

R3.Unity との違いとして、引数にキャンセルトークンを受ける(省略可能)。

これはどちらかというとなぜ R3.Unity がそうなっていないのか、という話。Unity はdestroyCancellationTokenという仕組みを持ち、R3.Unity は暗黙にそのトークンに利用している。

一方、明示的にトークンを渡せるのは任意タイミングのキャンセル可といったプラスの面もある。省略可能引数なので、選択肢が増えている、という捉え方もできる。


ちなみに、ver.0.1.7 は購読トラッカーのエディタUI実装 & R3.Godot のプラグイン化という大きな前進もあった。

R3、Godot に馴染んでくれて、良い。

【Godot C#】Cysharp の次世代 Rx ライブラリ「R3」を Godot で使ってみる


【追記】 この記事はR3のプレビューリリース時に書かれたものであり、正式リリース (2024/2/16) においては内容が符合しない部分があります。



Cysharp からプレビューリリースされた次世代 Rx ライブラリ、「R3」を Godot(ver. 4.2.1)で試す。

R3はなんと、プレビューリリース時点で明示的に Godot (4.x) をサポートしている。というわけでこの記事では、R3 on Godot C# なコードを書いて動かしてみる。

また、白眉な特徴であるトラッキング機能も確認する。



導入

Nuget で導入するだけでもコア機能の利用は可。

ただし、フレームベース処理といったプラットフォームサポートを受けるには作業が必要になる。

(上記リンク先導入手順 3. の前に、書いてないけれど、一旦ビルドするといいかもしれない。(自分はそのままだとエラーが出た))

プロジェクトページにも導入方法は書いてあるが、一応以下に記述(追記:ver. 0.1.7以降変わったため折りたたみ)

古くなったため折りたたみ

  1. Nuget から R3 を導入
  2. ダウンロードするなり clone するなりして、R3 プロジェクトの src/R3.Godot にある addons ディレクトリを、導入するプロジェクトに置く
  3. 「プロジェクト」メニュー ⇨ 「プロジェクト設定...」 ⇨ 「自動読み込み」タブにおいて、R3.Godot 内の FrameProviderDispatcher.cs を設定

プロジェクト ⇨ プロジェクト設定 ⇨ 自動読み込み

最初の例

まずは R3 のプロジェクトページに示されている例を見る。(コメントは付したもの)

public partial class Node2D : Godot.Node2D
{
    IDisposable subscription;

    public override void _Ready()
    {
        subscription = Observable.EveryUpdate()
            .ThrottleLastFrame(10) // 10 フレームごとに
            .Subscribe(x =>
            {
                // フレームカウントを GD.Print
                GD.Print($"Observable.EveryUpdate: {GodotFrameProvider.Process.GetFrameCount()}");
            });
    }

    protected override void Dispose(bool disposing)
    {
        // 購読を解除
        subscription?.Dispose();
    }
}

Godot において、10フレームごとにプリントするというフレームベースの処理が実現されている。

EveryUpdateのようなジェネレーターメソッドによる Observable 生成、ThrottleLastFrameのようなオペレーターメソッドによる操作、Subscribeによる購読、購読は最後に破棄、と、外見は当たり前だが Rx。

一方で、再設計などによるパフォーマンス向上やAPIの洗練などが行われており、Rx ライブラリとして選ぶだけでそれらの恩恵を受けることができる。

良い。

マウスを追いかけるアイコン ~ キャンセルトークンによるライフタイム管理

それでは、実践として動くものを書く。

マウスを追いかけるアイコンを Rx で表現してみる。

https://i.imgur.com/EiQcmEZ.png

(動画は apng であり、表示・再生されない場合があるかもしれない)
(パーティクル素材:Kenney's particle pack)

public partial class MainScene : Node2D
{
    private readonly CancellationTokenSource cts = new();
    private IDisposable disposables;

    private Viewport viewport;
    private Node2D icon;
    private Label labelMousePosition;

    private ReadOnlyReactiveProperty<Vector2> mousePosition;
    private ReadOnlyReactiveProperty<Vector2> iconPosition;

    public override void _Ready()
    {
        // GetNode など省略(簡潔のため)
        
        // IDisposable をまとめるビルダー
        var d = Disposable.CreateBuilder();
        cts.AddTo(ref d);

        // マウス位置(10Fごと更新)
        mousePosition = Observable.EveryUpdate(cts.Token)
            .ThrottleLastFrame(10)
            .Select(_ => viewport.GetMousePosition())
            .ToReadOnlyReactiveProperty();

        // アイコン位置
        iconPosition = Observable.EveryUpdate(cts.Token)
            .Select(_ =>
            {
                var current = iconPosition.CurrentValue;
                const float q = 1 / 12f;
                var v = q * (mousePosition.CurrentValue - current);
                return current + v;
            })
            .ToReadOnlyReactiveProperty();

        // UI更新
        mousePosition.Subscribe(p => labelMousePosition.Text = $"{p}");
        iconPosition.Subscribe(p => icon.Position = p);

        // DisposableBuilder をビルドして単一の IDisposable を取得
        disposables = d.Build();
    }

    protected override void Dispose(bool disposing)
    {
        cts.Cancel();
        disposables?.Dispose();

        base.Dispose(disposing);
    }
}

時間が関わる処理を、名前をつけて宣言的に書けるのは Rx の美徳。EveryUpdateが生成する Observable をソースとして、ReadOnlyReactivePropertyでマウスとアイコンの位置を定義した。

そして、Observable.EveryUpdateにキャンセルトークンを渡した。

R3 では、ジェネレーターメソッドやTakeUntilメソッドなどにキャンセルトークンを渡すオーバーロードがある。ノード破棄時にキャンセルして紐つけた Observable を完了させられるほか、単純に任意タイミングのキャンセルとして用いることもできる。Task による非同期処理はキャンセルトークンを用いるので、並びとして統一的1

Subscribeの返り値をAddToしていないが、上流からキャンセルすれば Observable が完了し、購読は終了する。subscription、上から切るか(キャンセルトークン)、下から切るか(AddTo)。個人的には、上からの管理は明瞭に思える。

また、Disposable.CreateBuilder()を使った。これは複数のIDisposableAddToを通して投げ込み、最後にBuildすることで単一のIDisposableを取得できる。なお、R3 にはIDisposable構築手段が複数用意されているので、効率や記述の楽さなどの志向に合わせて選べるようになっている。

アイコンがマウスに追いついているときの処理を追加する ~ Observable の合成

Observable ストリームは、合成などの操作を行うことができる。

ここでは、アイコンがマウスに追いついたときの処理を追加してみる。具体的には、パーティクルを派手にする。

https://i.imgur.com/ekxc4rt.png

public partial class MainScene : Node2D
{
    // 前掲部分省略

    private GpuParticles2D iconParticles;

    private Observable<bool> areCloseMouseAndIcon;

    public override void _Ready()
    {
        // 前掲部分、GetNode など省略

        // アイコンとマウスが概ね同じ位置か
        areCloseMouseAndIcon = mousePosition.CombineLatest(iconPosition, static (p1, p2) =>
            {
                var v = p1 - p2;
                return MathF.Abs(v.X) < 4f && MathF.Abs(v.Y) < 4f;
            })
            .DistinctUntilChanged();

        // パーティクル
        areCloseMouseAndIcon.Subscribe(
            onNext: areClose =>
            {
                var pm = iconParticles.ProcessMaterial;
                if (areClose)
                {
                    // アイコンがマウスに追いついていたらパーティクルを派手に
                    // (冗長なので具体的な操作省略)
                }
                else
                {
                    // 通常に
                    // (冗長なので具体的な操作省略)
                }
            },
            onComplete: _ =>
            {
                // 完了時にパーティクルを止める
                iconParticles.AmountRatio = 0f;
            });
    }
}

マウス位置とアイコン位置の最新の値をCombineLatestによって合成することで、アイコンがマウスに追いついているか定義した。それを購読することでパーティクルのパラメータを変更している。

また、購読完了時(OnCompleted)の処理も書いてみた。エラー時(OnErrorResume)の処理も書ける。

ところで、R3ではエラー時に購読が解除されない。これは本家 ReactiveExtensions や UniRx などと異なる挙動で、継続する必要がある場合に購読し直しを頑張る必要がなくなっている。一方で、例外を見て何かをしたい場合はOnErrorResumeOnCompletedResult型で情報を取れる)で容易に書けるため、戦略を選択しやすくなっている。

なお、起きたエラーはUnhandledExceptionHandlerを通る。R3.Godot の標準の挙動はGD.PrintErr。この挙動を変更したい場合、例えばGD.PushErrorにしたいなら、次のようにすればよい。

ObservableSystem.RegisterUnhandledExceptionHandler(ex => GD.PushError(ex));

クリアを設定してゲームにする ~ シグナルの処理

せっかくなのでクリアを設定してゲームにしてみる。

ここでは Godot との連携という意味で、シグナルを使う。ゲームクリアをシグナルで通知し、それを R3 で処理してみる。クリアのゴールは目標エリアに指定回数タッチ、とする。

https://i.imgur.com/DLr2ZUz.png

(追記:試行錯誤中のコードを消し忘れていたのを修正しました)

public partial class MainScene : Node2D
{
    // 前掲部分省略

    private ColorRect targetRect;
    private Label labelCount;
    private Label labelTime;
    private ColorRect clearOverlay;
    private Label labelScoreTime;

    private readonly ReactiveProperty<int> touchCount = new(0);
    private ReadOnlyReactiveProperty<double> elapsedSeconds;

    [Signal]
    public delegate void GameClearEventHandler();
    
    public override void _Ready()
    {
        // 前掲部分、GetNode など省略

        // 矩形タッチ処理
        touchCount.AddTo(ref d);
        iconPosition
            .Where(p => targetRect.GetRect().HasPoint(p)) // 目標矩形の範囲内にアイコンがいたら
            .Subscribe(_ =>
            {
                touchCount.Value += 1; // タッチ回数++

                if (touchCount.Value < 5) // 5回繰り返す
                {
                    // 目標矩形再設定
                    var windowSize = viewport.GetWindow().Size;
                    var r = Random.Shared;
                    var x = r.Next(0, windowSize.X - (int)targetRect.Size.X);
                    var y = r.Next(0, windowSize.Y - (int)targetRect.Size.Y);
                    targetRect.Position = new(x, y);
                }
                else
                {
                    // ゲームクリアのシグナルを発火
                    EmitSignal(SignalName.GameClear);
                }
            });

        // 経過タイム(0.1秒単位)
        TimeProvider tp = GodotTimeProvider.Process;
        var startingTime = tp.GetTimestamp();
        elapsedSeconds = Observable.EveryUpdate(cts.Token)
            .ThrottleLast(TimeSpan.FromSeconds(0.1), tp)
            .Select(_ => tp.GetElapsedTime(startingTime).TotalSeconds)
            .ToReadOnlyReactiveProperty();

        // UI更新
        touchCount.Subscribe(v => labelCount.Text = $"{v} pts.");
        elapsedSeconds.Subscribe(v => labelTime.Text = $"{v:0.0}秒");

        // ゲームクリア
        Observable.FromEvent(
                h => new GameClearEventHandler(h),
                h => GameClear += h,
                h => GameClear -= h)
            .Take(1)
            .Subscribe(_ =>
            {
                labelScoreTime.Text = $"{elapsedSeconds.CurrentValue:0.0}秒";
                clearOverlay.Show(); // クリア画面を表示
                cts.Cancel(); // キャンセル発火
            })
            .AddTo(ref d);
    }
}

シグナルの扱いには、Observable.FromEventを利用した。シグナルはデリゲートを定義するが、conversion を渡すオーバーロードを利用することで簡潔に扱うことができた。

今回の例では一度だけ待つので、本来は async/await が適するかもしれない。ただ、イベントは Rx を自然かつ便利に活用しうる例として挙げられるもののひとつ。シグナルはイベントを利用する仕組みであるため、Godot と Rx との親和ポイントとして数えてもいいかもしれない。

なおイベントということは、UIイベントも Rx の範疇。この点、R3.Unity ではOnClickAsObservableといった便利拡張メソッド群が用意されている。R3.Godot では現状まだ。(【追記】追加されました (ver.0.1.7~)。(参考:【Godot C#】R3.Godot のUI用便利拡張メソッド - てくメモ))

また、クリア時にキャンセルを発火してみた。貼った動画画像のとおり、購読に任せていたUI更新はクリア時に停止。ノードの寿命で区切る意味のほか、こうした任意タイミングキャンセルもよさそうに感じた。


というわけで以上のように、宣言的な記述といった Rx の表現を重ねることで、単純ながら動くものをつくることができた。

R3 on Godot、楽しい。


なお、参考でノード構成とコード全容を折りたたんでおく。

ノード構成画像とコード折りたたみ(長い)

ノード構成

using System;
using System.Threading;
using Godot;
using R3;

namespace Rx3Playground;

public partial class MainScene : Node2D
{
    private readonly CancellationTokenSource cts = new();
    private IDisposable disposables;

    private Viewport viewport;
    private Node2D icon;
    private GpuParticles2D iconParticles;
    private ColorRect targetRect;
    private Label labelMousePosition;
    private Label labelCount;
    private Label labelTime;
    private ColorRect clearOverlay;
    private Label labelScoreTime;

    private ReadOnlyReactiveProperty<Vector2> mousePosition;
    private ReadOnlyReactiveProperty<Vector2> iconPosition;
    private Observable<bool> areCloseMouseAndIcon;
    private readonly ReactiveProperty<int> touchCount = new(0);
    private ReadOnlyReactiveProperty<double> elapsedSeconds;

    [Signal]
    public delegate void GameClearEventHandler();

    public override void _Ready()
    {
        viewport = GetViewport();
        icon = GetNode<Node2D>("Icon");
        iconParticles = GetNode<GpuParticles2D>("Icon/IconParticles");
        targetRect = GetNode<ColorRect>("Rect");
        labelMousePosition = GetNode<Label>("Hud/LabelMousePosition");
        labelCount = GetNode<Label>("Hud/LabelCount");
        labelTime = GetNode<Label>("Hud/LabelTime");
        clearOverlay = GetNode<ColorRect>("ClearOverlay");
        labelScoreTime = GetNode<Label>("ClearOverlay/VBoxContainer/LabelScoreTime");

        // UnhandledExceptionHandler を GD.PushError に
        ObservableSystem.RegisterUnhandledExceptionHandler(ex => GD.PushError(ex));

        // 購読トラッカー有効
        SubscriptionTracker.EnableTracking = true;
        //SubscriptionTracker.EnableStackTrace = true;

        // IDisposable をまとめるビルダー
        var d = Disposable.CreateBuilder();
        cts.AddTo(ref d);

        // マウス位置(10Fごと更新)
        mousePosition = Observable.EveryUpdate(cts.Token)
            .ThrottleLastFrame(10)
            .Select(_ => viewport.GetMousePosition())
            .ToReadOnlyReactiveProperty();

        // アイコン位置
        iconPosition = Observable.EveryUpdate(cts.Token)
            .Select(_ =>
            {
                var current = iconPosition.CurrentValue;
                const float q = 1 / 12f;
                var v = q * (mousePosition.CurrentValue - current);
                return current + v;
            })
            .ToReadOnlyReactiveProperty();

        // アイコンとマウスが概ね同じ位置か
        areCloseMouseAndIcon = mousePosition.CombineLatest(iconPosition, static (p1, p2) =>
            {
                var v = p1 - p2;
                return MathF.Abs(v.X) < 4f && MathF.Abs(v.Y) < 4f;
            })
            .DistinctUntilChanged();

        // 矩形タッチ処理
        touchCount.AddTo(ref d);
        iconPosition
            .Where(p => targetRect.GetRect().HasPoint(p)) // 目標矩形の範囲内にアイコンがいたら
            .Subscribe(_ =>
            {
                touchCount.Value += 1; // タッチ回数++

                if (touchCount.Value < 5) // 5回繰り返す
                {
                    // 目標矩形再設定
                    var windowSize = viewport.GetWindow().Size;
                    var r = Random.Shared;
                    var x = r.Next(0, windowSize.X - (int)targetRect.Size.X);
                    var y = r.Next(0, windowSize.Y - (int)targetRect.Size.Y);
                    targetRect.Position = new(x, y);
                }
                else
                {
                    // ゲームクリアのシグナルを発火
                    EmitSignal(SignalName.GameClear);
                }
            });

        // 経過タイム(0.1秒単位)
        TimeProvider tp = GodotTimeProvider.Process;
        var startingTime = tp.GetTimestamp();
        elapsedSeconds = Observable.EveryUpdate(cts.Token)
            .ThrottleLast(TimeSpan.FromSeconds(0.1), tp)
            .Select(_ => tp.GetElapsedTime(startingTime).TotalSeconds)
            .ToReadOnlyReactiveProperty();

        // UI更新
        mousePosition.Subscribe(p => labelMousePosition.Text = $"{p}");
        iconPosition.Subscribe(p => icon.Position = p);
        touchCount.Subscribe(v => labelCount.Text = $"{v} pts.");
        elapsedSeconds.Subscribe(v => labelTime.Text = $"{v:0.0}秒");

        // ゲームクリア
        Observable.FromEvent(
                h => new GameClearEventHandler(h),
                h => GameClear += h,
                h => GameClear -= h)
            .Take(1)
            .Subscribe(_ =>
            {
                labelScoreTime.Text = $"{elapsedSeconds.CurrentValue:0.0}秒";
                clearOverlay.Show(); // クリア画面を表示
                cts.Cancel(); // キャンセル発火
            })
            .AddTo(ref d);

        // パーティクル
        areCloseMouseAndIcon.Subscribe(
            onNext: areClose =>
            {
                var pm = iconParticles.ProcessMaterial;
                if (areClose)
                {
                    // アイコンがマウスに追いついていたらパーティクルを派手に
                    iconParticles.AmountRatio = 1f;
                    iconParticles.SpeedScale = 1.5f;
                    pm.Set("initial_velocity_min", 180f);
                    pm.Set("initial_velocity_max", 180f);
                    pm.Set("radial_accel_min", 75f);
                    pm.Set("radial_accel_max", 100f);
                    pm.Set("tangential_accel_min", 25f);
                    pm.Set("tangential_accel_max", 100f);
                }
                else
                {
                    // 通常に
                    iconParticles.AmountRatio = 1 / 6f;
                    iconParticles.SpeedScale = 1f;
                    pm.Set("initial_velocity_min", 0f);
                    pm.Set("initial_velocity_max", 0f);
                    pm.Set("radial_accel_min", 0f);
                    pm.Set("radial_accel_max", 0f);
                    pm.Set("tangential_accel_min", 0f);
                    pm.Set("tangential_accel_max", 0f);
                }
            },
            onComplete: _ =>
            {
                // 完了時にパーティクルを止める
                iconParticles.AmountRatio = 0f;
            });

        // DisposableBuilder をビルドして IDisposable を取得
        disposables = d.Build();
    }

    public override void _Input(InputEvent @event)
    {
        // for debug
        if (@event.IsActionReleased("ui_cancel"))
        {
            GD.Print("cancel and dispose.");
            cts.Cancel();
            disposables.Dispose();
        }
        else if (@event.IsActionReleased("ui_focus_next"))
        {
            var count = 0;
            SubscriptionTracker.ForEachActiveTask(state =>
            {
                ++count;
                if (SubscriptionTracker.EnableStackTrace) GD.Print(state);
            });
            GD.Print($"current subscription count: {count}");
        }
        else if (@event.IsActionReleased("ui_accept"))
        {
            GD.Print("GC.Collect()");
            GC.Collect();
        }
    }

    protected override void Dispose(bool disposing)
    {
        cts.Cancel();
        disposables?.Dispose();

        base.Dispose(disposing);
    }
}

購読のトラッキング

R3 には、購読のトラッキングが可能という特徴がある。

R3プロジェクトページの例を、トラッカー部分だけ抜き出すと以下。

SubscriptionTracker.EnableTracking = true; // default is false
SubscriptionTracker.EnableStackTrace = true;

// subscriptions

SubscriptionTracker.ForEachActiveTask(x =>
{
    Console.WriteLine(x);
});

これで生きている購読の情報(ID、FormattedType(購読の型的な情報)、時間、スタックトレース)がすべて列挙される。

ユーザーが認識しやすい購読だけでなく、内部の購読も含めスタックトレース込みですべてが出力されるため、実際の活用は少し考える必要がある。この点、R3.Unity ではエディタ用のUIが用意されている。

R3.Godot ではエディタUIはまだ用意されておらず、自前でさっと用意するための知見もなかったので、今回は試すために以下のデバッグ用キー入力を用いた。


【追記】エディタ用UIは追加されました (ver.0.1.7~)


public override void _Input(InputEvent @event)
{
    // for debug
    if (@event.IsActionReleased("ui_cancel"))
    {
        GD.Print("cancel and dispose.");
        cts.Cancel();
        disposables.Dispose();
    }
    else if (@event.IsActionReleased("ui_focus_next"))
    {
        var count = 0;
        SubscriptionTracker.ForEachActiveTask(state =>
        {
            ++count;
            if (SubscriptionTracker.EnableStackTrace) GD.Print(state);
        });
        GD.Print($"current subscription count: {count}");
    }
    else if (@event.IsActionReleased("ui_accept"))
    {
        GD.Print("GC.Collect()");
        GC.Collect();
    }
}

ラッキング件数を確認し、キャンセルとDisposeによって漏れがなく破棄ができるどうかをチェックする、というかたちで利用する。今回の例は、多くの購読の寿命を個別AddToでなく上流でのキャンセルに委ねたが、機能しているか確認できる。

件数確認 ⇨ 確認のため一度強制GC ⇨ 件数確認 ⇨ キャンセル + Dispose ⇨ 件数確認 ⇨ 強制GC ⇨ 件数確認 とした出力が以下。

current subscription count: 16
GC.Collect()
current subscription count: 16
cancel and dispose.
current subscription count: 8
GC.Collect()
current subscription count: 0

キャンセルとDisposeをし、GCを経たあとに購読の数がゼロになったことを確認できた。


強力な特徴なので是非使いたい。Godot にもエディタUIがほしい。

おわりに

R3 というライブラリは次世代 Rx ライブラリなのだけれど、次世代性をフィーチャーせずとも、単純に Rx on Godot の実現手段として素晴らしいと感じた。


Rx は現在では「使うべきところで使う」といったポジションに落ち着いていると思う。

ただ、使うと高い表現力があると感じるし、それがモダンに再設計されたライブラリで実現されるならなおさら嬉しい。

プレビューリリースの段階ということもあり、注目したい。


もしここまで読まれてまだ試していない Godot C#er の人がいましたら、是非触ってみてください。



  1. 実際には、Task との並びというよりかは Unity の destroyCancellationToken が念頭に置かれているよう

【C#】並列実行での数値カウント

マルチスレッドの排他制御に関するある記事で、数値を操作するだけであってもInterlockedクラスよりlockステートメントの方がよい、と書かれていたのが感覚と異なったので、自分でも測ってみる。


単純なインクリメントを、Parallel.Forにより並列実行したベンチマークを示す。(BenchmarkDotNet)

ベンチマークコード折りたたみ

[ShortRunJob]
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[ReturnValueValidator(failOnError: true)]
public class ConcurrentCounterBenchmark
{
    private abstract class ConcurrentCounterBase()
    {
        protected abstract void Increment();
        protected abstract long GetCount();

        // メソッド名:ベンチマーク用に挙動を変えたのに名前を変え忘れて、挙動と名前が合っていない命名になっています(整合性を欠いたりするとよくないのでそのままに)
        public long GetCountPerSecond()
        {
            var options = new ParallelOptions()
            {
                MaxDegreeOfParallelism = 8
            };

            const int N = 1 << 16;
            Parallel.For(0, N, options, _ =>
            {
                Increment();
            });

            return GetCount();
        }
    }

    private sealed class LockClass() : ConcurrentCounterBase
    {
        private long counter;
        private static readonly object obj = new();

        protected override void Increment()
        {
            lock (obj) ++counter;
        }

        protected override long GetCount() => counter;
    }

    private sealed class InterlockedClass : ConcurrentCounterBase
    {
        private long counter;

        protected override void Increment()
        {
            Interlocked.Increment(ref counter);
        }

        protected override long GetCount() => counter;
    }

    private sealed class SpinLockClass : ConcurrentCounterBase
    {
        private long counter;
        private static SpinLock spinLock = new();

        protected override void Increment()
        {
            bool lockTaken = false;
            try
            {
                spinLock.Enter(ref lockTaken);
                ++counter;
            }
            finally
            {
                if (lockTaken) spinLock.Exit(false);
            }
        }

        protected override long GetCount() => counter;
    }

    private sealed class SemaphoreClass : ConcurrentCounterBase
    {
        private long counter;
        private static readonly SemaphoreSlim semaphore = new(1, 1);

        protected override void Increment()
        {
            semaphore.Wait();
            try
            {
                ++counter;
            }
            finally
            {
                semaphore.Release();
            }
        }

        protected override long GetCount() => counter;
    }

    private sealed class MutexClass : ConcurrentCounterBase
    {
        private long counter;
        private static readonly Mutex mutex = new();

        protected override void Increment()
        {
            mutex.WaitOne();
            try
            {
                ++counter;
            }
            finally
            {
                mutex.ReleaseMutex();
            }
        }

        protected override long GetCount() => counter;
    }

    private sealed class ThreadLocalClass : ConcurrentCounterBase
    {
        private readonly ThreadLocal<long> counter = new(true);

        protected override void Increment()
        {
            counter.Value++;
        }

        protected override long GetCount() => counter.Values.Sum();
    }

    [Benchmark(Baseline = true)]
    public long Lock() => new LockClass().GetCountPerSecond();
    [Benchmark]
    public long InterlockedIncrement() => new InterlockedClass().GetCountPerSecond();
    [Benchmark]
    public long SpinLock() => new SpinLockClass().GetCountPerSecond();
    [Benchmark]
    public long Semaphore() => new SemaphoreClass().GetCountPerSecond();
    [Benchmark]
    public long Mutex() => new MutexClass().GetCountPerSecond();
    [Benchmark]
    public long ThreadLocal() => new ThreadLocalClass().GetCountPerSecond();
}

| Method               | Mean         | Ratio  | 
|--------------------- |-------------:|-------:|-
| Lock                 |   2,656.4 μs |   1.00 | 
| InterlockedIncrement |   1,771.7 μs |   0.67 | 
| SpinLock             |  28,084.6 μs |  10.58 | 
| Semaphore            |   7,555.0 μs |   2.84 | 
| Mutex                | 392,752.6 μs | 147.87 | 
| ThreadLocal          |     349.4 μs |   0.13 | 

このケースではInterlocked利用の方が良くなった。

もちろん冒頭で挙げた記事と条件は異なるが、Interlockedが単純にlockに後れるわけでないことは確かめられた。

ThreadLocal<T>

ところで、カウントした値を出せればいいみたいな話ならThreadLocal<T>を使うこともできる。

private sealed class ThreadLocalClass : ConcurrentCounterBase
{
    private readonly ThreadLocal<long> counter = new(true);

    protected override void Increment()
    {
        counter.Value++;
    }

    protected override long GetCount() => counter.Values.Sum();
}

そもそも排他制御を行わず、スレッドごとの値を合計すればそれはカウント、という解法。

ベンチマークとしては条件が揃っていないけれど、実際は適した方法を選びたい。

【C#】スタック上に class インスタンスを錬成する

以下のポストがきっかけ。

上記ポストはざっくりと、「メモリマップトファイルみたいなものをダイレクトにクラスにしたとして、GCにその領域をマネージドヒープとして登録できるよ」という内容。

ただGCの話よりも個人的に気になったのが、例として示したコードでスタック上にclassインスタンスをでっち上げていること。コンストラクタをバイパスして実体を与える行為は、まさに「錬成」。

ポストで紹介されているAPIは、指定した領域を "Frozen Segment" として登録するもの。スタックのスコープの中で扱う限りは、GC関係の処理はなくても問題ないはず。

というわけで、錬成を抜き出して試してみる。

クラスインスタンスの構造

錬成をするにあたって、まず構造のおさらい。

クラスインスタンスは、次のような構造になっている。

  1. オブジェクトヘッダー(IntPtrのサイズ)
  2. メソッドテーブルのポインタ(IntPtrのサイズ)
  3. フィールドの内容

参考:Managed object internals, Part 1. The layout - Developer Support

つまり最低限、64 bit であれば 8バイト + 8バイト + フィールドのサイズが必要なサイズ。ここにパディングが絡み、メモリレイアウトが決定する。

オブジェクトヘッダーについて。冒頭ポストでも何かを設定することはしていないが、この領域はlockに使われる Sync Block であり、個別で何かを設定する必要はないようだ。(詳細未調査)

錬成

次のようなクラスを考える。

private class ConfigClass
{
    public int Value1;
    public int Value2;

    public override string ToString() => $"({Value1}, {Value2})";
}

試しなのでパディングの入らないクラスとした。サイズは 8 + 8 + (4 + 4)。

ちなみにConfigClassというのは、使い捨てるclass、と考えて浮かんだのがコンフィグ的なものだったため。

それでは錬成。

unsafe
{
    byte* mem = stackalloc byte[
        8 // オブジェクトヘッダー
        + 8 // メソッドテーブル
        + 4 + 4]; // フィールド
    byte* objStart = mem + 8;

    // メソッドテーブルのポインタをセット
    *(IntPtr*)objStart = typeof(ConfigClass).TypeHandle.Value;

    // 正しくオブジェクトを錬成できたか確認するため、あらかじめフィールドに値をセットしておく
    mem[16] = 8;
    mem[20] = 16;

    var instance = *(ConfigClass*)&objStart;

    Console.WriteLine(instance);
    // (8, 16) ... フィールドの値は期待通りで、オーバーライドした ToString もちゃんと呼ばれている

    instance.Value2 = 512;
    Console.WriteLine(instance);
    // (8, 512) ... フィールドの書き込みもできている

    Console.WriteLine(GC.GetGeneration(instance));
    // 2147483647 ... GC管理から外れている
}

クラスインスタンスとして振る舞うものを、スタック上につくり出すことができた。

ベンチマーク

せっかくなので、ベンチマークも行う。(BenchmarkDotNet)

[ShortRunJob]
[MemoryDiagnoser]
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class StackClassBenchmark
{
    private class Class
    {
        public int Value1;
        public int Value2;
    }

    [Benchmark(Baseline = true)]
    public int Heap() => new Class().Value1;

    [Benchmark]
    public unsafe int Stack()
    {
        byte* mem = stackalloc byte[24];
        byte* objStart = mem + 8;
        *(IntPtr*)objStart = typeof(Class).TypeHandle.Value;
        var instance = *(Class*)&objStart;
        return instance.Value1;
    }
}
| Method | Mean     | Ratio | Gen0   | Allocated | Alloc Ratio |
|------- |---------:|------:|-------:|----------:|------------:|
| Heap   | 3.440 ns |  1.00 | 0.0076 |      24 B |        1.00 |
| Stack  | 1.353 ns |  0.39 |      - |         - |        0.00 |

newしていないので当たり前といえば当たり前だが、アロケーションなし。

typeof(T).TypeHandle.Valueが少し気になっていたが、見る限り負荷的な落とし穴もなさそう。

むすび

まず何より危険。また、普通にnewするのに対する記述の冗長さや、レイアウトを踏まえたサイズが要求されるなど、気軽さもない。実践での利用はさすがに、といった感じ。

ただ、クラスを錬成するというのは今までまったく考えたことがなかったので、刺激的だった。

【C#】長さを利用した CopyTo のJIT最適化を試す

最初にきっかけとなったポストを示す。"why the _hacker version is faster?" ということなので、ポスト初見の場合、一度考えてもいいかもしれない。


ちなみに、ポストのベンチマーク .NET 9 で実行されているとのこと。そして、.NET 8 はそのままでは同様の最適化を受けられない。.NET 8 環境下で試したい場合、コンパイラにもう少しわかりやすく長さを示す必要がある。

public class CopyToHackBenchmark
{
    private const int constant = 10;
    private readonly int[] srcArray = new int[constant];
    private readonly int[] dstArray = new int[constant];

    public int[] CopyToSimpleWay()
    {
        srcArray.AsSpan().CopyTo(dstArray);
        return dstArray;
    }

    public int[] CopyToImperfectlyHacked()
    {
        // .NET 8 では冒頭ポストのように単に Span にするだけではダメ
        var src = srcArray.AsSpan();
        var dst = dstArray.AsSpan();

        if (src.Length == constant)
            src.CopyTo(dst);
        else
            src.CopyTo(dst);

        return dstArray;
    }

    public int[] CopyToHacked()
    {
        // このようにすると同様の最適化を受けられる
        var src = srcArray.AsSpan(0, constant);
        var dst = dstArray.AsSpan(0, constant);

        if (src.Length == constant)
            src.CopyTo(dst);
        else
            src.CopyTo(dst);

        return dstArray;
    }
}

試せば分かるが、確かに速くなる。


さて、"why" の答えについて。ポストのリプライで正解を告げられているのは次のポスト。

コンパイラが?)既知の長さについてのコピーは、一連の簡潔なロード/ストアにアンローリングされるから、というもの。アセンブリのレイヤーのお話。

正解を告げたリプライの画像を見ると、vmovdqu命令の連続になっている。


さて、仕組みが分かったところで条件を変えて試してみた。今回は LINQPad の出力で確認した。

  • .NET8, LINQPad 8, x64
#LINQPad optimize+

using System;
using System.Runtime.CompilerServices;

public class CopyToHackBenchmark
{
    // 自分の環境で vmovdqu 命令の連続が消える境界
    // - const を外すとダメ
    // - object[]: ダメ(長さ問わず)
    // - int[]: 32まで
    // - double[]: 16まで
    // - byte[]: 128まで
    private const int constant = 128;
    
    private byte[] srcArray = default!;
    private byte[] dstArray = default!;

    public void Setup()
    {
        srcArray = new byte[constant];
        dstArray = new byte[constant];
    }

    //[MethodImpl(MethodImplOptions.NoInlining)] // ダメ
    private static void CopyToHacked<T>(ReadOnlySpan<T> src, Span<T> dst, int size)
    {
        if (src.Length == size)
            src.CopyTo(dst);
        else
            src.CopyTo(dst);
    }

    public byte[] CopyToSimpleWay()
    {
        srcArray.AsSpan().CopyTo(dstArray);
        return dstArray;
    }

    public byte[] CopyToHacked()
    {
        var src = srcArray.AsSpan(0, constant);
        var dst = dstArray.AsSpan(0, constant);
        CopyToHacked(src, dst, constant); // 少なくともこの場合はインライン化必須

        return dstArray;
    }
}

アセンブラに明るくないので、出力を見つつ手探り。試した限りでは以下だった。

  • object[](参照型の配列)はダメ
  • 長さを示すのに変数はダメ(const(またはリテラル)の必要)
  • 128バイトまで(例えばint[]なら長さ32まで)
  • メソッドの呼び出しを隔てるとダメ(例えば、上記のような場合はインライン化される必要)


仕組みを確認し条件を変えて試してみたが、正直なところ知識を噛み砕けてはいない。常用できる類ではないと言い訳してとりあえずそのまま引き出しの中へ。

書いてあったら最適化であると頭には浮かぶと思うので、その点は前進。


ちなみに、Dynamic PGOで賢く同様の最適化がかかるようPRを出しているよう。

.NET 9 では気にしなくても自動的にこの恩恵を受けれているかもしれない。

【C#】ASCII文字に最適化された処理を提供する Ascii クラス(.NET 8~)

.NET 8 から、ASCII文字に最適化された処理を提供するAsciiクラスが提供された。

文字列を扱う際、仕様上・あるいは事実上ASCII文字だけということは珍しくない。どういう感じか、触りつつベンチマークする。

なお、複数のAPIが、charbyte (UTF8) をそれぞれあるいは両方を受けるシグニチャを備えている。そうした場合、ベンチマークではcharを受けるものを扱う。



ベンチマーク

最初にベンチマークを示す。(BenchmarkDotNet)

ベンチマークコード折りたたみ

[ShortRunJob]
[MemoryDiagnoser]
[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class AsciiBenchmark
{
    private readonly string target = $"The quick brown fox jumps over the lazy dog{'.'} {1234567890}"; // avoid ReferenceEquals
    private readonly string targetToBeTrimmed = $" {ascii} ";
    private const string ascii = "The quick brown fox jumps over the lazy dog. 1234567890";
    private const string asciiIgnoreCase = "the quick brown FOX jumps over the lazy DOG. 1234567890";
    private readonly byte[] utf8Target = "The quick brown fox jumps over the lazy dog. 1234567890"u8.ToArray();

    [Benchmark(Baseline = true), BenchmarkCategory("Equals")]
    public bool Equals() => target.Equals(ascii);
    [Benchmark(Description = "(Ascii)"), BenchmarkCategory("Equals")]
    public bool EqualsAscii() => Ascii.Equals(target, ascii);

    [Benchmark(Baseline = true), BenchmarkCategory("EqualsIgnoreCase")]
    public bool EqualsIgnoreCase() => target.Equals(asciiIgnoreCase, StringComparison.OrdinalIgnoreCase);
    [Benchmark(Description = "(Ascii)"), BenchmarkCategory("EqualsIgnoreCase")]
    public bool EqualsIgnoreCaseAscii() => Ascii.EqualsIgnoreCase(target, asciiIgnoreCase);

    [Benchmark(Baseline = true), BenchmarkCategory("IsValid")]
    public bool IsValid()
    {
        foreach(var c in target.AsSpan())
        {
            if (char.IsAscii(c) is false) return false;
        }
        return true;
    }
    [Benchmark(Description = "(Ascii)"), BenchmarkCategory("IsValid")]
    public bool IsValidAscii() => Ascii.IsValid(target);

    [Benchmark(Baseline = true), BenchmarkCategory("ToLower")]
    public string ToLower() => target.ToLowerInvariant();
    [Benchmark(Description = "(Span)"), BenchmarkCategory("ToLower")]
    public string ToLowerSpan() => string.Create(target.Length, target,
        static (dest, target) => target.AsSpan().ToLowerInvariant(dest));
    [Benchmark(Description = "(Ascii)"), BenchmarkCategory("ToLower")]
    public string ToLowerAscii() => string.Create(target.Length, target,
        static (dest, target) => Ascii.ToLower(target, dest, out int _));

    [Benchmark(Baseline = true), BenchmarkCategory("Trim")]
    public string Trim() => targetToBeTrimmed.Trim();
    [Benchmark(Description = "(Span)"), BenchmarkCategory("Trim")]
    public string TrimSpan() => targetToBeTrimmed.AsSpan().Trim().ToString();
    [Benchmark(Description = "(Ascii)"), BenchmarkCategory("Trim")]
    public string TrimAscii() => targetToBeTrimmed.AsSpan()[Ascii.Trim(targetToBeTrimmed)].ToString();


    [Benchmark(Baseline = true), BenchmarkCategory("FromUtf16")]
    public byte[] FromUtf16() => Encoding.UTF8.GetBytes(target);
    [Benchmark(Description = "(Utf8)"), BenchmarkCategory("FromUtf16")]
    public byte[] FromUtf16Utf8()
    {
        var dest = new byte[target.Length];
        Utf8.FromUtf16(target, dest, out int _, out int _);
        return dest;
    }
    [Benchmark(Description = "(Ascii)"), BenchmarkCategory("FromUtf16")]
    public byte[] FromUtf16Ascii()
    {
        var dest = new byte[target.Length];
        Ascii.FromUtf16(target, dest, out int _);
        return dest;
    }

    [Benchmark(Baseline = true), BenchmarkCategory("ToUtf16")]
    public string ToUtf16() => Encoding.UTF8.GetString(utf8Target);
    [Benchmark(Description = "(Utf8)"), BenchmarkCategory("ToUtf16")]
    public string ToUtf16Utf8() => string.Create(utf8Target.Length, utf8Target,
        static (span, target) => Utf8.ToUtf16(target, span, out int _, out int _));
    [Benchmark(Description = "(Ascii)"), BenchmarkCategory("ToUtf16")]
    public string ToUtf16Ascii() => string.Create(utf8Target.Length, utf8Target,
        static (span, target) => Ascii.ToUtf16(target, span, out int _));
}

| Method           | Mean      | Ratio | Gen0   | Allocated | Alloc Ratio |
|----------------- |----------:|------:|-------:|----------:|------------:|
| Equals           |  9.567 ns |  1.00 |      - |         - |          NA |
| (Ascii)          | 12.373 ns |  1.29 |      - |         - |          NA |
|                  |           |       |        |           |             |
| EqualsIgnoreCase | 22.450 ns |  1.00 |      - |         - |          NA |
| (Ascii)          | 33.296 ns |  1.48 |      - |         - |          NA |
|                  |           |       |        |           |             |
| IsValid          | 35.909 ns |  1.00 |      - |         - |          NA |
| (Ascii)          |  4.183 ns |  0.12 |      - |         - |          NA |
|                  |           |       |        |           |             |
| ToLower          | 39.016 ns |  1.00 | 0.0433 |     136 B |        1.00 |
| (Span)           | 38.223 ns |  0.98 | 0.0433 |     136 B |        1.00 |
| (Ascii)          | 35.589 ns |  0.91 | 0.0433 |     136 B |        1.00 |
|                  |           |       |        |           |             |
| Trim             | 26.968 ns |  1.00 | 0.0433 |     136 B |        1.00 |
| (Span)           | 35.558 ns |  1.32 | 0.0433 |     136 B |        1.00 |
| (Ascii)          | 35.313 ns |  1.31 | 0.0433 |     136 B |        1.00 |
|                  |           |       |        |           |             |
| FromUtf16        | 43.008 ns |  1.00 | 0.0255 |      80 B |        1.00 |
| (Utf8)           | 26.745 ns |  0.62 | 0.0255 |      80 B |        1.00 |
| (Ascii)          | 22.682 ns |  0.53 | 0.0255 |      80 B |        1.00 |
|                  |           |       |        |           |             |
| ToUtf16          | 43.751 ns |  1.00 | 0.0433 |     136 B |        1.00 |
| (Utf8)           | 33.506 ns |  0.77 | 0.0433 |     136 B |        1.00 |
| (Ascii)          | 22.452 ns |  0.51 | 0.0433 |     136 B |        1.00 |

各項目の最初の行はstringの対応メソッドなどによる方法、(Ascii) となっているのがAsciiクラスによるもの。(Utf8) や (Span) は参考として別の方法。

箇条書き。

  • 基本的に効果がある
  • Equals / EqualsIgnoreCaseTrimは今回の例では逆に悪くなった
    • ただし、Trimについては文字列を得なくていい (Spanでよい) なら良くなる

個別

Equals / EqualsIgnoreCase

public bool Equals() => target.Equals(ascii);
public bool EqualsAscii() => Ascii.Equals(target, ascii);
public bool EqualsIgnoreCase() => target.Equals(asciiIgnoreCase, StringComparison.OrdinalIgnoreCase);
public bool EqualsIgnoreCaseAscii() => Ascii.EqualsIgnoreCase(target, asciiIgnoreCase);

Equalscase-insensitiveEqualsIgnoreCaseが用意されているが、今回はむしろ遅くなった1

環境や渡される内容によってはまた変わってくるとは思う。

また、ReadOnlySpan<char>ReadOnlySpan<byte> (UTF8) を直接比較できるシグニチャが存在する。

const string utf16 = "The quick brown fox jumps over the lazy dog. 1234567890";
ReadOnlySpan<byte> utf8 = "The quick brown fox jumps over the lazy dog. 1234567890"u8;

Console.WriteLine(Ascii.Equals(utf16, utf8));
// True

IsValid

public bool IsValid()
{
    foreach(var c in target.AsSpan())
    {
        if (char.IsAscii(c) is false) return false;
    }
    return true;
}

public bool IsValidAscii() => Ascii.IsValid(target);

引数がASCII文字で構成されているかを得るメソッド。

最適化の恩恵が大きく、記載も簡潔。

ToLower / ToUpper

public string ToLower() => target.ToLowerInvariant();
public string ToLowerSpan() => string.Create(target.Length, target,
    static (dest, target) => target.AsSpan().ToLowerInvariant(dest));
public string ToLowerAscii() => string.Create(target.Length, target,
    static (dest, target) => Ascii.ToLower(target, dest, out int _));

効果はある。加えて、ベンチマークは条件を揃えるために結果を文字列としたが、結果がSpanでよいなら明確に良くなる。

また、destination を用意せずインプレースに行うToLowerInPlaceもある。

他に、ReadOnlySpan<char>ReadOnlySpan<byte>の両方を受けるシグニチャもある。(charを受けてbyteに書き出す / byteを受けてcharに書き出す)

並びのToUpper、そしてToUpperInPlaceもある。ベンチマーク省略。

Trim

public string Trim() => targetToBeTrimmed.Trim();
public string TrimSpan() => targetToBeTrimmed.AsSpan().Trim().ToString();
public string TrimAscii() => targetToBeTrimmed.AsSpan()[Ascii.Trim(targetToBeTrimmed)].ToString();

Ascii.TrimRangeを返す設計になっている。

結果の長さを不定と考えstring.CreateでなくToStringで文字列とした。それもあってか、今回はむしろ遅くなってしまった。もちろん、結果がSpanでよい場合は良くなる。

TrimStart, TrimEndもある。ベンチマーク省略。

FromUtf16

public byte[] FromUtf16() => Encoding.UTF8.GetBytes(target);

public byte[] FromUtf16Utf8()
{
    var dest = new byte[target.Length];
    Utf8.FromUtf16(target, dest, out int _, out int _);
    return dest;
}

public byte[] FromUtf16Ascii()
{
    var dest = new byte[target.Length];
    Ascii.FromUtf16(target, dest, out int _);
    return dest;
}

char (UTF16) to byte (UTF8)。

明確に速い。

ToUtf16

public string ToUtf16() => Encoding.UTF8.GetString(utf8Target);

public string ToUtf16Utf8() => string.Create(utf8Target.Length, utf8Target,
    static (span, target) => Utf8.ToUtf16(target, span, out int _, out int _));

public string ToUtf16Ascii() => string.Create(utf8Target.Length, utf8Target,
    static (span, target) => Ascii.ToUtf16(target, span, out int _));

byte (UTF8) to char (UTF16)。

明確に速い。



  1. 冒頭 .NET Blog 記事ではEqualsIgnoreCaseベンチマークを行っていたが、そこでは case-insensitive 処理をベタ書きしたものを比較対象としていた。StringComparison.OrdinalIgnoreCaseは比較対象として不適?(未調査)