クソコード仕置き人両兵衛

物騒に見えても単に基本的な技術情報への誘導です

第1回 【C#】List<T>.RemoveAll()は使い物にならないほど遅い?⇒遅くなる記述を選んだだけです

おことわり

この記事はZennに書いていたシリーズを移したもので、初出は2023年11月11日である。

目次

今回の標的

クソコード仕置き人というからには標的はクソコードである。今回のクソコードは下記の記事より。

クソコード生産者表示

クソコード仕置き人というからには標的はあくまでクソコードであるのだが、その量産業者がいるとすれば当然そちらも警戒せねばなるまい。
今回の生産者は内藤時浩…8~16ビットCPUの昔からPCゲームを嗜まれていた方は「えっ、あの?」と思われるかもしれない。中年プログラマの私は大層驚いた。御存じでない方向けのちょうどいい人物情報リンクが思いつかないので雑に説明すると、その昔に魅力的なPCゲームを多数開発された方だ。レトロゲーム名作などの文脈で「ハイドライド」シリーズなどを目にすることも多いのではないか。

クソコード概要

List<Point> canvasPointsに100万要素ほどの点が入っている。このリストから取り除きたい点の情報がList<Point> removePointsに入っているとき、

canvasPoints.RemoveAll(p => removePoints.Contains(p));  

としてせいぜい数百オーダーの削除を実行したところ、8秒以上もかかった!遅い!俺が削除ロジックを考えて使ったら20倍速になりました!参考にしていいぞ!ということのようである。

瞬時に「そりゃ遅くて当たり前やん」と思われた方には、続きが全く必要ない。以下には本当に当たり前の解説と検証しかない。

お仕置き

List<T>.Contains()O(n)操作である。長いループの中から呼び出すのはパフォーマンス上不利なので、高速な検索ができる手段に置き換えるべき。基本的には標準ライブラリのHashSet<T>.Contains()で十分だろう。このメソッドはO(1)操作である*1

canvasPoints.RemoveAll(removePoints.ToHashSet().Contains);  

時代劇風タイトルにしたつもりなのに、いきなり悪役が斬られて断末魔のシーンでええんかいなという気はするが、技術記事なら最初に結論がいるよね…

裏取り

裏取りに使ったコードはこちら
100万要素入ったList<Point>からランダムに100要素抜粋して削除対象リストを作り、下記4種の実装それぞれで削除に要する時間をBenchmarkDotNetで調べた。

BenchmarkDotNet v0.13.10, Windows 10 (10.0.19045.3570/22H2/2022Update)  
AMD Ryzen 5 5600, 1 CPU, 12 logical and 6 physical cores  
.NET SDK 7.0.403  
  [Host]     : .NET 6.0.24 (6.0.2423.51814), X64 RyuJIT AVX2  
  Job-NNHEGN : .NET 6.0.24 (6.0.2423.51814), X64 RyuJIT AVX2  
  
InvocationCount=8  UnrollFactor=1    
Method Mean Error StdDev Gen0 Gen1 Gen2 Allocated
Kuso 78.60 ms 0.783 ms 0.733 ms - - - 150 B
BetterKuso 197.19 ms 1.196 ms 1.119 ms - - - 8003518 B
Normal 18.72 ms 0.123 ms 0.115 ms - - - 2440 B
Except 74.73 ms 0.639 ms 0.597 ms 500.0000 500.0000 500.0000 81444279 B
  1. Kuso
    生産者が8秒もかかるとしていて、私の手元でも1800msかかった実装*2…のはずだったのだが、下の長い折り畳みの経緯で何故か80msほどになって元の遅さが再現できなくなってしまった。申し訳ないが、ある環境で1800ms程度かかるはずの実装ということでこれを基準にさせていただきたい。
    Kusoが謎の高速化 この記事を書き始める前に、「8秒もかかるならBenchmarkDotNetは不適切だな」と考えてStopWatchを使った検証にするつもりであった。実際に、それで最初に計測したKusoは1800ms程度かかっていた。生産者の記事で8秒もかかったのはPointSystem.Drawingじゃない方(小数のやつ)だったり、削除対象の「せいぜい数百」が500件程度だったりしたのかもしれないなと考えつつ、秒単位かかるのは事実だと確認して寝た。起きてからKuso以外共々改めて計測しよう…とコードを編集して実行したら何故かKusoが80ms程度まで速くなってしまった。これならBenchmarkDotNetにやらせるかとなった次第である。
    しかしKusoが異様に速くなった理由は本当に分からないので、変な話だが1800ms程度に遅くする方法にお心当たりのある方はお知らせいただきたい。寝て起きるまでの間にコンパイラもランタイムも変えていないはずだし、List.RemoveAll()のソースを見ても「引数Predicate<T>が効率悪い!何とかせねば!」とはなっていないし、JITが「最近こいつやたらとList<T>.Contains()呼ぶから暗黙の.ToHashSet()したれ」なんて気を回すとも思えないし…謎だ。
  2. BetterKuso
    生産者が「RemoveAllより高速」としていた独自実装。(上記Kusoの謎の高速化を見なかったことにすれば)10倍弱の速度が出たということになるか。当然こんなコードを書いてはいけない。
  3. Normal
    「お仕置き」のところで挙げた、基本に従ったまともな実装。要素100件からのContainsをO(n)からO(1)にしたので、(謎の高速化がないときの)Kusoの100倍程度の速度になるというのも納得しやすい。倍率でなく絶対的な値についても、UIで「この100個の点消して」と指示して20ms程度で終わればまず気にならないだろう。
  4. Except
    canvasPoints = canvasPoints.Except(removePoints).ToList();  

