Elasticsearchにおける「32GBメモリの壁」の境界線を調べる

## はじめに
Elasticsearchでは以前からJVMヒープメモリサイズの設定における「32GBの壁」の存在が語られてきました。
デフォルト設定はXms,Xmxともに1gですが、ノードに搭載されるメモリ量に応じてこのパラメータは適宜サイジングすることが必要です。

Elasticsearch 2.xの時代にはすでに[Elasticsearch Definitive Guide](https://www.elastic.co/guide/en/elasticsearch/guide/current/heap-sizing.html)にて、JVMのヒープサイズの設定で32GBを超えないことが性能上のTipsとして言及されていました。
現在の[公式document](https://www.elastic.co/guide/en/elasticsearch/reference/current/heap-size.html)にも、このサイズは「約」32GBを超えないこととして推奨がされています。

本記事ではある環境において、この正確なしきい値となるメモリサイズを調べる方法をまとめます。
なお、この内容はOSのバージョンやJDKのバージョンによって異なるため、お使いの環境における正確なサイズは下記の手順を実際に実行して確認するようにしてください。

## 「32GBの壁」とは何か（ざっくり）
最初に明確にしておくと、これはElasticsearchに限った話ではありません。JVMアプリケーション全般に当てはまる話です。
ここで言われている「32GBの壁」とはざっくり説明すると、64bitシステムにおいてJavaヒープに格納されるオブジェクトの参照ポインタの表現方法を圧縮(compressed)することでシステムリソースを節約する機能と関連があります。

以下のサイトに詳細な説明があるのですが(*1)、通常64bitシステムでのJavaヒープのアドレスは64bit分使うところを、もしもヒープサイズを32GB以下しか使わない場合に限り、次のような制約におさめることで32bitアドレスでうまく節約してしまおうというトリックになっています。

- ヒープオブジェクトのアドレスを8の倍数のサイズに制限する(Object Alignmentと呼ばれます)
- アドレスは32bitで表現し、実際のアドレスはその値に8(0x1000)を乗じた値とする

こうすることで、従来は32bitでは4GB(=2^32)しか表現できなかったところが、35bit(=2^35≒32GB)のアドレスを表すことができるようになります。この仕組みのことを"Compressed OOPs"（圧縮オブジェクト参照）と呼びます。

*1: https://www.baeldung.com/jvm-compressed-oops

![hoge](https://www.baeldung.com/wp-content/uploads/2019/04/Untitled-Diagram-2-e1554193172973.jpg)

当然、32GB以上のヒープメモリを扱う際にはこの方法は使えませんので64bitを使った通常のオブジェクト参照となります。この場合、Compressed OOPsを使った場合に比べてメモリの利用効率が落ちるため、32GBメモリ以上にヒープサイズを設定しないことが推奨されているというわけです。

## 検証手順
ここでは下記の環境を使い、ヒープサイズの大きさを変えながら、Compressed OOPsの「境界線」を探ります。結論から言うと、この境界線はGCの種類によっても変わるため、JDK11で使えるCMS:ConcMarkSweep)とG1GCの2種類それぞれについて調べています。

### 環境
Azure上で以下のスペックの仮想マシン/OSをデプロイしてOpenJDKとELasticsearchをインストールしました。

- スペック
  - D13-2_v2 (2vCPU/56GBmem) ※メモリは32GB以上あるものであればOK
- OS
  - Ubuntu18.04LTS
- JDK
  - OpenJDK 11.0.6
- Elasticsearch
  - Elasticsearch 7.6.1

### GCタイプとヒープサイズの組み合わせ
以下の4つの組み合わせで検証を行いました。01,02と03,04では境界線のヒープサイズが微妙に違う点に注意してください。（30MB違います）
また、01と02、03と04のようにそれぞれのGCについて境界線となるサイズを超えるとCompressed OOPsが使えなくなります。
以下で実際にそれを確かめる手順をまとめます。

| no. | GCタイプ | ヒープサイズ(Xms/Xmx) | Compressed Oopsが使えるか？ |
|---------|---------|--------|--------|
| 01 | CMS(ConcMarkSweep) | 32766MB | 使える |
| 02 | CMS(ConcMarkSweep) | 32767MB | 使えない |
| 03 | G1GC | 32736MB | 使える |
| 04 | G1GC | 32737MB | 使えない |

### 確認手順① JVM単体での確認
最初にJVM単体で確認を行います。これは"-XX:+PrintFlagsFinal"オプションを付けてjavaコマンドを実行することでJVMがCompressed Oops付きで実行されているかを確認する方法を用いています。

#### 01: CMSGCで境界線未満(32766MB)
以下の出力の最後の行に注目してください。「bool UseCompressedOops = true」となっている箇所が、Compressed Oopsが有効な際の出力です。つまり「32GBの壁」は超えていないことになります。

```shell
sodo@vm01:~$ java -XX:+UseConcMarkSweepGC -XX:+PrintFlagsFinal -Xms32766m -Xmx32766m 2>/dev/null | grep Compressed
   size_t CompressedClassSpaceSize                 = 1073741824                                {product} {default}
     bool UseCompressedClassPointers               = true                                 {lp64_product} {ergonomic}
     bool UseCompressedOops                        = true                                 {lp64_product} {ergonomic}
```

#### 02: CMSGCで境界線オーバー(32767MB)
「bool UseCompressedOops = false」となっており「32GBの壁」を超えています。

```shell
sodo@vm01:~$ java -XX:+UseConcMarkSweepGC -XX:+PrintFlagsFinal -Xms32767m -Xmx32767m 2>/dev/null | grep Compressed
   size_t CompressedClassSpaceSize                 = 1073741824                                {product} {default}
     bool UseCompressedClassPointers               = false                                {lp64_product} {default}
     bool UseCompressedOops                        = false                                {lp64_product} {default}
```

#### 03: G1GCで境界線未満(32736MB)
「bool UseCompressedOops = true」となっています。「32GBの壁」を超えていません。

```shell
sodo@vm01:~$ java -XX:+UseG1GC -XX:+PrintFlagsFinal -Xms32736m -Xmx32736m 2>/dev/null | grep Compressed
   size_t CompressedClassSpaceSize                 = 1073741824                                {product} {default}
     bool UseCompressedClassPointers               = true                                 {lp64_product} {ergonomic}
     bool UseCompressedOops                        = true                                 {lp64_product} {ergonomic}
```

#### 04: G1GCで境界線オーバー(32737MB)
「bool UseCompressedOops = false」となっています。「32GBの壁」を超えています。

```shell
sodo@vm01:~$ java -XX:+UseG1GC -XX:+PrintFlagsFinal -Xms32737m -Xmx32737m 2>/dev/null | grep Compressed
   size_t CompressedClassSpaceSize                 = 1073741824                                {product} {default}
     bool UseCompressedClassPointers               = false                                {lp64_product} {default}
     bool UseCompressedOops                        = false                                {lp64_product} {default}
```

### 確認手順② Elasticsearchでの確認
次にElasticsearchにてjvm.optionファイルにヒープサイズを指定して確認を行います。結果はelasticsearch.logに起動時にCompressed Oopsモードかどうかがログ出力されるため、それを確認します。

#### 01: CMSGCで境界線未満(32766MB)
jvm.optionファイルに以下のように設定を行います。3-7行目はデフォルトのままです。今回はOpenJDK11を使っているため、GCはCMSが使われます。(gc.logにも利用するGCタイプは出力されます）

```ini:jvm.options
-Xms32766m
-Xmx32766m

# 10-13:-XX:-UseConcMarkSweepGC
# 10-13:-XX:-UseCMSInitiatingOccupancyOnly
14-:-XX:+UseG1GC
14-:-XX:G1ReservePercent=25
14-:-XX:InitiatingHeapOccupancyPercent=30
```
この設定でElasticsearchを起動するとログに以下の行が確認されます。行末にある「compressed ordinary object pointers [true]」がCompressed Oopsが有効である印となります。

```elasticsearch.log
[2020-03-30T07:26:14,269][INFO ][o.e.e.NodeEnvironment    ] [vm01] heap size [31.9gb], compressed ordinary object pointers [true]
```

#### 02: CMSGCで境界線オーバー(32767MB)
jvm.optionファイルに以下のように設定を行います。1,2行目のヒープサイズのみ変えています。

```ini:jvm.options
-Xms32767m
-Xmx32767m

# 10-13:-XX:-UseConcMarkSweepGC
# 10-13:-XX:-UseCMSInitiatingOccupancyOnly
14-:-XX:+UseG1GC
14-:-XX:G1ReservePercent=25
14-:-XX:InitiatingHeapOccupancyPercent=30
```
今度は「compressed ordinary object pointers [false]」となりました。Compressed Oopsが無効化されたことがわかります。

```elasticsearch.log
[2020-03-30T09:43:01,828][INFO ][o.e.e.NodeEnvironment    ] [vm01] heap size [31.9gb], compressed ordinary object pointers [false]
```

#### 03: G1GCで境界線未満(32736MB)
ヒープサイズとあわせて、設定ファイル内にあるコメントに従ってGC設定を変更しています。今回はOpenJDK11を使っているため、この設定によりG1GCが使われます。(gc.logの出力も確認しました)

```ini:jvm.options
-Xms32736m
-Xmx32736m

10-13:-XX:-UseConcMarkSweepGC
10-13:-XX:-UseCMSInitiatingOccupancyOnly
11-:-XX:+UseG1GC
11-:-XX:G1ReservePercent=25
11-:-XX:InitiatingHeapOccupancyPercent=30
```
ここではCompressed Oopsが有効となっています。

```elasticsearch.log
[2020-03-30T07:22:29,694][INFO ][o.e.e.NodeEnvironment    ] [vm01] heap size [31.9gb], compressed ordinary object pointers [true]
```

#### 04: G1GCで境界線オーバー(32737MB)
ヒープサイズを1MBだけ増やします。その他は変更していません。

```ini:jvm.options
-Xms32737m
-Xmx32737m

10-13:-XX:-UseConcMarkSweepGC
10-13:-XX:-UseCMSInitiatingOccupancyOnly
11-:-XX:+UseG1GC
11-:-XX:G1ReservePercent=25
11-:-XX:InitiatingHeapOccupancyPercent=30
```
今度はCompressed Oopsが無効になりました。

```elasticsearch.log
[2020-03-30T07:23:19,486][INFO ][o.e.e.NodeEnvironment    ] [vm01] heap size [31.9gb], compressed ordinary object pointers [false]
```

## まとめ
Elasticsearchでよく言われる「32GBの壁」について、JVM全般で関連するCompressed Oops（圧縮オブジェクト参照）の仕組みの概要と、実際の環境でJVMおよびElasticsearch起動時にCompressed Oopsの有効・無効を確認する手順を紹介しました。
なお、実際には適切なヒープサイズを検討するにあたっては、Elasticsearchの用途（全文検索中心、メトリクスのソート/Aggregation中心など）や、Luceneが使うキャッシュメモリなど、いくつか考慮する点があります。不明点などはdiscuss.elastic.coなどのコミュニティサイトに聞いてみたり、Elasticsearch Tokyo User Groupなどのユーザコミュニティ/勉強会で有識者に聞いてみることもおすすめです。