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:

  1. Se seu programa precisa ser leve com pouco gasto de RAM e Memória: AOT
  2. 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:

  1. Apesar do aumento de performance, o JIT vai consumir mais cpu e memória para otimizar o seu código em execução

  2. 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.

> 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. Valeu pelas dicas, vou assistir. Eu vejo o trade-off assim: AOT: Não vai perder tempo durante o run-time compilando nada. Não precisa esquentar (warmup). JIT: Vai perder tempo durante o run-time compilando os hot spots. Vai precisar de um tempo de warmup para peak performance. Só que o JIT pode fazer otimizações muito mais agressivas que o AOT. Acho que o pessoal sempre achou que AOT com -O3 era a coisa mais rápida do mundo. Pelo testes que eu venho fazendo aí parece que perde muitas vezes para o JIT. Runtime information ajuda muito nas otimizações mais agressivas, principalmente no inlining. Sem saber os hotspots, o AOT vai acabar deixando de fazer inlining nas coisas mais importantes. Uma idéia que pensei aqui: por que os compiiladores de C++ não possuem alguma diretriz para forçar inlining de algum método no matter what? Ou tem isso e eu desconheço? O que se faz é usar PGO (profiling information) that can be fed into the AOT compiler. Tanto o AOT quanto o JIT podem ter isso. Mas daí o JIT leva muita vantagem, porque se os HotSpots mudarem em tempo de execução ele pode se re-adaptar, ou seja, recompilar os novos hot spots. AOT não existe depois que o código começa a executar. Na minha cabeça (posso estar errado) o problema é que se o código muda, daí vc tem que refazer o PGO para refletir o novo código. Se o código muda pouco no problem. Se muda muito aí complica. Agora para concluir, minhas pesquisas com o PGO do GraalVM e da ZingVM (ReadyNow) mostraram uma melhorar de no máximo 50%. Ou seja, não chega perto do JIT em tempo de execução não. É uma ajuda, mas não é a solução para não precisar de JIT. NOTE: PGO = Profile-guided optimizations
> por que os compiiladores de C++ não possuem alguma diretriz para forçar inlining de algum método no matter what? Forçar inline indiscriminadamente é impacta no cache de instruções e o aumento do tamanho do binário, pode acabar poluindo o cache de instruções (https://isocpp.org/wiki/faq/inline-functions#inline-and-perf). Sobre os hotspots mudarem durante a execução, isso não costuma ser uma preocupação no contexto de C++, porque ela é geralmente escolhida para cenários onde sabemos que estamos lidando com rotinas CPU-bound e grandes volumes de dados. Nesses cenários, mesmo que surjam novas features, times experientes em C++ geralmente já estão espertos para identificar isso.
> Forçar inline indiscriminadamente é impacta no cache de instruções e o aumento do tamanho do binário, pode acabar poluindo o cache de instruções Sim, claro. Não estava sugerindo fazer inline de tudo. Isso obviamente é impraticável e é exatamente por isso que o JIT acaba levando vantagem. Minha sugestão foi: *O AOT não tem como saber os hot spots. O JIT tem, mas gasta tempo de runtime para descborir. Já o programador possui esse conhecimento, até melhor que o AOT e o JIT. Então ele (o programador) pegaria aqueles métodos que ele sabe que são quentes (hot) e anotaria eles com um `@Inline` para forçar que esses métodos, independente do tamanho deles, fossem inlined.* Por que o compilador de C++ ou Java não oferecem isso?

Muito interessante. Por que vc acha que o GraalVM ficou mais lento? Vc poderia postar os resultados com o GraalVM native-image?

Não sei porque ficou mais lento. De novo AOT não consegue fazer milagres. Não testei com PGO, mas mesmo assim acredito que ficaria mais lento. Assim que tiver um tempo vou testar GraalVM com PGO. ``` $ native-image --gc=G1 -R:+AlwaysPreTouch -R:InitialHeapSize=4g -R:MaxHeapSize=4g \ -R:InitialHeapSize=512m -R:MaxHeapSize=1024m -march=native \ -cp target/coralbench-all.jar com.coralblocks.coralbench.example.IntMapBenchmark \ -o target/graal/IntMapBenchmark --no-fallback -O3 --initialize-at-build-time $ ./target/graal/IntMapBenchmark 0 10000000 5000000 Arguments: warmup=0 measurements=10000000 mapCapacity=5000000 Benchmarking put... Measurements: 10,000,000 | Warm-Up: 0 | Iterations: 10,000,000 Avg Time: 41.110 nanos | Min Time: 25.000 nanos | Max Time: 21.841 millis 75% = [avg: 29.000 nanos, max: 31.000 nanos] 90% = [avg: 29.000 nanos, max: 32.000 nanos] 99% = [avg: 30.000 nanos, max: 41.000 nanos] 99.9% = [avg: 30.000 nanos, max: 100.000 nanos] 99.99% = [avg: 30.000 nanos, max: 339.000 nanos] 99.999% = [avg: 30.000 nanos, max: 1.573 micros] Benchmarking get... Measurements: 10,000,000 | Warm-Up: 0 | Iterations: 10,000,000 Avg Time: 24.560 nanos | Min Time: 19.000 nanos | Max Time: 15.679 micros 75% = [avg: 22.000 nanos, max: 25.000 nanos] 90% = [avg: 23.000 nanos, max: 26.000 nanos] 99% = [avg: 23.000 nanos, max: 39.000 nanos] 99.9% = [avg: 24.000 nanos, max: 97.000 nanos] 99.99% = [avg: 24.000 nanos, max: 382.000 nanos] 99.999% = [avg: 24.000 nanos, max: 550.000 nanos] Benchmarking remove... Measurements: 10,000,000 | Warm-Up: 0 | Iterations: 10,000,000 Avg Time: 35.800 nanos | Min Time: 23.000 nanos | Max Time: 165.095 micros 75% = [avg: 30.000 nanos, max: 33.000 nanos] 90% = [avg: 30.000 nanos, max: 36.000 nanos] 99% = [avg: 31.000 nanos, max: 93.000 nanos] 99.9% = [avg: 32.000 nanos, max: 123.000 nanos] 99.99% = [avg: 32.000 nanos, max: 457.000 nanos] 99.999% = [avg: 34.000 nanos, max: 63.089 micros] ```

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).

