こういう記事を読んだ。
transparent-to-radiation.blogspot.com
なんかScalaのメモリ使用量が異常に多いなと思って、調べた。検証コードもアップした。
検証として、様々なJVM(OpenJDKとかCorettoとか)とそのバージョン(8, 11, 17)でJARを実行して考察した。JVMを用意するためにASDFを使った。また、ASDFから引っぱってこれないJVMのバージョン(OpenJDKの8など)は省略している。 JAVA_OPTS
は-Xms100M -Xmx2G
である。
手元のマシン(Linux x86_64 Xeon W-10855M 2.80GHz 64GiB RAM)でのrun-matrix.sh
の実行結果は、以下の通り(各列は、JVM、計算件数、実行時間Sec、メモリ総使用量KiB)。
openjdk-11 openjdk 11 1000 0.35 62612 openjdk-11 openjdk 11 1000000 0.62 115120 openjdk-11 openjdk 11 10000000 3.78 688544 openjdk-11 openjdk 11 100000000 46.77 1535268 openjdk-17 openjdk 17 1000 0.51 63140 openjdk-17 openjdk 17 1000000 0.70 110328 openjdk-17 openjdk 17 10000000 2.57 695104 openjdk-17 openjdk 17 100000000 32.91 1566164 temurin-8.0.362+9 temurin 8 1000 0.49 60696 temurin-8.0.362+9 temurin 8 1000000 0.77 145188 temurin-8.0.362+9 temurin 8 10000000 3.76 372256 temurin-8.0.362+9 temurin 8 100000000 49.37 2240764 temurin-11.0.18+10 temurin 11 1000 0.42 61728 temurin-11.0.18+10 temurin 11 1000000 0.62 124076 temurin-11.0.18+10 temurin 11 10000000 2.20 686860 temurin-11.0.18+10 temurin 11 100000000 33.52 1891060 temurin-17.0.6+10 temurin 17 1000 0.35 65692 temurin-17.0.6+10 temurin 17 1000000 0.48 113516 temurin-17.0.6+10 temurin 17 10000000 2.09 693068 temurin-17.0.6+10 temurin 17 100000000 32.92 1967876 corretto-8.372.07.1 corretto 8 1000 0.58 61340 corretto-8.372.07.1 corretto 8 1000000 0.94 160644 corretto-8.372.07.1 corretto 8 10000000 3.79 372752 corretto-8.372.07.1 corretto 8 100000000 49.09 2241108 corretto-11.0.19.7.1 corretto 11 1000 0.36 62172 corretto-11.0.19.7.1 corretto 11 1000000 0.56 127616 corretto-11.0.19.7.1 corretto 11 10000000 2.26 688568 corretto-11.0.19.7.1 corretto 11 100000000 35.94 1992936 corretto-17.0.7.7.1 corretto 17 1000 0.29 66072 corretto-17.0.7.7.1 corretto 17 1000000 0.39 114328 corretto-17.0.7.7.1 corretto 17 10000000 2.04 693248 corretto-17.0.7.7.1 corretto 17 100000000 30.20 1285704 graalvm-22.3.1+java11 graalvm 11 1000 0.36 118316 graalvm-22.3.1+java11 graalvm 11 1000000 0.49 151004 graalvm-22.3.1+java11 graalvm 11 10000000 1.52 177736 graalvm-22.3.1+java11 graalvm 11 100000000 23.67 301800 graalvm-22.3.1+java17 graalvm 17 1000 0.34 113060 graalvm-22.3.1+java17 graalvm 17 1000000 0.41 138260 graalvm-22.3.1+java17 graalvm 17 10000000 1.52 159484 graalvm-22.3.1+java17 graalvm 17 100000000 23.92 264100
実行時間をJVMのブランドと計算個数で分類して棒プロットにすると以下のようになる。一番全体的な性能が出てそうなのは最新のGraalVMだった。
メモリ使用量だと以下のようになる。
JVMのバージョンだけ取出すと以下の通り。
この検証の過程で注目すべきポイントをいくつか見付けた。
JVM
プロットでも分かるとおり、GraalVMは顕著にメモリ消費量が少ないし、計算性能も良い。メモリ消費が少ない理由はあとで説明するが、非常に速度が出ているのが面白い。最新版のGraalVMでの動作時間は、100000000個の計算を23秒で済ませている。かなり賢い最適化をしているのかなと思う。
また、Javaのバージョンが8から11に、11から17になるにつれて計算とメモリ消費の性能もおおむね良くなっているのが面白い。皆さんJavaのバージョンは上げましょう。こんなに調子良くなるとは思ってなかったので驚いたけど、思えばJava8が出たのは2014年のことなので、もうすぐ10年経つわけで、10年分の進歩がここに出ているのかなと思う。さすがに10年はデカい。
GC
このテストではGCは特に指定してなかったし、元記事でもGCをデフォルトのままにしていたようだ。Java/ScalaといったJVMファミリーでは「メモリ使用量」というものはちょっとトリッキーで、全体としては「-Xmx
オプションでJVMに与えられた最大メモリ容量を絶対に超えない」という共通点があるものの、それをどう使うかはGCアルゴリズムに任せられていて、最近のアルゴリズムは「与えられた最大値をのびのび使って最高のパフォーマンスを出す」方向性で調整する傾向にあるようだ。常時起動が前提のサーバ用途だとこの振る舞いが適切であろうことは想像がつくと思う。
JVM言語において、最大メモリ使用量が大きい小さいという議論はJVMの話であり単なるチューニング要素になってしまい、コードやコンパイラの善し悪しを離れた話となる。したがってJava/Scalaが他の言語と比べてメモリを食うというのは間違いではないのだけれど、あまり単純化もできないと思う。他の言語では決定的な「メモリ使用量」という概念は、JVM系言語では「チューニングの対象」であって、可変的なのである。
実際、GCアルゴリズムで消費メモリは大きく変化した。上掲の比較ではGraalVMがメモリ使用量が少ない(=良い?)という雰囲気だったが、GraalVMではデフォルトでSerial GCを使うので、単にSerial GCが保守的にメモリを回収してくれる(そのかわりスループットやレイテンシが劣る)というだけの話の可能性がある。ちなみにJava 9からG1 GCというGCが標準ラインナップに入ってきており、多くのJVMで採用されている。Serial GCと違ってマルチスレッドで動作し、(閾値はチューニング可能だが)あまり積極的にはメモリを解放しない傾向にある。
試しに全てのJVMで-XX:+UseG1GC
を設定してG1 GCを強制するとどうなるかというと、以下のような結果になる。
それでもやっぱりGraalVMは速いしメモリを食わないようだった。こういう大量計算が得意なのだろうか?賢い推論をして、メモリ割当てを最適化しているのかもしれない。
ArrayBuffer
性能が劣化している原因の仮説として自分が選んだもう1つの点が、元記事の検証コードで使われているscala.collection.mutable.ArrayBuffer
だ。これは内部にArrayを持っているため非常に高速なアクセスが可能であるし、動的な拡張も可能な優れたデータ構造だ。ちなみに元記事のJava版コードではjava.util.ArrayList
が使われているため、パフォーマンス差の原因ではないかと目論んだのだ。
実装を見るとわかるが、ScalaのArrayBufferは、あらかじめ確保しておいたArrayの容量が要素追加で不足するとき、内部のArrayのサイズを2倍にして確保しなおし、内容をコピーするという挙動になっている。これが巨大なメモリ消費の原因ではないかと思ったのだ。ちなみにJavaのArrayListはどの程度メモリを確保するかの仕様はJVMの実装に委ねているのが面白い。
GCをG1GCに固定した状態で、実装を書き換えてArrayListを使うようにすると以下のような結果になった。
あまり劇的な変化は起こらなかった。データ構造は支配的ではないようだった。
制御構造
元記事のコードでは、breakするような処理にscala.util.control.Breaks
を使っている。
val primeNumbers = new java.util.ArrayList[Int]() val b = new Breaks def isPrimeNumber(n: Int): Boolean = { var isPrime = true b.breakable { for (pn <- primeNumbers.iterator().asScala) { if (pn * pn > n) { isPrime = true b.break } if (n % pn == 0) { isPrime = false b.break } } } return isPrime }
これが大量に作られては捨てられているのではないか?と目星をつけ、素朴なScalaのコードに書き換えた(GCはG1GCで固定した)。
def isPrimeNumber(n: Int): Boolean = { var isPrime = true val iter = primeNumbers.iterator while (iter.hasNext) { val pn = iter.next() if (pn * pn > n) { return true } else if (n % pn == 0) { return false } } return isPrime }
したらてきめんに速くなって、だいたい25秒くらいで計算できるようになった。メモリ消費はちょっと改善したが結局GraalVMが消費量が少ないということは変化なしだった。
番外: Stack
ちなみにStackというデータ構造がある。
最新のScalaではStackの内部はArrayになっていて、追加や走査が高速に設計されている。今回は追加と走査しかしないのでStackでよいのだ。制御構造は先程の簡素なバージョンのまま、これを使うとどうなるのか試してみた。
かなりメモリ消費の具合が良くなったことがわかる。特に、Java 17での振る舞いの良さが顕著になっていて、あまりJVMの違いが気にならなくなっている(Graalは安定して軽いが)。
番外: Native
番外編としてScala Nativeでコンパイルしてみたかったが、GCがぜんぜん言うことを聞かずに無限にメモリを食ったりしてしまった。boehm GCをコンパイル時に指定しておくと安定して250MiBくらいの動作で動いてくれた。動作速度そのものはそんなに速くなくて、JITによる最適化が速度に強い影響を及ぼしているのかもしれないと感じた(ロードするクラスが少ないので、Nativeのメリットが出にくい課題だったように思う)。
結語
結局Javaよりもメモリ消費が多いのは謎だったが、おおむねGCとScalaの相性かなにかで、あまりメモリを解放しにくい動作になっているのかもしれないと思った。GraalVMの動作が白眉で、速いしメモリもすぐ解放するという夢のような感じだった。Javaに比べるとScalaはランタイムクラスのぶんが乗ってくるから、そこがメモリを食っているのかもしれないなと思った。
- 特定のユースケースではGraalVMを使うとメモリ消費が抑えられがちになるようだ
- メモリの目標が決まっているならちゃんとヒープを指定しないと、最近のGCはたくさんメモリを食おうとする
- 最適なデータ構造を選ぼう
- まだJava8使ってるんですか?