Estudo interessante de C++ (AOT) vs Java (JIT) com resultados favoráveis ao JIT
Projeto GitHub com resultados e mais detalhes => https://github.com/coralblocks/CoralBench?tab=readme-ov-file#hotspotvm--xcomp-and-jit-vs-c-llvm-clang-vs-graalvm
Diante dos detalhes abaixo, alguém tem alguma ideia do motivo pelo qual a versão em C++ do benchmark foi mais lenta que a versão em Java? Será que algo passou despercebido ou existe algum aspecto da implementação em C++ que poderia ser otimizado ainda mais? Talvez opções específicas de otimização do Clang que deveríamos explorar?
O objetivo desta pesquisa é explorar as diferenças de desempenho entre as estratégias de compilação JIT (just-in-time) e AOT (ahead-of-time) e entender suas respectivas vantagens e desvantagens. O intuito não é afirmar que uma linguagem é mais lenta ou pior do que a outra.
Em nossos testes, observamos melhores resultados com o HotSpot JVM 23 usando a compilação JIT. Obtivemos resultados mais lentos com C++ (compilado com Clang 18), GraalVM 23 (compilado com native-image) e HotSpot JVM 23 com a flag -Xcomp. Estamos buscando entender por que isso acontece e, se possível, identificar formas de melhorar o desempenho da versão em C++ para igualar os resultados da compilação JIT em Java.
Nosso benchmark envolve a comparação de uma implementação de tabela hash (map) em Java com uma implementação equivalente em C++. Fizemos todo o esforço possível para garantir consistência entre as duas implementações, mas é possível que alguns detalhes tenham sido negligenciados.
A implementação da tabela hash em si é simples, e procuramos tornar o código em Java e C++ o mais equivalente possível, incluindo a forma como a memória é gerenciada. Por exemplo, garantimos que os valores da tabela hash em C++ sejam armazenados por referência, e não por valor, para evitar cópias desnecessárias.
O benchmark cria uma tabela hash com 5.000.000 de buckets e insere 10.000.000 de objetos, minimizando colisões. A busca linear em cada bucket é limitada a um máximo de duas iterações para evitar discrepâncias nas medições devido a comportamentos variados de colisão entre os diferentes elementos.
A classe Bench, que realiza as medições, também foi projetada para ser equivalente nas duas implementações.
To compile and execute:
$ clang++ -Ofast -march=native -flto -std=c++17 -I./src/main/c -c ./src/main/c/int_map.cpp -o ./target/cpp/int_map.o
$ clang++ -Ofast -march=native -flto -std=c++17 -I./src/main/c -c ./src/main/c/bench.cpp -o ./target/cpp/bench.o
$ clang++ -Ofast -march=native -flto -std=c++17 -I./src/main/c -c ./src/main/c/int_map_benchmark.cpp -o ./target/cpp/int_map_benchmark.o
$ clang++ -Ofast -march=native -flto -std=c++17 -o ./target/cpp/int_map_benchmark ./target/cpp/int_map.o ./target/cpp/bench.o ./target/cpp/int_map_benchmark.o
$ ./target/cpp/int_map_benchmark 0 10000000 5000000
bench.hpp:
#ifndef BENCH_HPP
#define BENCH_HPP
#include <chrono>
#include <iostream>
#include <limits>
#include <iomanip>
#include <string>
#include <cmath>
#include <map>
#include <sstream>
class Bench {
public:
Bench(int warmupCount = 0);
~Bench();
void mark();
void measure();
void reset();
void printResults() const;
private:
int warmupCount;
int measurementCount;
long long sum;
long long minTime;
long long maxTime;
int size;
std::map<long long, long long>* results;
std::chrono::steady_clock::time_point startTime;
static std::string formatWithCommas(long long value);
static std::pair<double, std::string> formatTime(double nanos);
static std::string formatPercentage(double perc);
static double roundToDecimals(double d, int decimals);
void printPercentiles() const;
void addPercentile(double perc) const;
};
#endif // BENCH_HPP
bench.cpp:
#include "bench.hpp"
using namespace std;
Bench::Bench(int warmupCount)
: warmupCount(warmupCount),
measurementCount(0),
sum(0),
minTime(numeric_limits<long long>::max()),
maxTime(numeric_limits<long long>::min()),
size(0) {
results = new std::map<long long, long long>();
}
Bench::~Bench() {
delete results;
}
void Bench::mark() {
startTime = chrono::steady_clock::now();
}
void Bench::measure() {
auto endTime = chrono::steady_clock::now();
auto elapsed = chrono::duration_cast<chrono::nanoseconds>(endTime - startTime).count();
measurementCount++;
if (measurementCount > warmupCount) {
sum += elapsed;
if (elapsed < minTime) minTime = elapsed;
if (elapsed > maxTime) maxTime = elapsed;
// Increment the frequency of this elapsed time
auto it = results->find(elapsed);
if (it == results->end()) {
results->insert({elapsed, 1});
} else {
it->second++;
}
size++;
}
}
void Bench::reset() {
measurementCount = 0;
sum = 0;
minTime = numeric_limits<long long>::max();
maxTime = numeric_limits<long long>::min();
results->clear();
size = 0;
}
void Bench::printResults() const {
int effectiveCount = measurementCount - warmupCount;
if (effectiveCount <= 0) {
cout << "Not enough measurements after warmup to report statistics." << endl;
return;
}
double avg = static_cast<double>(sum) / effectiveCount;
string effCountStr = formatWithCommas(effectiveCount);
string warmupStr = formatWithCommas(warmupCount);
string totalStr = formatWithCommas(measurementCount);
cout << "Measurements: " << effCountStr
<< " | Warm-Up: " << warmupStr
<< " | Iterations: " << totalStr << endl;
auto [avgVal, avgUnit] = formatTime(avg);
auto [minVal, minUnit] = formatTime(static_cast<double>(minTime));
auto [maxVal, maxUnit] = formatTime(static_cast<double>(maxTime));
cout << fixed << setprecision(3);
cout << "Avg Time: " << avgVal << " " << avgUnit << " | "
<< "Min Time: " << minVal << " " << minUnit << " | "
<< "Max Time: " << maxVal << " " << maxUnit << endl;
printPercentiles();
}
string Bench::formatWithCommas(long long value) {
std::string numStr = std::to_string(value);
int insertPosition = static_cast<int>(numStr.length()) - 3;
while (insertPosition > 0) {
numStr.insert(insertPosition, ",");
insertPosition -= 3;
}
return numStr;
}
pair<double, string> Bench::formatTime(double nanos) {
if (nanos >= 1'000'000'000.0) {
double seconds = nanos / 1'000'000'000.0;
return {roundToDecimals(seconds, 3), seconds > 1 ? "seconds" : "second"};
} else if (nanos >= 1'000'000.0) {
double millis = nanos / 1'000'000.0;
return {roundToDecimals(millis, 3), millis > 1 ? "millis" : "milli"};
} else if (nanos >= 1000.0) {
double micros = nanos / 1000.0;
return {roundToDecimals(micros, 3), micros > 1 ? "micros" : "micro"};
} else {
double ns = nanos;
return {roundToDecimals(ns, 3), ns > 1 ? "nanos" : "nano"};
}
}
double Bench::roundToDecimals(double d, int decimals) {
double pow10 = std::pow(10.0, decimals);
return std::round(d * pow10) / pow10;
}
void Bench::printPercentiles() const {
if (size == 0) return;
double percentiles[] = {0.75, 0.90, 0.99, 0.999, 0.9999, 0.99999};
for (double p : percentiles) {
addPercentile(p);
}
}
std::string Bench::formatPercentage(double perc) {
double p = perc * 100.0;
std::ostringstream oss;
oss << std::fixed << std::setprecision(6) << p;
std::string s = oss.str();
// remove trailing zeros
while (s.back() == '0') {
s.pop_back();
}
// if the last character is now a '.', remove it
if (s.back() == '.') {
s.pop_back();
}
// Append the '%' sign
s += "%";
return s;
}
void Bench::addPercentile(double perc) const {
if (results->empty()) return;
long long target = static_cast<long long>(std::llround(perc * size));
if (target == 0) return;
if (target > size) target = size;
// Iterate through the map to find the top element at position target
long long iTop = 0;
long long sumTop = 0;
long long maxTop = -1;
for (auto &entry : *results) {
long long time = entry.first;
long long count = entry.second;
for (int i = 0; i < count; i++) {
iTop++;
sumTop += time;
if (iTop == target) {
maxTop = time;
goto FOUND;
}
}
}
FOUND:;
double avgTop = static_cast<double>(sumTop) / iTop;
auto [avgVal, avgUnit] = formatTime(avgTop);
auto [maxVal, maxUnit] = formatTime(static_cast<double>(maxTop));
cout << fixed << setprecision(3);
cout << formatPercentage(perc) << " = [avg: " << avgVal << " " << avgUnit
<< ", max: " << maxVal << " " << maxUnit << "]\n";
}
int_map.hpp:
#ifndef INT_MAP_HPP
#define INT_MAP_HPP
#include <optional>
#include <cstddef>
template <typename E>
class IntMap {
private:
template <typename T>
struct Entry {
int key;
T* value;
Entry<T>* next;
};
Entry<E>** data;
int count;
Entry<E>* head;
const int capacity;
Entry<E>* newEntry(int key, const E& value, Entry<E>* next) {
Entry<E>* newEntry = head;
if (newEntry != nullptr) {
head = newEntry->next;
} else {
newEntry = new Entry<E>();
}
newEntry->key = key;
newEntry->value = const_cast<E*>(&value);
newEntry->next = next;
return newEntry;
}
void free(Entry<E>* e) {
e->value = nullptr;
e->next = head;
head = e;
}
int toArrayIndex(int key) const {
return (key & 0x7FFFFFFF) % capacity;
}
public:
IntMap(int capacity)
: capacity(capacity), count(0), head(nullptr) {
data = new Entry<E>*[capacity];
for (int i = 0; i < capacity; i++) {
data[i] = nullptr;
}
}
~IntMap() {
clear();
while (head != nullptr) {
Entry<E>* temp = head;
head = head->next;
delete temp;
}
delete[] data;
}
int size() const {
return count;
}
E* get(int key) const {
Entry<E>* e = data[toArrayIndex(key)];
while (e != nullptr) {
if (e->key == key) {
return e->value;
}
e = e->next;
}
return nullptr;
}
E* put(int key, const E& value) {
int index = toArrayIndex(key);
Entry<E>* e = data[index];
while (e != nullptr) {
if (e->key == key) {
E* old = e->value;
e->value = const_cast<E*>(&value);
return old;
}
e = e->next;
}
data[index] = newEntry(key, value, data[index]);
count++;
return nullptr;
}
E* remove(int key) {
int index = toArrayIndex(key);
Entry<E>* e = data[index];
Entry<E>* prev = nullptr;
while (e != nullptr) {
if (e->key == key) {
if (prev != nullptr) {
prev->next = e->next;
} else {
data[index] = e->next;
}
E* oldValue = e->value;
free(e);
count--;
return oldValue;
}
prev = e;
e = e->next;
}
return nullptr;
}
void clear() {
for (int index = capacity - 1; index >= 0; index--) {
while (data[index] != nullptr) {
Entry<E>* next = data[index]->next;
free(data[index]);
data[index] = next;
}
}
count = 0;
}
};
#endif // INT_MAP_HPP
int_map.cpp:
#include "int_map.hpp"
// NOOP
int_map_benchmark.cpp:
#include "int_map.hpp"
#include "bench.hpp"
#include <iostream>
using namespace std;
struct Dummy {};
int main(int argc, char* argv[]) {
int warmupCount = 1000000;
int measureCount = 1000000;
int capacity = 100000;
if (argc > 1) {
warmupCount = atoi(argv[1]);
}
if (argc > 2) {
measureCount = atoi(argv[2]);
}
if (argc > 3) {
capacity = atoi(argv[3]);
}
cout << "\nArguments: warmup=" << warmupCount << " measurements=" << measureCount << " mapCapacity=" << capacity << endl << endl;
int iterations = warmupCount + measureCount;
IntMap<Dummy>* map = new IntMap<Dummy>(capacity);
Bench bench(warmupCount);
Dummy dummy;
cout << "Benchmarking put..." << endl;
bench.reset();
for (int i = 0; i < iterations; i++) {
bench.mark();
map->put(i, dummy);
bench.measure();
}
bench.printResults();
cout << endl;
cout << "Benchmarking get..." << endl;
bench.reset();
for (int i = 0; i < iterations; i++) {
bench.mark();
volatile auto val = map->get(i); // volatile to prevent optimization out
bench.measure();
}
bench.printResults();
cout << endl;
cout << "Benchmarking remove..." << endl;
bench.reset();
for (int i = 0; i < iterations; i++) {
bench.mark();
map->remove(i);
bench.measure();
}
bench.printResults();
cout << endl;
delete map;
return 0;
}
Results:
Benchmarking put...
Measurements: 10,000,000 | Warm-Up: 0 | Iterations: 10,000,000
Avg Time: 38.597 nanos | Min Time: 30.000 nanos | Max Time: 31.107 micros
75% = [avg: 32.235 nanos, max: 33.000 nanos]
90% = [avg: 32.501 nanos, max: 35.000 nanos]
99% = [avg: 33.093 nanos, max: 106.000 nanos]
99.9% = [avg: 37.288 nanos, max: 884.000 nanos]
99.99% = [avg: 38.177 nanos, max: 1.316 micros]
99.999% = [avg: 38.438 nanos, max: 15.270 micros]
Benchmarking get...
Measurements: 10,000,000 | Warm-Up: 0 | Iterations: 10,000,000
Avg Time: 24.109 nanos | Min Time: 19.000 nanos | Max Time: 15.886 micros
75% = [avg: 21.578 nanos, max: 25.000 nanos]
90% = [avg: 22.200 nanos, max: 26.000 nanos]
99% = [avg: 22.978 nanos, max: 91.000 nanos]
99.9% = [avg: 23.639 nanos, max: 160.000 nanos]
99.99% = [avg: 23.884 nanos, max: 432.000 nanos]
99.999% = [avg: 23.963 nanos, max: 13.451 micros]
Benchmarking remove...
Measurements: 10,000,000 | Warm-Up: 0 | Iterations: 10,000,000
Avg Time: 24.279 nanos | Min Time: 19.000 nanos | Max Time: 29.132 micros
75% = [avg: 21.899 nanos, max: 24.000 nanos]
90% = [avg: 22.426 nanos, max: 26.000 nanos]
99% = [avg: 23.140 nanos, max: 91.000 nanos]
99.9% = [avg: 23.798 nanos, max: 153.000 nanos]
99.99% = [avg: 24.017 nanos, max: 426.000 nanos]
99.999% = [avg: 24.125 nanos, max: 13.548 micros]
Bom, nesse caso não há muito o que uma linguagem de programação AOT possa fazer para competir com linguagens que usa JIT. Mas há casos e casos:
- Se seu programa precisa ser leve com pouco gasto de RAM e Memória: AOT
- Se seu programa for criado para iniciar super rápido e ser usado rapidamente: AOT
Em outros casos, não há problema nenhum em usar JIT sabendo que:
-
Apesar do aumento de performance, o JIT vai consumir mais cpu e memória para otimizar o seu código em execução
-
A inicialização de um processo com JIT é mais lento que a inicialização de um projeto AOT por conter estruturas adicionais para compilação in-runtime do projeto
Mas se seu código vai ficar por muito tempo vivo, de preferência continuamente executando alguma coisa então não tem nada de errado em usar JIT.
Inclusive o canal do Andreas Kling que eu citei em outro post, para lidar com o ladybird, seu webbrowser ele decidiu criar um interpretador javascript próprio chamado LibJS que contém um JIT próprio e você pode acompanhar por exemplo este Vídeo sobre ele prototipando um JIT
E voltando ao assunto principal, não tem com uma linguagem AOT competir com um JIT porque as abordagens são diferentes:
AOT: Passei uma vez pelo código e o binário vai ser justamente isso aqui x. JIT: Tive que passar várias vezes pelo mesmo trecho de código então acho que posso otimizar isso aqui x.
Muito interessante. Por que vc acha que o GraalVM ficou mais lento? Vc poderia postar os resultados com o GraalVM native-image?
Legal, gostei da sua postagem, até porque provavelmente eu nunca veria isso sem ela.
Primeiro, uma coisa que todo mundo precisa saber que nem todo estudo prova alguma coisa. Na verdade poucos possuem provas, a maioria especulam com alguma fundamentação (alguns nem isso), alguns são bem mal conduzidos, nunca validados e até mesmo é comum serem contestados por outro estudo. Fotra quando apenas o foco é diferente ou o critério não é o mesmo.
Vou dar um exemplo dos estudos que estabelecem o limite de pessoas que o planeta suporta. Os que mais gosto são os que falam em 4 bilhões de pessoas dentro da economia atual (se bem que isso não muda há anos, e não acho que tenha tanta credibilidade assim) e outros que diz que é 1.5 bilhão usando o critério de todas as pessoas terem consumo uniforme nivelado pelos países desenvolvidos. Tem estudo que fala em trilhões de pessoas, o critério deve ser quantas cabem em pé na superfície terrestre.
Estudo sério tem uma forma bastante rígida de ser realizado e publicado. E só passa ter um valor maior quando é publicado por algum veículo científico. Hoje em dia muiutos desses veículos são apenas meios comerciais para dar valor para estudos extremamente fracos, criou-se um mercado para atender os pesquisadores que precisam de uma validação que antes já era corporativista e agora é meramente quuem pode pagar. Tem publicações muito sérias também.
Em gerl nós não acessamos estudos mais sérios, eles chatos, difíceis de entender se não por um pesquisador na área (inclusive muitas validações para publicação são feitas por quem não entende do que está sendo tratado ali).
Claro que podemos ter bons estudos sem toda essa pompa também. Não estou invalidando o estudo que o cara fez, até porque eu precisaria de bastante tempo para isso. Só estou colocando as coisas no lugar para os deslumbrados não teimosos que não tem mais solução entenderem o contexto, até porque tudo depende de contexto.
Dito isso, um JIT sempre tem potencial de conseguir executar algo melhor que um AOT porque ele pode avaliar a execução e fazer otimizações que só fazem sentido para aquele padrão que está ocorrendo isso. Isso não quer dizer que é fácil fazer isso, que compense sempre, e que em alguns casos a análise e regeração do código otmizado pode matar o ganho obtido ou até piorar o desempenho.
Os casos em que o ganho costuma ser frequente porque o padrão se repete é possível ter as mesmas otimizações com AOT se usar PGO (Profile Guided Optimization). E é possível até mais de uma versão de código para padrões diferentes que podem ocorrer, ainda que isso vai engordar seu executável e ter um custo para escolher qual usar. Isso não costuma ser usado porque normalmente não se quer esse preço quando se usa AOT.
Se você conseguisse comparar um AOT e um JIT em condições absolutamente iguais, na esmagadora maioria dos casos o AOT te daria um resultado melhor porque seria comum que as execuções seriam sempre dentro do mesmo padrão. Quando você compara um compilador JIT com um AOT completamente diferente, é o mesmo que comparar um JIT com outros JIT completamente diferente, a chance maior é que a diferença de implementação é que esteja fazendo diferença e não o modelo de compilação.
Por isso estudos precisam ser lidos com cuidado, assim o ovo pode fazer bem ou mal para sua saúde e ambos estão certos. A vida não é o manequismo que cada vez mais as pessoas desejam que exista. Não existem respostas tão absolutas quanto as pessoas querem, por isso que alguns brincam que a senioridade é medida de acordo com a quantidade de "dependes" que ele fala. Se quando você faz o estudo se errar uma vírgula na definição de critério o estudo já pode estar inteiro furado.
Olhando por cima esse estudo mostra uma compração de uma situação encontrada. Não vou questionar se está certo ou errado porque eu teria muito trabalho para fazer isso de forma adequada, correria o risco de errar também, mas quero ressaltar que ele mostra só o que está aí, qualquer especulação que o JITando já consegue ser melhor que Ahead Of Time não pode ser feita.
Por isso que eu semrpe falo que linguagem não possui velocidade. No máximo a implmentação possui. Claro que algumas características colocadas na sua específicação podem facilitar ou dificultar ter perfroamnce, mas é possível ter mecanismos que melhorem o resultado ou que piore muito sem necessidade. E você pode fazer um teste que uma implementação de uma linguagem ganha de outra implmentação da mesma linguagem ou de outra linguagem e uma mudança mínima no código pode dar o resultado contrário. Mais ainda, em uma nova versão da implementação o resultado pode ser outro.
Eu acho ótimo que o JITters estejam melhorando, até porque ele sempre vão puxar o AOT+PGO para cima junto e todos saem ganhando.
Um dos grandes problemas dos compuladores Just In Time é que ele engordam o executável e gastam mais energia para executar, nem sempre você quer isso. Mas ele pode trazer benefícios que enxugam o código total e economizar energia ou outros recursos.
Em tempos de uso mobile, IoT, serverless e outras coisas pagas pelo uso, e até a IA, escolher o melhor modo de compilação, e a tecnologia mais adequada, sempre que possível, é outra skill que o bom programador deve ter.
Em se tratando de linguagens diferentes pode ser que para obter o melhor resultado precise fazer um código diferenet na outra linguagem. Quase sempre o código já é um pouco difeente, então a comparação não é exatamente banans com bananas, nem mesmo banana prata com banana prata. O Clang pode ser assim, mas o GCC ou VSC pode ser de outro jeito, ou ainda a próxima versão do Clang pode dar um resultado diferente, até mesmo pode ser uma versão mais antiga que dava e hoje não dá mais, embora seja menos provável.
Equivalência de código pode ser ótmo para comparar algo, mas pode ser justamente o que destrói o resultado.
Me lembro de um caso de muitos anos atrás que um funcionário da Microsoft fez uma versão de um software de um dos maiores figurões da empresa, bem conhecido em que ele portou para C# o que era original em C++. Na época o JITter do .NET era ruim. E o código em C# ficou mais rápido. O "japonês" figurão da Microsoft começou mexer no dele em C++, introduziu bugs no processo e teve um trabalhaão para chegar em uma versão (a sexta) que batesse o C# de forma definitiva. Vou contar essa história em detalhes no meu canal que estreará em breve.
É quase certo que sempre você conseguirá obter um resultado melhor em C++ do que em Java ou C#, aina mais no estado que eles estão hoje que ainda tem muito potencial para melhor (até C++ ainda tem) nas otimizações. Mas o trabalho será cada vez maior para conseguir.
Você já viu como a STL é implementadas nas bibliotecas dos diversos compiladores? Você não consegue entender nada do código, é algo de uma complexidade absurda. Por que alguém faria isso? Para obter a melhor performance. Vale o esforço porque o mundo todo se beneficiará dele.
Para te ajudar bastante teria que fazer uma análise microscópica, analisar o código final gerado que realmente executa para descobrir o que aconteceu. Mas já posso dizer que a compração ficaria mais justa se fizer um PGO (coisa que nunca fiz em C++).
Farei algo que muitos pedem para aprender a programar corretamente, gratuitamente (não vendo nada, é retribuição na minha aposentadoria) (links aqui no perfil também).
Vi essa discussão há uns 20 anos atrás, e é curioso como esses assuntos acabam voltando. A conclusão da época foi: código manso por código manso, JIT quase sempre se sai melhor. O ponto é que escolhemos C/C++ quando cabe no projeto explorar mais truques do chapeu para otimizacão, ou seja código brabo). Os truques incluem metaprogramação de templates, criação de memory pools, paralelização, incluir códigos em assembly. Ou seja, da muito mais trabalho. Um exemplo clássico é o código das implmentações do memcpy em produção, mega complexos e cheios de assembly.
Dessa forma é um grande error optar por C++ esperando performance e não incluir esse tipo de trabalho no escopo do projeto.
Indo direto ao ponto: o motivo do jit ser mais rápido é por que ele esta alocando memoria de forma muito mais eficiente, do que fazer um malloc/free por elemento. Que são operacões extramamente lentas!!!
Se estiver curioso em entender o que esta acontecendo, execute ambos os executaveis com o dtrace por exemplo ou com outra instrumentação para ver as syscalls sendo feitas. Quase sempre isso é o fator determinante!