「ある集合から、別の集合を除くのなら結局差集合なのでは?」という至極もっともな実装。Normalよりもこちらを先に思いつくことがあっても自然だろう。LINQで求めた結果のリスト全体を作り直すため、今回のように100万要素もある場合は主にメモリについて圧倒的に不利だが、遅いというわけではない。ゆうしゃの持ち物リスト最大8個から、まおうが指定する禁止アイテム10選の内容に従って没収する程度ならこの記述でも全く問題ないだろう。もっとも、集合としての演算なので重複する要素があった場合は一要素にまとめられてしまうことには注意が必要だ。

戒め

.NET標準ライブラリが遅いのではない。貴方の書いたコードが遅いのだ。
勿論、ライブラリ中に極めて鈍足なコードを見つけたり、画期的な改善法を思いつくことがあれば折角のオープンソースなのだからissueでもpull requestでも挙げればよい。しかし、すでにそうやって毎日世界中の優れたエンジニアがツッコミを入れているライブラリなのであって、それよりも独自実装がずばぬけたパフォーマンスをたたき出すことはそうそうないと自覚すべきだ。いずれ標準よりも高速なライブラリやフレームワークを次々と発表するエンジニアになれれば話は別だが*3、まずは先人や公式ドキュメントが説いてくれている当たり前のことを当たり前にできるようにするべきだろう。常識に捉われるな!と叫んで非常識に陥るようではいけない。

次回予告?

最近たまたま素晴らしい題でひどいことを書いているサイトを相次いで見つけてもうて。そのうちの一つが下記のサイト。

沢山書き並べているけど、そのうち有用と言えるのは

string.StartsWithメソッド、string.EndsWithメソッドのパフォーマンスが、適切な引数で200倍になる

の1個のみである。後は全部嘘か、対処が不適切だ。
ただ、自分でお仕置きすることにこだわってはいないので、「この程度すぐ退治したるわ」という方がやっといてくれると私も嬉しい。

まだ嘆いている

にしても、あの内藤時浩がなあ…

…が、このメソッド、めちゃくちゃ遅いのです。canvasPoints が凡そ 100万ポイントあるとして、removePoints がせいぜい数百というオーダーであっても、これを実行すると
平均: 8,536ms
…遅い、遅すぎる。内部でいちいちリストを詰めている様が目に見えるようだ。こんなことせず、インデックスで処理して最後にまとめてえいやっとコピーしてくれれば十分普通に速いだろうに、なにやってくれちゃってるんでしょうか。
仕方が無いので、RemoveAll より高速に動作する removePoints に該当する処理を作ってみる事にしました。

本当になにやってくれちゃってるんだか。

*1:勿論「遅いO(1)もありうる」事実は常に意識しておく必要がある。当然、必ずO(n)のn倍速になるというわけでもない。ただし、今回のように極端に効率が悪いと分かっている場合は「長いループから呼ぶのでO(1)採用」で十分である。

*2:BenchmarkDotNetに乗せる都合上、測定コード上ではアルゴリズムに影響のない範囲で表現を変えている。以下も同様

*3:私がこの文脈で最初に思い浮かべるのは@neueccさん