単語を1回数えるために、2回もハッシュ化していた話
先週、ログパーサーのプロファイリングを行いました。これは日次ログ内のエラーコードをカウントするものです。コアとなるロジックは、単純な辞書(dictionary)の更新です。
ほとんどの開発者は、次のように記述します。
if (counts.TryGetValue(code, out int c))
counts[code] = c + 1;
else
counts[code] = 1;
このコードは、キーを2回ハッシュ化しています。TryGetValue は、ハッシュを計算してバケットを辿ることでエントリを見つけます。その後、インデクサが値を更新するために、全く同じ作業をもう一度行います。トークンごとに、同じキーと同じハッシュに対して2回もCPUサイクルを無駄にしているのです。
2回目の工程をスキップできます。CollectionsMarshal.GetValueRefOrAddDefault を使用してください。
ref int slot = ref CollectionsMarshal.GetValueRefOrAddDefault(counts, code, out _);
slot++;
このメソッドは、1ステップでスロットの検索または作成を行います。ストレージへの参照を直接返してくれるため、ハッシュ計算とバケットの走査は1回だけで済みます。その後、値をその場で(in place)変更できます。
500万個のトークンでテストした結果は以下の通りです。
結果: • TryGetValue + インデクサ: 160.0 ms • GetValueRefOrAddDefault: 95.0 ms
1回のルックアップで済むバージョンは、1.7倍高速でした。
重要なのは、メモリ割り当ては変わらなかったということです。このテクニックはメモリを節約するものではありません。CPUを節約するものです。ガベージコレクションが原因でコードが遅くなっている場合、この変更は効果がありません。カウント処理が重いためにコードが遅くなっている場合は、効果があります。
ループ内で既存のキーに対して多くの更新を行う場合にこれを使用してください。更新と挿入の比率が高まるほど、そのメリットは大きくなります。
注意:この参照は、辞書内部のストレージを指しています。この参照が有効なのは、次の構造的な変更(structural change)が行われるまでです。キーを追加または削除する場合は、参照を保持しないでください。参照を取得して値を変更したら、すぐに次の処理へ進んでください。
ほとんどのタスクでは TryGetValue を使用してください。その方が読みやすいためです。辞書のループがパフォーマンスのボトルネックになっている場合にのみ、この ref 版を使用してください。
Source: https://dev.to/ssukhpinder/i-hashed-every-word-twice-to-count-it-once-1mg9
