R3のコードから見るC#パフォーマンス最適化技法実例とTimeProviderについて
- 2024-05-01
4/27に大阪で開催されたC#パフォーマンス勉強会で「R3のコードから見る実践LINQ実装最適化・コンカレントプログラミング実例」という題でセッションしてきました!
タイトル的にあまりLINQでもコンカレントでもなかったかな、とは思いますが、R3を題材に、具体的なコードをもとにした最適化技法の紹介という点では面白みはあったのではないかと思います。
Rxの定義
R3は、やや挑発的な内容を掲げていることもあり、R3は「Rxではない」みたいなことを言われることもあります。なるほど!では、そもそも何をもってRxと呼ぶのか、呼べるのか。私は「Push型でLINQ風のオペレーターが適用できればRx」というぐらいの温度感で考えています。もちろん、R3はそれを満たしています。
mutable struct
の扱いと同じく、あまり教条主義的にならず、時代に合わせて、柔軟により良いシステムを考えていきましょう。コンピュータープログラミングにおいて、伝統や歴史を守ることは別に大して重要なことではないはずです。
TimeProvider DeepDive
TimeProviderについて、セッションでも話しましたが、大事なことなのでもう少し詳しくいきましょう。TimeProviderにまず期待するところとしては、ほとんどがSystemClock.Now
、つまりオレオレDateTime.Now
生成器の代わりを求めているでしょう。それを期待しているとTimeProviderの定義は無駄に複雑に見えます。しかしTimeProvider
を分解してみると、これは4つの時間を司るクラスの抽象層になっています。
public abstract class TimeProvider
{
// TimeZoneInfo
public virtual TimeZoneInfo LocalTimeZone => TimeZoneInfo.Local;
// DateTimeOffset
public virtual DateTimeOffset GetUtcNow() => DateTimeOffset.UtcNow;
public DateTimeOffset GetLocalNow() =>
// Stopwatch
public virtual long TimestampFrequency => Stopwatch.Frequency;
public virtual long GetTimestamp() => Stopwatch.GetTimestamp();
public TimeSpan GetElapsedTime(long startingTimestamp, long endingTimestamp) =>
public TimeSpan GetElapsedTime(long startingTimestamp) => GetElapsedTime(startingTimestamp, GetTimestamp());
// System.Threading.Timer
public virtual ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) =>
}
public interface ITimer : IDisposable, IAsyncDisposable
{
bool Change(TimeSpan dueTime, TimeSpan period);
}
4つの時間を司るクラス、すなわちTimeZoneInfo、DateTimeOffset、Stopwatch、System.Threading.Timer。
この造りになっているからこそ、あらゆる時間にまつわる挙動を任意に変更することができるのです。
挙動を任意に変更するというとユニットテストでの時間のモックにばかり意識が向きますが(実際、FakeTimeProviderはとても有益です)、別にユニットテストに限らず、優れた時間の抽象化層として使うことができます。ということを実装とともに証明したのがR3で、特にR3ではCreateTimer
をかなり弄っていて、WPFではDispatcherTimerを使うことで自動的にUIスレッドにディスパッチしたり、UnityではPlayerLoopベースのタイマーとしてScaledとUnsacledでTimescaleの影響を受けるタイマー・受けないタイマーなどといった実行時のカスタマイズ性を実現しました。
セッションではStopwatchについてフォーカスしました。二点の時刻の経過時間を求めるのにDateTimeの引き算、つまり
DateTime now = DateTime.UtcNow;
/* do something... */
TimeSpan elapesed = DateTime.UtcNow - now;
といったコードを書くのはよくあることですが、これはバッドプラクティスです。DateTimeの取得はタダではありません。では、なるほどStopwatchですね?ということで
Stopwatch sw = Stopwatch.StartNew();
/* do something... */
TimeSpan elapsed = sw.Elapsed;
これは、Stopwatchがclassなのでアロケーションがあります。うまく使いまわしてあげる必要があります。 使いまわしができないシチュエーションのために、アロケーションを避けるためにstructのStopwatch、ValueStopwatchといったカスタム型を作ることもありますが、待ってください、そもそもStopwatchが不要です。
二点の経過時間を求めるなら、時計による時刻も不要で、その地点の何らかのタイムスタンプが取れればそれで十分なのです。
// .NET 7以降での手法(GetElapsedTimeが追加された)
long timestamp = Stopwatch.GetTimestamp();
/* do something... */
TimeSpan elapsed = Stopwatch.GetElapsedTime(timestamp);
このlongは、通常は高解像度タイムスタンプ、WindowsではQueryPerformanceCounterが使われています。TimeSpanでよく使うTicksではないことに注意してください。
ベンチマークを取ってみましょう。
using BenchmarkDotNet.Attributes;
using System.Diagnostics;
BenchmarkDotNet.Running.BenchmarkRunner.Run<TimestampBenchmark>();
public class TimestampBenchmark
{
[Benchmark]
public long Stopwatch_GetTimestamp()
{
return Stopwatch.GetTimestamp();
}
[Benchmark]
public DateTime DateTime_UtcNow()
{
return DateTime.UtcNow;
}
[Benchmark]
public DateTime DateTime_Now()
{
return DateTime.Now;
}
}
NowではUtcNowに加えてTimeZoneからのオフセット算出が入るために更にもう一段遅くなります。
ちなみに、2点間の時間の算出にDateTimeではなくTimestampを使うもう一つの利点としては、システム時間の変更の影響を受けないという点があります。dotnet/reactiveではISchedulerがDateTimeOffsetベースで作られていたため、ISchedulerインターフェイスそのものがこの問題の影響を避けられないために、内部的にゴチャゴチャしたハックが繰り返され、パフォーマンスの大幅な劣化にも繋がっていました。
なお、マイクロベンチマークを取るときは必ずBenchmarkDotNetを使ってください。(micro)benchmark is hard、です。Stopwatchで測られても、あらゆる要因から誤差が出まくるし、そもそも指標もよくわからないしで、数字を見ても何もわかりません。私はそういう数字の記事とかを見た場合、役に立たないと判断して無視します。
まとめ
セッション資料に盛り込めた最適化技法の紹介は極一部ではありますが、R3がどれだけ気合い入れて作られているかが伝わりましたでしょうか?10年の時を経て、私自身の成長とC#の成長が合わさり、UniRxからクオリティが桁違いです。
これからも足を止めずにやっていきますし、みなさんも是非モダンC#やっていきましょう……!(Unityも十分モダンC#の仲間入りで良いです!)
そういえばブログに貼り付けるのを忘れてたのですが3月末にはこんなセッションもしていました。
ええ、ええ。Unityもモダンですよ!大丈夫!