Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

Scalaのメモリ使用量はJavaよりも多いか検証した

こういう記事を読んだ。

transparent-to-radiation.blogspot.com

なんかScalaのメモリ使用量が異常に多いなと思って、調べた。検証コードもアップした。

github.com

検証として、様々な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というデータ構造がある。

docs.scala-lang.org

最新の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使ってるんですか?
★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?