IOスレッドとワーカースレッドが歩いていてバーに入っていくと:マイクロベンチマークの話

Red Hat で Solution Architect として Quarkus を担当している伊藤ちひろです。

この記事は、Quarkus.io のブログ記事、A IO thread and a worker thread walk into a bar: a microbenchmark story の翻訳記事です。 注:タイトルはアメリカで鉄板のジョークです。

競合他社は最近、彼らのスタックのパフォーマンスをQuarkusと比較したマイクロベンチマークを発表しました。Quarkusチームは、このマイクロベンチマークを額面通りに捉えるべきではないと考えています。なぜなら、このマイクロベンチマークは、似たような比較をしていなかったために、間違った結論になってしまったからです。比較対象の2つのフレームワークは、どちらもリアクティブ処理をサポートしています。リアクティブ処理は、ビジネスロジックをIOスレッド上で直接実行することを可能にします。それは最終的には応答時間と同時実行性に焦点を当てたマイクロベンチマークでより良いパフォーマンスを発揮します。マイクロベンチマークは、両方のフレームワークがこの恩恵を受けるよう(またはどちらのフレームワークでも受けないように)に書かれているべきでした。いずれにしても、これは非常に興味深いトピックであり、Quarkusユーザーにとっては良い情報であることがわかりました。

TL;DR

Quarkusは、命令的なワークロードと反応性の高いワークロードの両方に対して優れたパフォーマンスを発揮します。それは、Quarkus自体がEclipse Vert.xをベースにしているからです。これは、成熟したトップパフォーマンスのリアクティブフレームワークであり、ユーザーのユースケースに最も適したIOの典型を階層化、混合、一致させることができる方法です。

純粋にIOスレッドでの実行に適したRESTのシナリオがある場合は、Quarkus Reactive Routesを使用してVert.x Reactive Routeを追加します。すると、Quarkus RESTEasyを使用するよりもアプリのパフォーマンスが向上します。

私たちは、この低負荷なREST + 検証をする検証競合が書いたマイクロベンチマークを実行しました。これは静的データを返すだけでブロッキング操作を行わないことが特徴です。Quarkus Reactive Routesを使用して純粋にIOスレッド上でQuarkusを実行した場合、Quarkus RESTEasy(IOスレッドとワーカースレッドを混在させたもの)を使用して実行した場合と比較して、リクエスト数/秒が2.6倍になり、メモリ使用量(RSS)が30%減少したことがわかりました。しかし、それはこの特定のシナリオのために構築されたマイクロベンチマークの目的上にあります(それについての詳細は後で)。

https://quarkus.io/assets/images/posts/iothread-benchmark/throughput.png

より面白い読み物

マイクロベンチマーク自体は面白くないが、反応性のあるスタックで起こりうる現象の良いデモになっています。Quarkusとそのリアクティブエンジンを学ぶための乗り物として使ってみましょう。

命令型とリアクティブ:エレベーターピッチ

このブログ記事では、命令型実行モデルとリアクティブ実行モデルの根本的な違いを説明していません。しかし、なぜ言及したマイクロベンチマークでこれほどの違いがあるのかを理解するためには、いくつかの概念が必要です。

一般的に、JavaのWebアプリケーションでは、IO操作のブロッキングと組み合わせた命令型プログラミングを使用します。これは、信じられないほど人気があります。なぜならコードを推論するのが簡単だからです。物事は順次実行されます。一つのリクエストが他のリクエストに影響されないようにするために、それらは別のスレッドで実行されます。ワークロードがデータベースや別のリモートサービスと相互作用する必要がある場合、IOのブロッキングに依存します。そのスレッドはそれらの回答を待ってブロックされています。別のスレッドで実行されている他のリクエストが大幅に遅くなることはありません。しかし、これは同時実行リクエストごとに1つのスレッドを意味します。それは全体の同時実行性を制限しています。

