AcapellaDB Network Transport comparation test

Цели бенчмарков

  • Сравнить скорость GRPC и Aeron RPC
  • Понять, какая часть Aeron RPC потребляет больше всего CPU
  • Узнать максимальную скорость передачи сообщений по Aeron

Конфигурация тестовых машин

Для тестов использовались две машины со следующими характеристиками:

  • 72-ядерный CPU
  • 64 ГБ ОЗУ
  • Сеть 10 Gbit Ethernet

Aeron

Для начала, оценим скорость передачи простых сообщений через Aeron без сериализации и RPC. Сообщения отправляются с максимальной скоростью, ограниченной только back-pressure.

код отправки сообщений

// msgSize - размер сообщения
val pub = aeron.addPublication("aeron:udp?endpoint=$serverAddress", 10)
val buffer = UnsafeBuffer(BufferUtil.allocateDirectAligned(1024 * 1024, BitUtil.CACHE_LINE_LENGTH))
...
while (true) {
    pub.offer(buffer, 0, msgSize)
}

код получения сообщений

val counter = AtomicLong() // счётчик полученных байт
val sub = aeron.addSubscription("aeron:udp?endpoint=$serverAddress", 10)

val asm = FragmentAssembler { buffer, offset, length, header ->
    counter.addAndGet(length.toLong())
}
...
while (true) {
    sub.poll(asm, 1024)
}

Результаты замеров:

размер сообщения пропускная способность, байт/сек пропускная способность, сообщений/сек
1 3116441 3116441
2 6940044 3470022
4 25040070 6260018
8 22913552 2864194
16 68332646 4270790
32 165270332 5164698
64 234009030 3656391
128 269632089 2106501
256 319087334 1246435
512 315183411 615593
1024 316454604 309038
2048 207172812 101159
4096 229573427 56048
8192 327012352 39919
16384 260198923 15881
32768 235457740 7186
65536 390627328 5961
131072 317285990 2421

lifehack

Если мелко откройте : "open image in new tab" (правый клик)

пропускная способность aeron, байт/сек пропускная способность aeron, сообщений/сек

Максимальная пропускная способность - примерно 400 Мбайт/сек.

Максимальное количество сообщений - примерно 6 млн/сек.

Aeron + Serialization

Добавим сериализацию и десериализацию сообщений с помощью библиотеки Protobuf. Они выполняются в потоках отправки и приёма сообщений соответственно.

protobuf-описание сообщения

message PingRequest {
    bytes data = 1;
}

код отправки сообщений

// msgSize - размер сообщения
val pub = aeron.addPublication("aeron:udp?endpoint=$serverAddress", 10)
val buffer = UnsafeBuffer(BufferUtil.allocateDirectAligned(1024 * 1024, BitUtil.CACHE_LINE_LENGTH))
val output = DirectBufferOutputStream()
val request = Ping.PingRequest
        .newBuilder()
        .setData(ByteString.copyFrom(ByteArray(msgSize)))
        .build()
...
while (true) {
    output.wrap(buffer)
    request.writeTo(output)
    pub.offer(buffer, 0, output.position())
}

код получения сообщений

val counter = AtomicLong() // счётчик полученных байт
val sub = aeron.addSubscription("aeron:udp?endpoint=$serverAddress", 10)
val input = DirectBufferInputStream()

val asm = FragmentAssembler { buffer, offset, length, header ->
    input.wrap(buffer, offset, length)
    val request = Ping.PingRequest
            .newBuilder()
            .mergeFrom(input)
            .build()
    counter.addAndGet(request.data.size().toLong())
}
...
while (true) {
    sub.poll(asm, 1024)
}

Результаты замеров:

размер сообщения пропускная способность, байт/сек пропускная способность, сообщений/сек
1 399005 399005
2 872933 436467
4 1753412 438353
8 3533239 441655
16 6549750 409359
32 10079913 314997
64 20378009 318406
128 32360908 252820
256 55729945 217695
512 77656166 151672
1024 157514547 153823
2048 164054016 80105
4096 224154419 54725
8192 276282572 33726
16384 337874124 20622
32768 318396825 9717
65536 359071744 5479
131072 320903577 2448

пропускная способность aeron + serialization, байт/сек пропускная способность aeron + serialization, сообщений/сек

