Livesplit ver1.6 以降では「Windows の機能にある 1 つの高精度タイマーをベースにして、ネットから取得した時刻を用いて補正している」方式になっています。
Livesplit のタイムの精度に関する既存の資料にある説明で「Windows の機能にある 3 種類のタイマーを平均している」といった記述がありますが、これは vre1.5.5 までの古い方式で現在は使われていません。
もくじ
現行の方式の概要
Livesplit が表示しているタイム
(表示タイム) = (その瞬間におけるタイムスタンプ) - (タイマースタートの瞬間のタイムスタンプ)
実は Livesplit 自身はタイムをカウントしてはいません。タイムスタンプの差を表示しているだけなのです。
タイムスタンプ
Livesplit 起動時からの経過時間を Stopwatch クラスがカウントしていて、NTP サーバーから取得した時刻を用いて補正した起動時間をタイムスタンプとしています。
Stopwatch クラスは現在の一般的な環境では内部で QueryPerformanceCounter() 関数を利用していて、環境によって異なるらしいですが、QueryPerformanceCounter() の精度はミリ秒くらいとのことです。
そのため、Livesplit が表示しているタイムの精度はどんなに良くても QueryPerformanceCounter() の精度までと考えられます。
用語の確認
- 精度
ここでの精度は一定時間経過した瞬間における、正確な時間との誤差の大きさを意味します。
精度は「一定時間とする時間の長さ」と「誤差の大きさ」の 2 つの情報がそろって初めて評価できます。
- 分解能
ここでの分解能は値変化の最小値くらいの意味です。分野によって意味が異なる言葉で、あくまでこの記事においてはこの意味です。
例えば、秒針が 0.25 秒刻みで動くアナログストップウォッチなら分解能 0.25 秒、0.01 秒まで計測できるデジタルストップウォッチなら分解能 0.01 秒です。
タイマーのズレは不可避
Livesplit は Windows が管理している時計を利用しています。
Windows は PC というハードウェア上で動作していて、Windows が管理している時計もまた PC というハードウェア上で動作しています。
まずはハードウェアの影響が大きい部分の話です。
参考
システムクロック
システムクロックは PC の電源が入っている間でだけ動作する時計で、CPU の周波数を利用して一定間隔で時間経過をカウントしています。
この仕組みで分かるのは経過時間のみで、PC 起動時にハードウェアクロック(後述)から時刻を取得して初期時刻とすることで現在の時刻を知ることができるようになっています。
PC 起動中は、基本的にこのシステムクロックが時刻や時間経過の基準となるので、このシステムクロックの精度が Livesplit の精度にも影響します。
ハードウェアクロック
ハードウェアクロックは PC の電源が入っていても入っていなくても動作し続けている、クオーツ時計と同じ原理[1] で時間をカウントする時計です。
PC のマザーボードにリアルタイムクロック(RTC)という電子部品が搭載されていて、同じくマザーボードに取り付けられている電池によって駆動することで PC の電源が入っていない間も時間を刻み続けます。
RTC という電子部品にも様々な会社で様々な型番のものが作られているようで、一般的な PC で使われている RTC の精度はどうなのかよく分からんです。
高精度なものでひと月で 10-20 s くらいの誤差[2][3] ぽいです。
ハードウェアクロックはシステムクロックに初期時刻を与えるためだけでしか使われていないらしいです。
このハードウェアクロックの精度は、少なくとも Livesplit の標準機能のみでタイム計測する場合において Livesplit が表示するタイムの精度には影響しないハズ。
しいて言うなら、Livesplit の履歴データに記録される試行開始・終了時刻の正確さに影響するくらいかなと思います。
参考
PC のタイムカウントはズレる
システムクロックは CPU という電子部品の周波数を利用して経過時間をカウントしており、ハードウェアクロックはリアルタイムクロックという電子部品に含まれているクオーツ(水晶)の周波数を利用して時刻をカウントしています。
これらのは電子部品の製品として許容されている精度の限界があるうえに、経年変化や自身の温度といった物理的な性質と、周囲の温度といった環境の変動などによってタイムカウントの精度が変動するため、必ず時刻がズレます。
Livesplit の精度に関係しているシステムクロックに関しては、おそらくハードウェアの性能や環境などによって結果が大きく変わり得るからだと思いますが、どれくらいの精度があるかの具体的な数値の記述を見付けることができませんでした。
ただ少なくとも、ハードウェアクロックよりもシステムクロックのほうが精度が良いらしいです。
Livesplit が利用している時計
ここからはプログラムの話です。
Livesplit は Windows 上で動作するタイマーアプリケーションであり、Windows が管理している時計を利用しています。この Windows が管理している時計の精度にも限界があります。
ハードウェアの段階でタイムカウントの誤差があるので、そのハードウェア上で動くプログラムのタイムカウントには当然ながらそれ以上の誤差があるハズです。
この記事を書いている時点では Livesplit は .NET Framework というフレームワーク、C# というプログラミング言語で作られているので、これ前提の説明をします。
Livesplit の表示タイムの仕組みには 2016/08/30 リリースの ver1.5.5 以前の旧方式であるトリプルタイマーと、ver1.6 以降続いている現行方式のタイムスタンプとがあります。
トリプルタイマー方式は Windows から複数の方法で時刻・経過時間を取得して、その中で誤差の小さいものを選ぶ仕組みでした。
タイムスタンプ方式では Windows の高精度タイマー 1 つのみを採用して、ネットワークから取得した時刻を使って定期的に補正する仕組みになっています。
トリプルタイマー(旧方式)
Livesplit ver1.5.5 以前の方式では次の 3 つの日時・経過時間を利用しています。
- DateTime.UtcNow
- Environment.TickCount
- Stopwatch.Elapsed
DateTime.UtcNow は NTP サーバーとの時刻同期の影響を受けます。
Livesplite のタイマースタートの瞬間から計測中のその瞬間までの、3 つのタイマーそれぞれの経過時間を計算して、3 つの内の誤差が少なかった 2 つのタイマーの経過時間の平均を Livesplit の表示タイムとしています。
ソースコード
2014/02/18 に書かれた、Livesplit 作者の CryZe92 さんによる(主に WSplit の fallback timer の)解説
DateTime.UtcNow
DateTime は日時を扱う構造体です。
DateTime.UtcNow プロパティはその瞬間の協定世界時基準の日時を取得します。
おそらくはシステムタイマから日時を取得していると思います。タスクバーに表示される時計も多分同様。
DateTime 構造体が扱っている時間の最小単位は DateTime.Tick の 100 ナノ秒単位ですが、これはあくまで変数のサイズがそうなっているだけで、それだけの精度がある訳ではありません。
DateTime.UtcNow は .NET と .NET Framework とで精度が異なっているらしく、.NET Framework では数十ミリ秒くらいの精度[4] とのこと。(単位の詳細不明)
タイムカウントの分解能が 64 Hz の分解能 ≒ 15.625 ms という記述があるので、もしかするとこの十数ミリ秒の精度は 15.625 ms の分解能のことを言っているかもしれません。
ちなみに、その瞬間の日時を取得するメソッドは協定世界時基準の UtcNow とタイムゾーン換算された Now とがありますが、UtcNow のほうが Now よりも処理が早い[5] らしいです。
ネットワーク上にある NTP サーバーの時刻と PC の時計とを同期することで PC の時計を補正するということが一般的に行われています。
DateTime.UtcNow はこの時刻の同期の影響を受けます。詳細は後述。
参考
Environment.TickCount
Environment.TickCount プロパティはシステムが起動してからの経過タイムカウントを取得します。
.NET 11 にて仕様変更されたらしく、ここでは .NET Framework を前提としているので仕様変更前を見ます。
内部的には Win32 API の GetTickCount64() 関数を呼んでいて、64 Hz ≒ 15.625 ms の分解能があります。[6]
少なくとも、.NET Framework においてここで挙げている時間取得メソッドの中では Environment.TickCount が最も高速[7] です。
.NET Framework において、64 bit int 型を返す Environment.TickCount64 の値を切り捨てて 32 bit の int 型にしているのが Environment.TickCount で、約 49 日ごとにオーバーフローする可能性があるとのこと。(普通の int 型だと約 24.9 日、uint 型なら約 49 日)[6][8]
参考
Stopwatch.Elapsed
Stopwatch は経過時間を計測してくれるクラスです。
Stopwatch.Elapsed は Stopwatch を開始してからの経過時間を取得するメソッドで、高分解能パフォーマンスカウンタというものがサポートされている環境では Win32 API の QueryPerformanceCounter() 関数を呼び、サポートされていない場合は DateTime.UtcNow.Ticks を取得します。[9]
現在の PC では QueryPerformanceCounter() が使用されるケースばかり[10] らしいので、この記事内では QueryPerformanceCounter() と見なして説明します。
QueryPerformanceCounter() は理屈上は高精度だけど、実際には環境によって精度にバラつきが大きいらしいです。
分解能は Frequency フィールドの値で、環境によって異なるとのこと。ネットで見つけた範囲では数百ナノ秒~。[11]
実際の精度はミリ秒前後くらいぽいです。[12][13]
参考
- [9] Stopwatchクラス
- [10] 高解像度タイムスタンプの取得
- [11] 高精度タイマーQueryPerformanceCounterの分解能
- [12] Windowsで高精度タイマを使う
- [13] 計時の精度・分解能
NTP サーバーとの同期
NTP サーバーはネット上で正しい時刻を配信しているサーバーで、PC の時刻合わせにも利用されています。
一般的に、NTP サーバーが提供する時刻は市販の PC などよりもはるかに高精度な時計が基になっていますが、ネットワーク経由で時刻を取得する際に通信状況や通信速度などの影響を受けるため遅延 0 で時刻を受け取ることはたぶんムリ。(通信速度を考慮した時刻を推測する仕組みが働いている[14] らしいです)
実用上は時刻同期は数ミリ秒~数十ミリ秒くらいの精度になる[15] らしいです。(ここでの「精度」は時刻同期の瞬間における時刻のズレに関する値で、この記事の他の「精度」とは意味が異なります)
また、同期することによって PC の時刻が急に飛んでしまうと、本来は連続しているハズの時間に空白ができたり重複が生じたりしてしまいます。
このような状況を防ぐために Slew モードという同期方法が使われていて、タイムカウントの速度を少しだけ変えてゆっくりとズレを直していきます。
設定にもよりますが、1 秒のズレを 2,000 秒かけて 0 に近付けていく場合もある[15] そうです。
ただし、環境ごとに設定されているズレ量の基準値を超えると、瞬間的に時刻を同期する仕組みになっている[16][17] そうです。
トリプルタイマー方式では NTP サーバーから取得した時刻は直接は利用していませんが、PC 時刻の同期によって DateTime.UtcNow が影響を受けます。
PC の時刻のズレが小さかった場合はタイムカウントの早さが変動し、ズレが大きかった場合はタイムカウントが飛ぶか重複が発生してしまいます。
参考
- [14] 「1秒を笑う者は1秒に泣く」NTPと時刻同期、基本の"キ"
- [15] PC内部の時計の正確さ
- [16] 時刻同期モードの種類
- [17] NTPによるWindowsの時刻同期の方法
実際の計算
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
Livesplit 起動時に Stopwatch をスタート
Livesplit のタイマースタートの瞬間に 3 つのタイマーの値を記憶
Environment.TickCount
Stopwatch.Elapsed
DateTime.UtcNow
タイマースタートから計測中のその瞬間までの 3 つのタイマーそれぞれの経過時間を計算
Environment.TickCount経過
Stopwatch.Elapsed経過
DateTime.UtcNow経過
ミリ秒換算して、次の 3 種類の経過時間の差の絶対値を計算
差1 = |Environment.TickCount経過 - Stopwatch.Elapsed経過|
差2 = |Stopwatch.Elapsed経過 - DateTime.UtcNow経過|
差3 = |DateTime.UtcNow経過 - Environment.TickCount経過|
3 種類の絶対値の大小を比較して、
差1 が最も小さい場合
表示タイム = (Environment.TickCount経過 + Stopwatch.Elapsed経過) / 2
差2 が最も小さい場合
表示タイム = (Stopwatch.Elapsed経過 + DateTime.UtcNow経過) / 2
差3 が最も小さい場合
表示タイム = (DateTime.UtcNow経過 + Environment.TickCount経過) / 2
タイムスタンプ(現行方式)
Livesplit ver1.6 以降の方式では、次の 2 つの時刻・経過時間を利用しています。
- NTP サーバーから取得した時刻
- Stopwatch.Elapsed
この 2 つの説明については前の項目で説明しているので、ここでは省略。
Livesplite のタイマースタートの瞬間のタイムスタンプと計測中のその瞬間でのタイムスタンプとの差を Livesplit の表示タイムとしています。
このタイムスタンプは Stopwatch.Elapsed を NTP サーバーから取得した時刻を用いて補正した値を持っています。
あくまで Stopwatch.Elapsed がベースであって、NTP サーバーから取得した時刻は補正値の計算のためだけに使われています。
ソースコード
実際の計算
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
Livesplit 起動時に Stopwatch をスタート
同時に、補正値の定期計算を開始
Livesplit のタイマースタートの瞬間
タイムスタンプ(補正値で補正した Stopwatch.Elapsed)を記憶
計測中のその瞬間
タイムスタンプ(補正値で補正した Stopwatch.Elapsed)を取得
表示タイム = タイムスタンプの差
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
Livesplit 起動中は定期的に補正値を計算
次のタイム差を 5 秒おきに 10 回取得を試みる
(タイム差) = (NTP サーバーから取得した DateTime.Ticks) - (Stopwatch.Elapsed.Ticks)
取得できたタイム差の数が
5 個未満だった場合、
0.5 h 待機して次の定期計算を開始
5 個以上だった場合、
取得できたタイム差の平均値を計算
その瞬間の Stopwatch.Elapsed を最新の経過時間として記憶
先ほど計算した平均値と最新の経過時間から調整経過時間を計算
調整経過時間を DateTime に変換して最新の補正日時として記憶
経過時間の初期値を
まだ記憶していない
最新の経過時間と最新の補正日時をそれぞれ初期値として記憶
1 h 待機して次の定期計算を開始
記憶済み
最新の経過時間と最新の補正日時とでそれぞれ次の値を計算
(現時点での経過時間) = (最新の値) - (初期値)
計算した 2 つの現時点での経過時間から仮補正値を計算
(仮補正値) = (Stopwatch.Elapsed経過) / (補正日時経過)
0.99 < (仮補正値) < 1.01 だった場合、
補正値を計算
(補正の強さ) = (定数) ^ (補正時間経過の大きさ)
(補正値) = (仮補正値) ^ (補正の強さ)
0.5 h 待機して次の定期計算を開始
その他
2014 年の資料
いろんなSWソフト調査
ping値さんによる各種タイマー精度検証のまとめ。この記事を書いたきっかけのひとつです。感謝。
この検証資料にある 3 つのタイム取得関数はどれも Win32 API の関数。
timeGetTime() 関数と GetTickCount() 関数はどちらもシステムクロックから PC 起動してからの経過時間を取得しているぽいけど、マルチメディア向けの timeGetTime() 関数のほうが精度が良いらしいです。[18][19]
| 種類 | 当時資料 Win32 API 関数 | Livesplit で使われているもの .NET Framework |
|---|---|---|
| PC 起動からの経過 | GetTickCount() | Environment.TickCount |
| 高精度タイマー | QueryPerformanceCounter() | Stopwatch.Elapsed |
検証資料中にリンクがある Livesplit 作者さんによる当時の資料(What is a fallback timer? 、ping値さんによる日本語訳)にある Windows Time というのはたぶん DateTime.UtcNow のこと。
この検証資料では NTP サーバーとの同期の直後にシステムクロックが正しい時刻に書き換わっているように見えます。
当時はそのような仕組みが一般的ではなかったのか、この時の検証では Slew モードが働いていなかったもとの考えられます。
参考
さいごに
何年か前に ping値さんの検証資料を拝見していて、それからしばらくして「Livesplit の仕様はこの時とは変わっている」といった発言をどこかで見かけてから、どう変わったのか実はずっと気になって頭の片隅にありました。
つい先日、Livesplit のタイムスタンプを利用した拡張機能を作った時に ping値さんの検証資料のことをふと思い出して、この記事を書こうと決めました。
専門知識がなさ過ぎて調べ集めた情報の正誤を判断しきれず、もしかすると間違った内容を含んでいるかもしれません。
お気付きの点などありましたら、コメントなどでご指摘いただけると助かります。
0 件のコメント:
コメントを投稿