> É 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. O trabalho demorou menos de um dia. O código está todo disponível no GitHub. Qualquer um pode baixar e rodar na sua máquina. Fazer os seus testes. Tirar as suas próprias conclusões. Refutar ou aceitar os resultados obtidos, com argumentos e provas técnicas. Ou seja, seria legal *matar a cobra e mostrar o pau*, caso contrário estamos apenas tendo uma conversa de bar :) Ou seja, afirmar que C++ é mais rápido e pronto, e o que aconteceu aí foi uma quebra das leis da física ou um evento paranormal não vai ajudar na pesquisa nem no aprendizado :) Pode ter sido um erro/imprecisão do benchmarking? Pode. E estamos atrás desse erro ou dessa imprecisão. Assim como estamos atrás dos alienígenas. Brincadeiras a parte, nós fizemos os testes e colocamos eles disponíveis. Se uma IntMap em C++ pode ser mais rápida que uma IntMap em Java, então **gostaríamos de ver isso na prática e não na teoria**. Há poucos dias atrás houve um tópico parecido que viralizou. O nosso amigo Pedro Pessoa, que é um cara muito inteligente, fez um video excelente sobre isso aqui => https://www.youtube.com/watch?v=YI0k6bGRuzk Gostaria sinceramente de entender o porquê desse código em C++ ser mais lento que o mesmo código em Java. Seria JIT melhor que AOT em muitos casos como esse? Ou dá para fazer alguma coisa no compilador de C++ para otimizar esse código? Tem um discussão rolando em outro forum onde levantaram a questão de como o C++ está alocando a memória no heap. E que ele não faz isso de uma maneira tão eficiente quanto a JVM. Eu realmente ficaria surpreso caso não houvesse uma maneira de fazer esse código simples de C++ performar no mesmo nível que o código Java. **Até porque no final, tudo vai para assembly.**

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!