Максимальная пропускная способность - примерно 350 Мбайт/сек.

Максимальное количество сообщений - примерно 440 тысяч/сек.

Количество сообщений в секунду сократилось на порядок из-за накладных расходов на сериализацию. При этом, большие сообшения почти не страдают из-за этого оверхеда, поэтому всё ещё получается передавать большой объём данных.

Aeron + RPC

Добавим к передаче сериализованных сообщений простую реализацию RPC. Сериализация и десериализация, так же как и в прошлом тесте, выполняются в потоках оправки и получения сообщений, но теперь они через очередь пересылают сообщения в поток RPC.

protobuf-описание сообщений

message PingRequest {
    bytes data = 1;
}

message PingResponse {
    bytes data = 1;
}

код отправки запросов

class RpcRequest(val id: Long, val payload: MessageLite)

val pub = aeron.addPublication("aeron:udp?endpoint=$serverAddress", 10)
val buffer = UnsafeBuffer(BufferUtil.allocateDirectAligned(1024 * 1024, BitUtil.CACHE_LINE_LENGTH))
val output = DirectBufferOutputStream()
val messages = OneToOneConcurrentArrayQueue<RpcRequest>(1024)
...
while (true) {
    messages.drain { msg ->
        buffer.putLong(0, msg.id)
        output.wrap(buffer, BitUtils.SIZE_OF_LONG, buffer.capacity())
        msg.payload.writeTo(output)
        pub.offer(buffer, 0, BitUtils.SIZE_OF_LONG + output.position())
    }
}

код получения запросов

class RpcRequest(val id: Long, val payload: MessageLite)

val counter = AtomicLong()
val sub = aeron.addSubscription("aeron:udp?endpoint=$serverAddress", 10)
val input = DirectBufferInputStream()
val messages = OneToOneConcurrentArrayQueue<RpcRequest>(1024)

val asm = FragmentAssembler { buffer, offset, length, header ->
    val id = buffer.getLong(offset)
    input.wrap(buffer, offset + BitUtils.SIZE_OF_LONG, length - BitUtils.SIZE_OF_LONG)
    val payload = Ping.PingRequest
            .newBuilder()
            .mergeFrom(input)
            .build()
    messages.offer(RpcRequest(id, payload))
}
...
while (true) {
    sub.poll(asm, 1024)
}

код RPC клиента и сервера

interface PingApi {
    @RPC(1) suspend fun ping(request: Ping.PingRequest): Ping.PingResponse
}

// counter - счётчик полученных байт
class PingImpl(private val counter: AtomicLong) : PingApi {
    override suspend fun ping(request: Ping.PingRequest): Ping.PingResponse {
        counter.addAndGet(request.data.size().toLong())
        return Ping.PingResponse
                .newBuilder()
                .setData(request.data)
                .build()
    }
}

// concurrency - количество конкурентных запросов
suspend fun client(ctx: CoroutineContext, node: RpcNode) {
    val api = node.create<PingApi>()
    val client = api[serverAddress]

    for (c in 1..concurrency) {
        launch(ctx) {
            while (true) {
                withTimeoutOrNull(1000) {
                    client.ping(request)
                }
            }
        }
    }
}

Отправлять запросы без ожиданя ответа здесь уже не получится, поэтому появляется ещё один параметр - количество конкурентных запросов, для начала посмотрим, как он влияет на производительность:

количество конкурентных запросов пропускная способность, сообщений/сек
1 17743
2 32962
4 75879
8 120853
16 152122
32 223092
64 332447
128 343200
256 328979
512 366588
1024 270144

пропускная способность aeron + rpc, сообщений/сек

Из результатов теста видно, что примерно после 64 конкурентных запрсов не происходит увеличения пропускной сопосбности, поэтому будем проводить следующий тест именно с таким значением этого параметра.

Результаты замеров в зависимости от размера сообщения:

размер сообщения пропускная способность, байт/сек пропускная способность, сообщений/сек
1 328979 328979
2 726566 363283
4 1092852 273213
8 2705092 338137
16 5768800 360550
32 13337392 416794
64 21619884 337811
128 33036044 258094
256 24751488 96686
512 71553843 139754
1024 149788160 146278
2048 118082969 57658
4096 228886118 55880
8192 326871449 39901
16384 308343603 18820
32768 377795379 11529
65536 312875417 4774
131072 342517350 2613

