物事の始まり
この疑問が出たのは、ただString(あるいはC#などの言語ではstringとも言えます。以下string)とStringBuilder、StringBufferの性能の違いに対する初心者向けの話でした。
stringは実際には読み出し専用であることは、素人以外は大体知っている事実です。immutableと言いますが、変わらないということです。よって、『+』や『concat』をすると、stringはそのたびに、新しいインスタンスをリターンするようになります。
C++では、strcat()で文字列を修正するとき、あらかじめ大きいスペースを割り当てると、速度が飛躍的に上がるといいますが、C#とJavaでは文字列の修正は難しいので、ほかの方法が必要になり、それはMSDNにも出ています。JavaとC#が似ていることは、コピーしたらすぐわかるところですが。
効果はあるのか
それで、その違いはどうやって判りますか。Javaの先生は文字列を作り出し続けるコードを出して、「まぁ、全然変わらないじゃん」と言いましたが、実際に大きい差があることはある程度、コードからも把握できます。
/* string performance in C# */
string me = "";
Console.WriteLine("String START");
var cnt = Environment.TickCount;
for (int i = 0; i < 100000; i++)
{
me += "a";
}
Console.WriteLine("Time: "+(Environment.TickCount - cnt).ToString());
Console.WriteLine("END");
//Console.WriteLine(me);
StringBuilder sb = new StringBuilder();
Console.WriteLine("StringBuilder START");
cnt = Environment.TickCount;
for (int i = 0; i < 100000; i++)
{
sb.Append("a");
}
Console.WriteLine("Time: " + (Environment.TickCount - cnt).ToString());
Console.WriteLine("END");
C# コード
/* string performance */
String me = "";
System.out.println("String START");
long cnt = System.currentTimeMillis();
for (int i = 0; i < 100000; i++)
{
me += "a";
}
System.out.println("Time: "+(System.currentTimeMillis() - cnt));
System.out.println("END");
StringBuffer sb = new StringBuffer();
System.out.println("StringBuffer START");
System.out.printf("capacity: %d, length: %d\n", sb.capacity(), sb.length());
cnt = System.currentTimeMillis();
for (int i = 0; i < 100000; i++)
{
sb.append("a");
}
System.out.println("Time: " + (System.currentTimeMillis() - cnt));
System.out.printf("capacity: %d, length: %d\n", sb.capacity(), sb.length());
System.out.println("END");
Javaコードは次のように結果が出ました。
String START
Time: 7467
END
StringBuffer START
capacity: 16, length: 0
Time: 9
capacity: 294910, length: 200000
END
中途半端なもんではないですね。10万個のStringにつけながら、7秒以上かかりました。一方、 C#は長ければ、5秒以内程度で、コンパイラー最適化のすごさを分かります。StringBufferとStringBuilderも10msを下回り、0msが出る時もありました。刹那と言っても、過言ではありません。
さすがに、Heapをどれほど、使い切ったり、新しいメモリーの割り当てを避けて、有効に扱うのが大切なのか、目に見える実験でした。
浮かび始める疑問
面白いところは、JavaのStringBufferとは違って、C#のStringBuilderの所要時間が0msと出たところです。コード一つでもなく、10万なのに果たしてできるのか?誤差はないのか?気になりました。
早速、Environment.TickCountを疑いました。実はTickCountでコードのパフォーマンスを比較する時代は、マルチプロセッサの到来で、もう過ぎたという意見もあります。コードの後先はあっても、働くタイミングは異なるかも知らないという、スレッドの危険が日常化した時代ですからね。
C#のゆーざーはどういう方法を使っているのでしょうか。
- Environment.TickCount
- GetTickCount / GetTickCount64
- DateTime.Now(UtcNow).Ticks
- Stopwatch
- static Stopwatch.GetTimestamp
簡単で、使いやすいのはEnvironment static classのメンバーであるTickCountです。パソコンのアップタイムをミリセカンドで見せるGetTickCount()とはほぼ同じ機能をしていて、余計に符号が付く整数(int)なので、24.9日以上つけっぱなしのパソコンでは問題が発生します。Int32.MaxValueを超えるとInt32.MinValueに戻ってから始めるらしいです。当然、差を求めると、符号が逆になってしまいます。MSDNではInt32.MaxValueとの&(AND)演算で一時解決したようです。(Math.Abs()より早く符号がなくなりますね。)
VistaからできたWin32 APIであるGetTickCount64は符号なしlongタイプでリターンします。システムが始まった時間から積み重ねていくミリセカンドの数字を表すには、十分な長さだと言えるでしょう。しかし、本当に遊ばないで、たとえば、節電、ハイバネーションなどなく、起動時間を求めるには、QueryUnbiasedInterruptTimeが必要となります。
西暦1年1月1日午前零時から、100ナノセカンドで経った時間を表します。うるう秒などはなく、本当の時間とは差が少なくないらしいです。
Stopwatchはnewキーワードなく、インスタンス化できるStaticメソッドであるStartNew()を使って、時間を図り始め、Stop()を呼び出した後、ElapsedMillisecondsなどのプロパティで経過時間がわかるようになります。
- IsHighResolutionプロパティは現在パソコンのハードウェアが別のハイレゾ性能カウンター機能があるかどうかを確認できる読み出し専用のブール属性です。今どきのパソコンはほとんどTrueがリターンされるようです。
- Frequencyプロパティはタイマーのtick周期をリターンします。
long frequency = Stopwatch.Frequency;
Console.WriteLine(" Timer frequency in ticks per second = {0}",
frequency);
long nanosecPerTick = (1000L*1000L*1000L) / frequency;
Console.WriteLine(" Timer is accurate within {0} nanoseconds",
nanosecPerTick);
Stopwatchのインスタンス化後の時間も無駄だとみる一部のユーザーはGetTimestamp static メソッドの性能に注目をしました。
- ハイレゾ性能カウンターがあるときには、現在値をリターンします。
- 一般のシステム時計が使われている場合、DateTime.Ticksと同じ結果が出ます。
ベンチマーク
Repeating measurement 3 times in loop of 10,000,000:
Measured: GetTickCount64() [ms]: 228
Measured: Environment.TickCount [ms]: 50
Measured: DateTime.UtcNow.Ticks [ms]: 83
Measured: Stopwatch: .ElapsedMilliseconds [ms]: 856
Measured: static Stopwatch.GetTimestamp [ms]: 476
Measured: Stopwatch+conversion to DateTime [ms]: 811
Measured: GetTickCount64() [ms]: 219
Measured: Environment.TickCount [ms]: 50
Measured: DateTime.UtcNow.Ticks [ms]: 84
Measured: Stopwatch: .ElapsedMilliseconds [ms]: 847
Measured: static Stopwatch.GetTimestamp [ms]: 465
Measured: Stopwatch+conversion to DateTime [ms]: 799
Measured: GetTickCount64() [ms]: 235
Measured: Environment.TickCount [ms]: 51
Measured: DateTime.UtcNow.Ticks [ms]: 84
Measured: Stopwatch: .ElapsedMilliseconds [ms]: 851
Measured: static Stopwatch.GetTimestamp [ms]: 470
Measured: Stopwatch+conversion to DateTime [ms]: 791
Compare that with DateTime.Now.Ticks [ms]: 1150
General Stopwatch information:
- Using high-resolution performance counter for Stopwatch class.
- Stopwatch accuracy- ticks per microsecond (1000 ms): 2.3
(Max. tick resolution normally is 100 nanoseconds, this is 10 ticks/microsecond.)
- Approximated capacity (maxtime) of TickCount [dd:hh:mm:ss] 25:20:31:23
Done.
シングルスレッドのみ使うように設定したうえ、スレッドの優先順位を高めて、Stopwatchを利用し、1000万会呼び出しにそれぞれかかった時間です。
TickCountが共通的に早いのかと思うと、StopwatchはElapsedMillisecondsの表示のための手順もあるので、 表示をせずに、ただStart()とStop()を繰り返すのにどうかを確認した方がよかったかなと思いました。
ソースの出所: https://stackoverflow.com/questions/243351/environment-tickcount-vs-datetime-now
それで?
GetTickCountは混沌と混沌の歴史です。まさか、20余日パソコンをつけっぱなしにするあほがいるかと思った先人の誤りでしょうね。DWORDとして、符号をなくしてせいぜい40余日です。
普段使うことで問題はなさそうですが、何が長くかかり、メソッドやクラスの特徴さえ知っておけば、長引く作業を後回しもでき、パフォーマンスも向上できると思います。
これからも、気になることはぜひ実験してみないとわからないのが当然だということでしょう。