一方、リアクティブ実行モデルは、非同期開発モデルと非ブロッキングIOを包含しています。このモデルでは、複数のリクエストを同じスレッドで処理することができます。リモートサービスを要求したり、データベースと対話したりするため、リクエストの処理が進まなくなったとき、非ブロッキングIOを使用します。これはスレッドを即座に解放し、別のリクエストに対応するために使用することができます。IO操作の結果が利用可能になると、リクエストの処理が復元され、実行が継続されます。このモデルでは、複数のリクエストを処理するためにIOスレッドを使用することができます。大きなメリットは2つあります。まず、別のスレッドに飛ぶ必要がないので、レスポンスが小さくなります。第二に、スレッドの使用量が減るため、メモリの消費量を減らすことができます。リアクティブモデルはハードウェアリソースをより効率的に使用します。しかし...大きな欠点があります。リクエストの処理がブロックされるようになったら、これは本当にひどいことになります。他のリクエストには対応できません。これを避けるためには、非ブロッキングコードの書き方、非同期処理の構造、非ブロッキングIOの使い方を学ぶ必要があります。それは価値観の劇的な変化です。

Quarkusでは、私たちはできるだけ簡単に変化できるようにしたいと考えています。しかし、ユーザーアプリケーションの大多数は命令型モデルを使って書かれていることがわかりました。そのため、ユーザーアプリケーションがJAX-RSを使用する場合、Quarkusはデフォルトでワーカースレッドに(命令的な)ワークロードを実行するようになっています。

Hello Worldマイクロベンチマーク。IOスレッドかワーカースレッドか?

競合他社のマイクロベンチマークの話に戻りますが、私たちは些細な処理と同様に些細な検証を行うRESTエンドポイントを持っています。かなり意味のあるビジネスの仕事ではない。以上、RESTのHello Worldの全ての意図と目的でした。

Quarkus RESTEasyでマイクロベンチマークを実行すると、リクエストはIOスレッド上のリアクティブエンジンによって処理されます。その後、処理作業はワーカースレッドプールから第二のスレッドに引き渡されます。それがディスパッチというものです。あなたのマイクロベンチマークがHello Worldと同じくらいわずかなことをすると、ディスパッチのオーバーヘッドは相対的に大きくなります。ディスパッチのオーバーヘッドはほとんどの(実生活の)アプリケーションでは気になりません。ですが、マイクロベンチマークのような人工的な構築物では非常に目に見えます。

しかし、競合他社のスタックは、デフォルトではすべてのリクエスト操作を IO スレッド上で実行します。つまり、このマイクロベンチマークが実際に比較していたのは、ワーカースレッドプールへのディスパッチのコストだけです。そして率直に言って(競合他社の数字によると)、この余分なディスパッチ作業にもかかわらず、Quarkusは今日、競合他社のスループットの95%を達成することができました。私たちは常にパフォーマンスを向上させているので、今日はそう言いました。また、実際にはもうすぐリリースされる1.4のリリースでさらに得られることを期待しています。

不利な点(ワーカースレッドへのディスパッチ)で比較すると、Quarkusはそれにもかかわらず、スループットではほぼ同程度の速さです。

しかし、Quarkusはディスパッチを完全に回避してIOスレッド上で操作を実行することもできます。これは、どちらの場合も、アプリケーションが必要としたときに派遣を求めるのはユーザーの責任であるため、競合他社のスタックがどのように構成されていたかと比較すると、より正確です。同じもの同士を比較するために、Eclipse Vert.xで実装されたQuarkus Reactive Routesを使ってみましょう。このモデルでは、デフォルトでは処理は IO スレッド上で実行されます。

@ApplicationScoped
public class MyDeclarativeRoutes {

  @Inject
  Validator validator;

  @Route(path = "/hello/:name", methods = HttpMethod.GET)
  void greetings(RoutingExchange ex) {
    RequestWrapper requestWrapper = new RequestWrapper(ex.getParam("name").orElse("world"));
    Set<ConstraintViolation<RequestWrapper>> violations = validator.validate(requestWrapper));
    if( violations.size() == 0) {
      ex.ok("hello " + requestWrapper.name);
    } else {
      StringBuilder validationError = new StringBuilder();
      violations.stream().forEach(violation -> validationError.append(violation.getMessage()));
      ex.response().setStatusCode(400).end(validationError.toString());
    }
  }

  private class RequestWrapper {
    @NotBlank
    public String name;

    public RequestWrapper(String name) {
      this.name = name;
    }
  }

}