Boa dica! Uma maneira de provar/desprovar isso seria pre-allocar um monte de `Entry` objects e rodar o benchmark dos `puts` com os objetos já pre-allocados pelo C++. Concorda? Agora isso explicaria a vantagem do `put`, mas não explica a vantagem do `get` e do `remove`. Concorda? E o mais importante: não tem jeito de resolver essa ineficiência de C++ na alocação de memória no heap? A HotSpot JVM vai sempre levar vantagem nisso? E aquela história que o C++ te dá controle total de tudo, etc e tals? Estou perguntando na boa. Quero entender as vantagens e desvantagens de cada linguagem (C++ e Java) e cada approach (AOT e JIT).
> Boa dica! Uma maneira de provar/desprovar isso seria pre-allocar um monte de Entry objects e rodar o benchmark dos puts com os objetos já pre-allocados pelo C++. Concorda? Teste e compartilhe os resultados. Acredito que pre-alocar todos os objetos em memória estática resultaria em uma melhora de desempenho de várias ordens de grandeza. > Agora isso explicaria a vantagem do put, mas não explica a vantagem do get e do remove. Concorda? A explicação para a vantagem do JIT em todas as operações reside na redução da fragmentação de memória, que leva ao uso eficiente do cache. > E aquela história que o C++ te dá controle total de tudo, etc e tals? Exatamente. A otimização da alocação de memória é uma responsabilidade do programador. Isso envolve técnicas como alocação em blocos, uso de pools de memória e até mesmo a implementação de alocadores personalizados. O compilador, neste caso, não vai oferecer otimizações significativas.
> Exatamente. A otimização da alocação de memória é uma responsabilidade do programador. Isso envolve técnicas como alocação em blocos, uso de pools de memória e até mesmo a implementação de alocadores personalizados. O compilador, neste caso, não vai oferecer otimizações significativas. Entendi. Então possível é, só exige uma eforço e uma complexidade muito maior para conseguir algo que a HotSpot JVM/JIT te dá de mão beijada. Prealocar tudo no pool (note no código que os `Entries` ficam numa linked-list que é um pool) antes de começar a executar é inconveniente. Até porque muitas vezes vc não vai saber a quantidade média/máxima com antecedência. Agora fiquei curioso/interessado em MELHORAR esse código. Em consertá-lo para ele ser tão performático quanto a versão em Java. Isso é possível ou é melhor escrever isso em assembly? :P Vc mencionou **alocação em blocos e até mesmo a implementação de alocadores personalizados.** Isso é possível de se fazer para tornar esse código mais rápido? Engraçado que as pessoas abrem a boca para falar que C++ é a coisa mais performática do mundo, blah, blah, blah. Se vc é DEUS talvez ele na sua mão seja bem performático mesmo. Se para implementar uma simples hash table você precisa pensar em 10 complexidades extras que com o Java vc não precisa nem saber que existem, realmente eu me pergunto como grandes empresas mantem projetos de C++ grandes, que envolvem vários programadores, com qualidade. Talvez o KERNEL do Linux tenha dado certo, poque por muito tempo o único que meteu a mão ali foi o Linus. Abstração é muito importante. E Java te dá isso sobre C++ de uma maneira muito eficiente e performática. O JIT resolve muita coisa. Se vc quiser baixar o nível para "controlar melhor a performance" então vc deve ser um DEUS e nesse caso melhor partir direto para o assembly. A impressão que fica é que se você for apenas um bom/ótimo programador, o resultado final não vai ficar bom se comparado como poderia ter ficado com uma linguagem mais de alto nível (e igualmente performática) como o Java. Não vai ficar bom tanto do ponto de vista da performance como do ponto de vista da organização (clareza do código, ausência de bugs, ausência de vulnerabilidades, etc). Não vamos nem entrar na questão de que C++ é o paraíso das vulnerabilidades e até o governo americano está querendo se livrar dele nos seus sistemas. Repare que no GitHub a gente também testou GraalVM compilando o Java para nativo ahead-of-time. Mas não fica mais rápido que a HotSpot JIT.
> Vc mencionou alocação em blocos e até mesmo a implementação de alocadores personalizados. Isso é possível de se fazer para tornar esse código mais rápido? Uma abordagem muito simples e ingênua seria, em vez de alocar cada entrada individualmente, alocar, digamos, 1000 de uma vez. Ou simplesmente alocar, digamos, 1 KB de memória, seria mais inteligente e alinhar o número de elementos para caber nesse tamanho de bloco predefinido. E se você estiver realmente preocupado com desempenho, poderia ajustar esse tamanho para preencher a cache L1 do seu processador. Se você quiser ficar mais sofisticado, consulte a [documentação do cppreference para o alocador polimórfico e etc](https://en.cppreference.com/w/cpp/named_req/Allocator). Ou se você quiser ir ainda mais fundo, estude a [documentação do Linux para brk](https://man7.org/linux/man-pages/man2/brk.2.html) e implemente seu próprio malloc com ele. Ou não. > Se vc quiser baixar o nível para "controlar melhor a performance" então vc deve ser um DEUS e nesse caso melhor partir direto para o assembly. Novamente, entender quais chamadas do sistema acontecem e quando é a primeira coisa a começar a entender e pensar sobre desempenho. Não, não precisa ser um deus. Mas tem que saber de sistemas operacionais e organização de computadores, no mínimo. Programar em assembly, é uma grande perda de tempo. Compiladores modernos são altamente otimizados e geralmente superam em muito capacidade de micro-otimização humana. No entanto, quando desempenho é crítico, uma prática essencial é examinar o código assembly gerado pelo compilador para identificar possíveis gargalos e oportunidades de otimização. Algumas vezes, o compilador pode falhar em gerar o código mais eficiente. Em muitos casos, alterar a construção da linguagem de alto nível é suficiente. Além disso, [temos as instruções mágicas (como as vetorias) de processadores atuais](https://www.intel.com/content/www/us/en/products/docs/accelerator-engines/what-is-intel-avx-512.html). Compiladores são bastante inteligentes e frequentemente otimizam o código para usar essas instruções sem intervenção explícita do programador, mas, em muitos casos, é necessário utilizá-las diretamente em código C/C++ de forma inline. Um abraço e bons estudos!
> E se você estiver realmente preocupado com desempenho, poderia ajustar esse tamanho para preencher a cache L1 do seu processador. Com o Java também dá para fazer isso. Tenho [um exemplo usando o Java heap](https://github.com/coralblocks/CoralQueue/blob/8a95e7252267bc7393d921fd4893f5881874db79/src/main/java/com/coralblocks/coralqueue/util/PaddedAtomicLong.java#L21) e outro usando [memória nativa](https://github.com/coralblocks/CoralQueue/blob/8a95e7252267bc7393d921fd4893f5881874db79/src/main/java/com/coralblocks/coralqueue/util/PaddedAtomicLong.java#L21) via `sun.misc.Unsafe`. Não é super complexo quanto parece. Tudo que vc tem que fazer é espaçar as coisas para isolá-las dentro de um cache line do CPU, de forma que outras coisas não te expulsem do cache sem que vc precisasse ter saído do cache. Para isso vc pode usar padding. Acho que o disruptor foi o primeiro que fez isso com Java, mas posso estar errado.