モバイルベンチマーク
gRPCがより高速で優れたRPCフレームワークになるにつれて、「gRPCはどれくらい速いのか?」という質問を常に受けてきました。サーバーサイドの包括的なベンチマークはすでにありますが、モバイルベンチマークはありません。クライアントのベンチマークは、サーバーのベンチマークとは少し異なります。クエリ/秒(QPS)や同時実行スレッド数よりも、レイテンシやリクエストサイズなどの要素をより重視します。そこで、これらの要因を定量化し、それらに確かな数値を提示するためにAndroidアプリを構築しました。
具体的にベンチマークしたいのは、クライアントサイドのprotobufとJSONのシリアライゼーション/デシリアライゼーション、およびgRPCとRESTful HTTP JSONサービスとの比較です。シリアライゼーションベンチマークでは、メッセージのサイズと、シリアライズおよびデシリアライズの速度を測定したいと考えています。RPCベンチマークでは、エンドツーエンドのリクエストのレイテンシとパケットサイズを測定したいと考えています。
Protobuf vs. JSON
protobufとJSONをベンチマークするために、ランダムに生成されたプロトでシリアライゼーションとデシリアライゼーションを繰り返し実行しました。これはこちらで見ることができます。これらのプロトは、数バイトから100KBを超えるものまで、サイズと複雑さが大きく異なりました。JSONの同等物が作成され、それらもベンチマークされました。protobufメッセージでは、シリアライズとデシリアライズのために主に3つの方法がありました。バイト配列を単純に使用する方法、CodedOutputStream/CodedInputStream(protobuf独自の入出力ストリーム実装)を使用する方法、JavaのByteArrayOutputStreamとByteArrayInputStreamを使用する方法です。JSONでは、org.jsonのJSONObjectを使用しました。これは、それぞれtoString()とnew JSONObject()の1つの方法しかありませんでした。
ベンチマークを可能な限り正確にするために、ベンチマーク対象のコードをインターフェースでラップし、単純に指定された回数だけループさせました。これにより、システム時刻をチェックするのに費やされた時間は除外されました。
interface Action {
void execute();
}
// Sample benchmark of multiplication
Action a = new Action() {
@Override
public void execute() {
int x = 1000 * 123456;
}
}
for (int i = 0; i < 100; ++i) {
a.execute();
}
ベンチマークを実行する前に、JVMによる一時的な挙動をクリーンアップするためにウォームアップを実行し、次に指定された時間(protobuf vs JSONの場合は10秒)実行するのに必要な反復回数を計算しました。これを行うために、1回の反復から開始し、その実行にかかった時間を測定し、最小サンプル時間(この場合は2秒)と比較しました。反復回数が十分な時間を要した場合、計算によって10秒実行するのに必要な反復回数を推定しました。そうでない場合は、反復回数を2倍にして繰り返しました。
// This can be found in ProtobufBenchmarker.java benchmark()
int iterations = 1;
// Time action simply reports the time it takes to run a certain action for that number of iterations
long elapsed = timeAction(action, iterations);
while (elapsed < MIN_SAMPLE_TIME_MS) {
iterations *= 2;
elapsed = timeAction(action, iterations);
}
// Estimate number of iterations to run for 10 seconds
iterations = (int) ((TARGET_TIME_MS / (double) elapsed) * iterations);
結果
protobuf、JSON、およびgzip化されたJSONでベンチマークが実行されました。
protobufのシリアライゼーション/デシリアライゼーション方法に関わらず、JSONよりも一貫して約3倍高速であることがわかりました。デシリアライゼーションでは、JSONは実際には小さいメッセージ(<1kb)では約1.5倍速いですが、大きいメッセージ(>15kb)ではprotobufが2倍高速です。gzip化されたJSONでは、protobufはサイズに関わらず、シリアライゼーションで5倍以上高速です。デシリアライゼーションでは、小さいメッセージでは両者ほぼ同じですが、大きいメッセージではprotobufが約3倍高速です。結果については、READMEでさらに詳しく調査および再現できます。
gRPC vs. HTTP JSON
RPC呼び出しをベンチマークするには、エンドツーエンドのレイテンシと帯域幅を測定したいと考えています。これを行うために、60秒間サーバーとピンポンし、毎回同じメッセージを使用して、レイテンシとメッセージサイズを測定します。メッセージは、サーバーが読み取るいくつかのフィールドと、バイトのペイロードで構成されます。gRPCのユニタリアン呼び出しと、単純なRESTful HTTP JSONサービスを比較しました。gRPCベンチマークはチャンネルを作成し、60秒が経過するまで応答を受信するたびに繰り返されるユニタリアン呼び出しを開始します。応答には、送信されたのと同じペイロードを持つプロトが含まれています。
同様に、HTTP JSONベンチマークでは、同等のJSONオブジェクトをサーバーにPOSTリクエストとして送信し、サーバーは同じペイロードを持つJSONオブジェクトを返します。
// This can be found in AsyncClient.java doUnaryCalls()
// Make stub to send unary call
final BenchmarkServiceStub stub = BenchmarkServiceGrpc.newStub(channel);
stub.unaryCall(request, new StreamObserver<SimpleResponse>() {
long lastCall = System.nanoTime();
// Do nothing on next
@Override
public void onNext(SimpleResponse value) {
}
@Override
public void onError(Throwable t) {
Status status = Status.fromThrowable(t);
System.err.println("Encountered an error in unaryCall. Status is " + status);
t.printStackTrace();
future.cancel(true);
}
// Repeat if time isn't reached
@Override
public void onCompleted() {
long now = System.nanoTime();
// Record the latencies in microseconds
histogram.recordValue((now - lastCall) / 1000);
lastCall = now;
Context prevCtx = Context.ROOT.attach();
try {
if (endTime > now) {
stub.unaryCall(request, this);
} else {
future.done();
}
} finally {
Context.current().detach(prevCtx);
}
}
});
HttpUrlConnectionとOkHttpライブラリの両方が使用されました。
HTTPとの比較では、gRPCのユニタリアン呼び出しのみがベンチマークされました。ストリーミング呼び出しはユニタリアン呼び出しよりも2倍以上高速であったためです。さらに、HTTPにはストリーミングの同等物はありません。これはHTTP/2固有の機能です。
結果
レイテンシの点では、gRPCは95パーセンタイルまで5倍から10倍高速で、平均エンドツーエンドリクエストは約2ミリ秒です。帯域幅に関しては、gRPCは小さいリクエスト(100〜1000バイトのペイロード)で約3倍、大きいリクエスト(10KB〜100KBのペイロード)で一貫して2倍高速です。これらの結果を再現したり、さらに詳しく調査したりするには、リポジトリを確認してください。