これは、あなたのJAX-RS相当のものと大差ありません。

スループット数

Kubernetesによってオーケストレーションされたコンテナへの典型的なリソース割り当てを反映するように制約されたdockerコンテナ内でマイクロベンチマークアプリケーションを実行しました。

  • 4 CPU
  • 256MBのRAM
  • Javaプロセスのため-Xmx128mのヒープ使用量

Reactive Routesを使用したQuarkusは、リクエスト/秒数が2.6倍になることがわかりました。 2.6倍!それは理にかなっています!アプリケーションコードは事実上何もしないのを思い出して下さい。ディスパッチコストが比較的高いです。もし、もっと現実的なワークロードを書こうとしたら(JPAアクセスのようなブロッキング操作をして、それゆえにディスパッチを強制的に行うこともあるかもしれません)、結果は大きく異なるでしょう。コンテキストが大事!

マイクロベンチマークを再現するコードや方法はGitHubにあります。

表1.Quarkusのワーカースレッドへのディスパッチと純粋にIOスレッド上での実行を比較したマイクロベンチマークの結果

Quarkus - 1.3.1.Final - 4 CPU’s ワーカースレッド IO スレッド 比率
最初のリクエストまでの平均開始時間 (ms) [1] 993.9 868.3 87.4%
最大RSS (MB) 138.8 97.9 70.5%
最大スループット (req/sec) 46,172.2 123,520.4 267.5%
最大リクエスト/秒/MB 332.7 1,262.1 379.4%

https://quarkus.io/assets/images/posts/iothread-benchmark/throughput-percentile.png

公平に比較すると(純粋にIOスレッドにある - ディスパッチなし)、Quarkusはそのスループットを2倍以上にしています。

生成された負荷がテスト対象のシステムの最大スループットに貢献するように、クライアントが体験する応答時間は指数関数的に増加します。なので、(負荷量の割には)できるだけ右に縦線が入っているのがベストなシステムです。同様に重要なのは、できるだけフラットなラインを長く保つことです。システムが最大スループットに達する前に応答時間が低下することを望みません。

ところで、競合のマイクロベンチマークでは、Quarkusの方がRSSの消費量が多い(RAMの消費量が多い)ことが示されています。これは、競合他社がワーカースレッドプールを持っていなかったのに対し、ワーカースレッドプールが運用されていることでも説明できます。Quarkus Reactive Routesソリューション(純粋なIOイベントの実行時)では、RSSの使用量が30%削減します。

https://quarkus.io/assets/images/posts/iothread-benchmark/rss.png

このグラフでは、低ければ低いほど良いです。純粋な IO スレッドソリューションは、メモリ使用量 (RSS) にほとんど変化を与えずにスループットを向上させることができます。これはとても良いです!

結論

Quarkusでは、ブロッキング操作を安全に実行したり、IOスレッド上で非ブロッキング操作を実行したり、両方のモデルを混在させたりすることができます。Quarkusチームはパフォーマンスを非常に重視しています。Quarkusは、命令的モデルを使用しているか、反応性モデルを使用しているかにかかわらず、素晴らしい数字を提供していると考えています。より現実的なワークロードでは、ディスパッチコストははるかに少なく、2つのアプローチの間にこれほど劇的な違いは見られないでしょう。いつものように、できるだけ実際のアプリケーションに近い状態でテストしてください。

謎が解けました。ベンチマークは難しい、チャレンジしましょう。しかし、物語の教訓は、すべての悪いことには、いくつかの良いことが来るということです。Quarkusのアプリケーションを完全にIOスレッド上で実行する方法がわかりました。そして、状況によってはそれが大きな違いを生むこともあります。覚えておいてください、ブロックしないでください。実際、そうするとQuarkusが警告してくれます。そして、Quarkusはとても速く、自分自身を打ち負かすこともできるということも学びました。 ;P

* 各記事は著者の見解によるものでありその所属組織を代表する公式なものではありません。その内容については非公式見解を含みます。