пропускная способность aeron + rpc, байт/сек пропускная способность aeron + rpc, сообщений/сек

Максимальная пропускная способность - примерно 350 Мбайт/сек.

Максимальное количество сообщений - примерно 400 тысяч/сек.

По сравнению с предыдущим тестом сериализации, производительность уменьшилась незначительно. Можно сказать, что больше всего времени занимает сериалиазация сообщений.

GRPC

Для сравнения, возьмём популярную библиотеку GRPC, которая позволяет удалённо вызывать методы между разными языками. Она использует HTTP/2 для передачи сообщений и protobuf для сериализации.

protobuf-описание сообщений и сервиса

message PingRequest {
    bytes data = 1;
}

message PingResponse {
    bytes data = 1;
}

service PingApi {
    rpc Ping(PingRequest) returns (PingResponse) {}
}

код RPC клиента и сервера

// counter - счётчик полученных байт
class PingImpl(private val counter: AtomicLong) : PingApiImplBase() {
    override fun ping(request: Ping.PingRequest, response: StreamObserver<Ping.PingResponse>) {
        counter.addAndGet(request.data.size().toLong())
        response.onNext(Ping.PingResponse
                .newBuilder()
                .setData(request.data)
                .build())
        response.onCompleted()
    }
}

// concurrency - количество конкурентных запросов
suspend fun client() {
    val channel = ManagedChannelBuilder
            .forAddress(serverHost, serverPort)
            .usePlaintext(true)
            .build()
    val stub = PingApiGrpc.newFutureStub(channel)

    for (c in 1..concurrency) {
        launch {
            while (true) {
                withTimeoutOrNull(1000) {
                    stub.ping(request)
                }
            }
        }
    }
}

Точно так же для начала необходимо найти оптимальное количество конкурентных запросов:

количество конкурентных запросов пропускная способность, сообщений/сек
1 3812
2 6049
4 12329
8 15378
16 22354
32 23757
64 34033
128 44330
256 51589
512 49648
1024 43088

пропускная способность grpc, сообщений/сек

Из результатов теста видно, что примерно после 128 конкурентных запрсов не происходит увеличения пропускной сопосбности, поэтому будем проводить следующий тест именно с таким значением этого параметра.

Результаты замеров в зависимости от размера сообщения:

размер сообщения пропускная способность, байт/сек пропускная способность, сообщений/сек
1 40378 40378
2 83335 41668
4 172504 43126
8 317909 39739
16 659072 41192
32 1258892 39340
64 2681286 41895
128 4803801 37530
256 9913420 38724
512 19149414 37401
1024 36178022 35330
2048 62747443 30638
4096 107170611 26165
8192 175803596 21460
16384 202763468 12376
32768 253322854 7731
65536 310103244 4732
131072 356371660 2719

пропускная способность grpc, байт/сек пропускная способность grpc, сообщений/сек

По результатам этих тестов можно сказать, что на небольших сообщениях Aeron быстрее GRPC почти на порядок, но с увеличением размера сообщения разница становится не такой значительной, при максимальных размерах сообщения её практически нет. Это объясняется увеличением отношения полезной нагрузки к затратам на пересылку и сериализацию сообщенния. Можно сказать, что для передачи больших сообщений нет разницы, какой транспорт использовать.

Сравнительные графики

Далее представлены графики, более наглядно показывающие производительность различных вариантов.

aeron vs aeron + serialization vs aeron + rpc, байт/сек пропускная способность aeron + rpc, сообщений/сек

aeron + prc vs grpc, байт/сек aeron + prc vs grpc, сообщений/сек

aeron + prc vs grpc, конкурентность, сообщений/сек

Выводы

  1. Aeron быстрее GRPC на небольших сообщениях, до 128 байт, примерно на порядок.
  2. С ростом размера сообщений, начиная от 1-2 Кбайт, разница между транспортами начинает исчезать.
  3. Больше всего процессорного времени уходит на сериализацию сообщений.
  4. Учитывая пункты 1 и 2, эффективный батчинг сообщений на уровне приложения может сильно сократить расходы на удалённые вызовы.
  5. Максимальная пропускная способность Aeron полученная в этих тестах - 400 Мбайт/сек или 3.2 Гбит/сек от максимально возможных 10 Гбит/сек.