Alto Desempenho, Regras que Sobrevivem à Medição
por Frank de Alcantara em 09/07/2026
Em março de 2013, Andrei Alexandrescu publicou no blog de engenharia do Facebook a transcrição de uma palestra com três dicas de otimização para C++. Três. Não trinta, não “dez truques que os compiladores odeiam”. Três: meça antes de acreditar, reduza a força das operações e minimize escritas na memória. O artigo envelheceu melhor que a maioria dos frameworks lançados naquele ano, o que diz algo sobre frameworks e algo sobre fundamentos.
A parte mais citada nem é uma das dicas. É a observação de que as CPUs modernas abandonaram o modelo determinístico, um ciclo por operação, memória sem espera, em troca de desempenho estatístico: caches profundos, pipelines longos, execução especulativa, renomeação de registradores. O processador ficou mais rápido na média e mais imprevisível no caso particular. A consequência prática é desconfortável: a sua intuição sobre o que é rápido está, com alta probabilidade, errada. A dele também estava. Por isso ele media.
Alexandrescu resume com uma frase que empresta de Walter Bright: measuring gives you a leg up on experts who are too good to measure. Medir coloca você à frente dos especialistas bons demais para medir. Guarde essa frase. Ela é o rodapé implícito de tudo que vem a seguir.
Eu juntei as dicas de Alexandrescu de 2013, com um pouco de experiência, muitas horas de pesquisa e acrescentei um pouco do livro de programação competitiva e alto desempenho que estou escrevendo em um artigo. Este artigo. Mas, este trabalho não substitui o livro. Nem complementa. No máximo, chama atenção, todas as dicas são úteis, algumas são indispensáveis. Mas, o tratamento completo, só no livro que virá, em inglês.
Este artigo é um catálogo de regras para escrever C++23 rápido sem sacrificar a correção, coisa que treze anos de linguagem depois de 2013 tornaram mais fácil e, ao mesmo tempo, mais cheia de armadilhas novas. As regras estão agrupadas por tema: tipos, compile time, abstrações, namespaces, memória, loops e algoritmos. Nenhuma delas substitui a medição. Todas elas reduzem o espaço de busca, que é exatamente o que Alexandrescu prometia: não garantias, boas heurísticas para explorar um espaço de otimização grande demais.
Tipos São Parte do Algoritmo
A primeira família de regras trata de algo que costuma ser decidido no piloto automático: os tipos numéricos. Decidir tipo no piloto automático funciona até o dia em que não funciona, e nesse dia o programa não avisa. Ele apenas devolve um número errado com toda a confiança do mundo. Em silêncio, com cara de paisagem.
Use using, não #define, para aliases de tipo
O pré-processador não sabe o que é um tipo. Pensa em um bicho burro. Apesar disso, ele sabe substituir texto, e substitui texto com o entusiasmo de quem não vai ficar para ver o resultado. using, por outro lado, cria um alias que o compilador entende, respeita escopo, aparece em mensagens de erro e funciona com templates:
#define Int long long // substituição de texto, sem escopo
using i64 = long long; // um tipo de verdade
template <typename T>
using Vec = std::vector<T>; // alias template: impossível com #define
O #define sobrevive em C++ moderno para exatamente duas coisas: guardas de inclusão, quando #pragma once não serve, e compilação condicional.
Mantenha aliases poucos e claros
Aliases como i64, Vi para std::vector<int> ou Vec<T> pagam o próprio aluguel: encurtam código que aparece o tempo todo sem esconder o que ele é. O problema começa quando o arquivo abre com quarenta aliases e o leitor precisa de uma versão moderna da pedra de Roseta para descobrir que umap_pii_vll significa alguma coisa. Coisa que só o autor sabe.
Use tipos largos onde o valor acumulado pode exceder int
Um int de 32 bits guarda até pouco mais de $2{,}1 \times 10^9$. Parece muito até você somar $10^6$ valores de $10^4$ cada, o que dá $10^{10}$, e descobrir que a soma não coube. Overflow de inteiro com sinal é Undefined Behavior: o programa não tem obrigação de avisar, de fazer wrap-around, nem de fazer a mesma coisa duas vezes seguidas.
A regra é aritmética de guardanapo: estime o maior valor que a variável pode assumir, some, multiplique, acumule, e escolha o tipo pelo pior caso, não pelo caso do exemplo que você testou.
Evite auto soma = 0 quando o tipo numérico é parte do algoritmo
O auto deduz int de um literal 0, e deduz sem remorso:
std::vector<int> precos(2'000'000, 2'000);
auto soma = 0; // soma é int. O algoritmo precisava de i64
for (const auto& p : precos) soma += p; // overflow silencioso
auto correta = 0LL; // agora o tipo está no literal
Quando o tipo do acumulador é uma decisão do algoritmo, e em somas, produtos e custos ele quase sempre é, escreva a decisão. auto é para quando o tipo é óbvio ou irrelevante, não para quando ele é a diferença entre a resposta certa e um número aleatório com cara de resposta.
Use sufixos corretos e o separador de dígitos
C++ tem sufixos de literal para exatamente esse problema: LL para long long, e o C++23 adicionou uz para std::size_t e z para o correspondente com sinal. Junto com o separador ', números longos ficam legíveis e com o tipo certo:
constexpr auto MOD = 1'000'000'007LL; // legível e long long
for (auto i = 0uz; i < v.size(); ++i) // i é std::size_t, sem warning
processa(v[i]);
O separador ' não muda nada para o compilador e muda tudo para o revisor: 1000000000000 e 100000000000 são gêmeos idênticos até o dia do incidente em produção. 1'000'000'000'000 e 100'000'000'000 não são.
Promova operandos antes da operação, não depois
Este é o erro clássico de quem conhece a regra dos tipos largos mas aplica tarde demais:
int a = 2'000'000'000, b = 3;
long long errado = a * b; // multiplica em int, overflow, UB,
// e só então converte o cadáver
long long certo = static_cast<long long>(a) * b; // promove, depois multiplica
A atribuição a long long não protege nada: a expressão do lado direito é avaliada nos tipos dos operandos. O mesmo raciocínio vale para subtrações entre unsigned, que dão a volta no zero com entusiasmo, e para std::abs(INT_MIN), que é UB porque $ |
{-2^{31}} | $ não cabe em 32 bits com sinal. Promova o operando antes da operação perigosa. |
O Que o Compilador Resolve Antes do Programa Existir
Alexandrescu recomendava reduzir a força das operações em tempo de execução. A versão moderna e mais radical dessa dica é reduzir a força para zero: fazer a conta em tempo de compilação.
Use constexpr para constantes e tabelas fixas
Uma tabela que não depende da entrada não tem motivo para ser calculada toda vez que o programa roda:
constexpr auto POW10 = [] {
std::array<long long, 19> p{};
p[0] = 1;
for (auto i = 1uz; i < p.size(); ++i) p[i] = p[i - 1] * 10;
return p;
}();
Uma lambda imediatamente invocada dentro de um constexpr monta a tabela em compile time. Em tempo de execução ela é só memória inicializada, custo zero, e ainda por cima const, o que dá ao otimizador liberdade para fazer o que otimizadores fazem quando ninguém pode alterar os dados.
Use static_assert para contratos compiláveis
Se uma propriedade pode ser verificada em compile time, verifique em compile time. O exemplo de Alexandrescu, digits10, aquela cascata de comparações contra potências de 10 que substitui divisões por comparações, aceita a versão moderna do contrato:
constexpr std::uint32_t digits10(std::uint64_t v) {
std::uint32_t r = 1;
for (;;) {
if (v < 10) return r;
if (v < 100) return r + 1;
if (v < 1'000) return r + 2;
if (v < 10'000) return r + 3;
v /= 10'000;
r += 4;
}
}
static_assert(digits10(9) == 1);
static_assert(digits10(1'000'000) == 7);
static_assert(digits10(std::numeric_limits<std::uint64_t>::max()) == 20);
Esses static_assert são testes que rodam em toda compilação, não custam nada em runtime e transformam uma regressão em erro de compilação. Um teste que impede o binário de existir é o único teste que ninguém esquece de rodar.
Não exagere no constexpr
Como toda ferramenta boa, constexpr tem uma zona de abuso. Três sinais de que você passou do ponto: a tabela é grande o suficiente para inflar o binário e expulsar dados úteis do cache, o valor depende da entrada, caso em que constexpr é simplesmente mentira, ou o tempo de compilação do projeto dobrou porque alguém decidiu pré-computar meio milhão de valores a cada make. Uma tabela de $10^7$ entradas calculada em 20 ms no início do main é, com frequência, a decisão de engenharia mais sensata do arquivo. Compile time não é um fim moral. É uma ferramenta com fatura.
Prefira <bit> para operações de bits
Antes do C++20, operações de bits eram um festival de intrinsics não portáveis e expressões com >> e máscaras que cada um escrevia de um jeito. O header <bit> padronizou o que o hardware já sabia fazer: std::popcount, std::countl_zero, std::countr_zero, std::bit_ceil, std::has_single_bit. Todas operam sobre unsigned, e a conversão deve ser deliberada, não acidental:
#include <bit>
int msb(long long x) { // posição do bit mais significativo
auto u = static_cast<unsigned long long>(x); // conversão visível
return 63 - std::countl_zero(u);
}
O static_cast explícito documenta que você sabe que está mudando de domínio, com as regras de wrap-around do domínio novo. Bits são unsigned por natureza; inteiros com sinal são uma interpretação. Não misture os dois sem assinar o termo de responsabilidade.
Abstrações que Não Cobram Pedágio
C++ vende abstração de custo zero desde os anos noventa. A propaganda é verdadeira com uma condição contratual em letras miúdas: o custo é zero quando a abstração é resolvida em compile time. Quando ela sobrevive até o runtime, o pedágio existe, alguém paga e a produção fica mais lenta.
Templates só quando removem duplicação real
Um template se justifica em quatro situações: remove duplicação real de código, preserva inlining onde uma alternativa com ponteiro de função o mataria, expressa um fato conhecido em compile time, como um tamanho ou uma política, ou rejeita tipos errados antes de o programa existir. Fora dessas quatro, um template é generalidade especulativa: você paga em tempo de compilação, em mensagens de erro e em legibilidade por uma flexibilidade que nenhum chamador pediu. Generalizar para dois casos de uso reais é engenharia. Generalizar para casos de uso imaginários é ficção científica com instanciação.
Constranja templates com conceitos
Quando o template se justifica, o C++20 deu a ele um contrato legível:
template <std::integral T>
constexpr T ceil_div(T a, T b) {
return (a + b - 1) / b;
}
O std::integral documenta a pré-condição no único lugar onde documentação não desatualiza: a assinatura. E converte aquele erro de template de quatrocentas linhas, que começa em ceil_div e termina nas entranhas de um header que você nunca abriu, em uma frase: o tipo não satisfaz o conceito. Conceitos não deixam o código mais rápido. Deixam o fracasso mais rápido, o que na prática economiza mais tempo.
Lambdas locais pertencem ao algoritmo
Quando uma operação só faz sentido dentro de um algoritmo, uma lambda local mantém a operação onde ela mora, com acesso ao contexto e visível para o inliner. Os dois cuidados são de lifetime: capturar por referência algo que morre antes da lambda é UB clássico, e capturar this em uma lambda que escapa do objeto é o mesmo erro de terno e gravata.
Evite std::function em recursão local
O idioma antigo para lambda recursiva era std::function:
std::function<void(int, int)> dfs = [&](int u, int parent) {
for (int v : adj[u])
if (v != parent) dfs(v, u);
};
Funciona, e cobra: std::function é type erasure, o que significa possível alocação no heap, despacho indireto em cada chamada e um muro de tijolos na frente do inliner, tudo isso dentro da parte mais quente de um algoritmo recursivo. O C++23 aposentou o idioma com o deducing this:
auto dfs = [&](this auto&& self, int u, int parent) -> void {
for (int v : adj[u])
if (v != parent) self(v, u);
};
dfs(0, -1);
Chamada direta, que pode sofrer inline, sem alocação. Se o seu compilador ainda não chegou ao C++23, uma struct local com operator() resolve o mesmo problema com sintaxe de 1998, o que é menos elegante e igualmente rápido.
Namespaces: Higiene Não É Pedantismo
Não use using namespace std;
Essa linha despeja algumas centenas de nomes no seu escopo, incluindo std::size, std::count, std::begin e outros substantivos que você certamente já usou como nome de variável. O resultado varia de erro de compilação confuso a, pior, resolução de overload silenciosa para uma função que você não sabia que estava chamando. Em um header, using namespace std; não é estilo, é vandalismo: contamina todo arquivo que incluir o seu.
Prefira aliases de namespace e using direcionado
Quando std::ranges:: a cada linha ficar verboso demais, as alternativas cirúrgicas existem:
namespace rng = std::ranges; // alias curto e explícito
void ordena(std::vector<int>& v) {
using std::swap; // direcionado, em escopo de função
rng::sort(v);
}
O alias reduz ruído sem esconder a origem dos nomes. O using local traz um nome, para um escopo, por um motivo, que é exatamente a granularidade em que importações deveriam acontecer.
Componentes reutilizáveis moram em namespace próprio
Se uma função vai ser usada em mais de um arquivo, ela merece um sobrenome. Um namespace próprio previne colisões, agrupa o que pertence junto e torna o ponto de uso auto-documentado: geo::cross(a, b) diz mais que cross(a, b), principalmente no dia em que o projeto ganhar um segundo cross.
Memória: Onde os Nanossegundos Moram
A segunda dica de Alexandrescu era minimizar escritas na memória, porque todo tráfego com a memória acontece na granularidade de linha de cache e escrever uma palavra custa ler a linha inteira e escrevê-la de volta. As regras desta seção são a versão estrutural da mesma ideia: organizar os dados para que a memória trabalhe a seu favor, porque contra ela você não ganha.
Reserve vector quando o tamanho final é previsível
Um std::vector que cresce de push_back em push_back realoca um número logarítmico de vezes, e cada realocação copia ou move tudo que já existia, o pior tipo de escrita em memória: a repetida. Se você sabe o tamanho final, ou uma cota superior razoável, diga:
std::vector<Resultado> saida;
saida.reserve(entradas.size()); // uma alocação, zero realocações
Realocação invalida tudo; recrie o que apontava para dentro
O corolário sombrio do crescimento do vector: qualquer realocação invalida todos os iteradores, referências, ponteiros e spans para os elementos. O código abaixo compila, roda e mente:
std::vector<int> v = {1, 2, 3};
int& primeiro = v.front();
v.push_back(4); // pode realocar
// primeiro agora pode apontar para a memória antiga. UB.
Depois de qualquer operação que possa realocar, recrie o que apontava para dentro. O bug resultante de não recriar é dos piores da categoria: intermitente, dependente do allocator e ausente em todas as máquinas exceto a do cliente.
std::array só quando o tamanho é conhecido em compile time
std::array<T, N> vive na stack, sem alocação, com o tamanho no tipo. É a escolha certa exatamente quando N é uma constante de verdade, uma dimensão fixa, uma tabela pequena. Usá-lo com um N superdimensionado “para caber o pior caso” troca uma alocação por um estouro de stack e por um tipo que mente sobre quantos elementos são válidos. Tamanho decidido em runtime é problema de vector.
std::span é parâmetro, não dono
std::span é a resposta moderna para “quero receber uma sequência contígua sem me importar de onde ela veio”. Um std::span<const T> aceita vector, array, array C e pedaços de qualquer um deles, sem cópia e sem template:
long long soma(std::span<const int> dados) {
return std::ranges::fold_left(dados, 0LL, std::plus{});
}
A palavra que governa span é view: ele enxerga memória de outra pessoa. Daí as duas proibições absolutas: nunca retorne um span para armazenamento local, que morre no fim da função e deixa o span apontando para um terreno baldio, e nunca mantenha um span sobre um vector através de uma operação que pode realocar. Um span não sabe que o dono se mudou. Ele continua visitando o endereço antigo.
Matrizes: vector<vector<T>>, vector<T> plano ou mdspan
Para dados bidimensionais existem três formas honestas. vector<vector<T>> é a mais clara e a menos contígua: cada linha é uma alocação separada, espalhada pelo heap, com o cache pagando a conta a cada troca de linha. Um vector<T> plano com indexação i * colunas + j coloca tudo em um bloco contíguo, e o C++23 trouxe std::mdspan para dar a essa memória plana uma interface multidimensional sem custo:
std::vector<double> dados(linhas * colunas);
auto m = std::mdspan(dados.data(), linhas, colunas);
m[i, j] = 1.0; // operator[] multidimensional, C++23
O critério de escolha: linhas de tamanhos diferentes pedem vector<vector<T>>; matriz retangular percorrida com frequência pede armazenamento plano; e mdspan entrega a legibilidade do primeiro com a localidade do segundo. Quando o desempenho importa, a resposta quase nunca é a primeira.
Percorra a matriz na ordem da memória
Em layout row-major, o padrão de C++, elementos da mesma linha são vizinhos na memória. Percorrer com a coluna no loop interno visita a memória em sequência e o prefetcher agradece; inverter os loops transforma cada acesso em um salto de colunas * sizeof(T) bytes e cada linha de cache carregada em desperdício quase total. A diferença medida costuma ficar entre 2x e 10x, dependendo do tamanho. São os mesmos loops, as mesmas contas, o mesmo big-O. A notação assintótica não enxerga o cache, mas o cache enxerga você. Esse você vai ter que pesquisar, ou aguardar o livro.
AoS ou SoA: decida pelo padrão de acesso dominante
Array of Structs mantém cada objeto inteiro e contíguo; Struct of Arrays mantém cada campo inteiro e contíguo. Se o código quente toca todos os campos de um objeto por vez, AoS ganha. Se ele varre um campo de todos os objetos, uma soma de preços, um filtro por coordenada, SoA carrega no cache apenas o campo relevante e abre a porta para vetorização. A escolha errada não quebra nada. Apenas paga aluguel de cache por dados que ninguém leu.
Loops que o Hardware Entende
const auto& para ler, auto& para mutar, cópia só quando deliberada
Em um range-based for sobre objetos não triviais, a forma da variável de loop é uma decisão de custo:
for (const auto& pedido : pedidos) total += pedido.valor; // lê, sem cópia
for (auto& pedido : pedidos) pedido.processado = true; // muta
for (auto pedido : copias_por_design) altera(pedido); // cópia proposital
for (auto x : v) copia cada elemento, e para um std::string de 200 bytes isso significa uma alocação por iteração dentro do loop quente. Quando a cópia é intencional, você quer mutar um rascunho local, escreva-a de propósito e, idealmente, comente. Cópia acidental é o imposto invisível favorito dos profilers.
Loops indexados quando o índice importa, sem misturar signed e unsigned
Quando o algoritmo precisa do índice, para acessar dois vetores paralelos, para olhar v[i-1], use um loop indexado, mas escolha um lado da guerra de sinais. v.size() retorna unsigned; um int i comparado com ele gera conversões silenciosas, e a expressão i < v.size() - 1 com vetor vazio compara contra $2^{64}-1$, que é um loop mais longo do que o planejado. As opções limpas:
for (auto i = 0uz; i < v.size(); ++i) // tudo unsigned, C++23
for (int i = 0; i < std::ssize(v); ++i) // tudo signed, C++20
Qualquer uma das duas. A mistura, nenhuma das vezes.
E quando o índice serve apenas para acompanhar o elemento, o C++23 dissolveu o dilema com std::views::enumerate, que entrega índice e elemento sem que você administre nenhum dos dois:
for (auto [i, x] : std::views::enumerate(v)) // i é o índice, x o elemento
std::println("{}: {}", i, x);
O índice de enumerate já nasce com tipo definido pela biblioteca e o loop não tem condição de parada para errar. Menos decisões, menos lugares para a guerra de sinais recomeçar.
Prefira algoritmos padrão e ranges para o vocabulário básico
Sort, busca binária, contagem, preenchimento, cópia, geração, mínimo e máximo têm nomes na biblioteca padrão, e os nomes carregam intenção, correção testada e otimizações que o seu loop manual reimplementaria com bugs:
std::ranges::sort(v);
auto pos = std::ranges::lower_bound(v, x);
auto quantos = std::ranges::count(v, alvo);
auto [mn, mx] = std::ranges::minmax(v);
std::ranges::sort(v) diz o que acontece. O loop equivalente diz como, e obriga o leitor a verificar se o como implementa mesmo o quê.
Use loop manual quando ele diz algo que o algoritmo não diz
A regra anterior tem a contrapartida honesta: quando o corpo mantém um invariante não trivial, atualiza múltiplos estados por iteração, tem early exit complexo ou precisa de widening explícito no acumulador, o loop manual é mais claro que uma composição de views que ninguém consegue depurar. Algoritmo padrão para vocabulário, loop manual para lógica. Forçar um nas atribuições do outro produz código que impressiona no code review e sabota no plantão.
Erase-unique, do jeito certo
Deduplicar um vetor é um idioma de duas etapas que muita gente executa pela metade:
std::ranges::sort(v);
auto [first, last] = std::ranges::unique(v); // compacta, não encolhe
v.erase(first, last); // agora encolhe
unique só remove duplicatas adjacentes, daí o sort antes, e não altera o tamanho do vetor: ele move os sobreviventes para o início e devolve a fronteira do lixo. Esquecer o erase deixa um sufixo de valores em estado válido porém não especificado no fim do vetor, esperando pacientemente pelo primeiro loop que o percorra.
Projeções eliminam arrays paralelos de chaves
O idioma antigo para ordenar por um campo era extrair chaves para um array paralelo ou escrever um comparador de três linhas. Ranges aceitam projeções:
std::ranges::sort(pessoas, {}, &Pessoa::idade);
auto it = std::ranges::max_element(pedidos, {}, &Pedido::valor);
Menos código, nenhuma estrutura auxiliar, e a chave da ordenação declarada no único lugar em que alguém vai procurá-la.
Em folds e reduções, o inicializador decide o tipo
O C++23 finalmente deu à redução uma interface de ranges: std::ranges::fold_left. Mas a regra que valia para o velho std::accumulate sobreviveu intacta à modernização: o tipo do acumulador é deduzido do valor inicial, não do container. É a regra do auto soma = 0 usando crachá de função da biblioteca padrão:
std::vector<int> v(2'000'000, 2'000);
auto errado = std::ranges::fold_left(v, 0, std::plus{}); // acumula em int
auto certo = std::ranges::fold_left(v, 0LL, std::plus{}); // acumula em long long
O mesmo cuidado vale para o std::accumulate que você vai continuar encontrando em código existente: accumulate(v.begin(), v.end(), 0) acumula em int, com 0LL acumula em long long. Dois caracteres separam a soma correta do overflow. A biblioteca padrão é poderosa, não paternal.
Algoritmos: Conte o Workflow Inteiro
unordered_map::reserve quando a cardinalidade justifica
Um unordered_map que cresce elemento a elemento faz rehash periodicamente, e cada rehash realoca buckets e redistribui tudo que já foi inserido. Se a contagem de frequências vai receber $10^6$ chaves distintas, freq.reserve(1'000'000) paga o custo uma vez em vez de amortizá-lo em prestações com juros. Para meia dúzia de chaves, o reserve é ruído cerimonial. Cardinalidade esperada é informação; use-a quando a tiver.
nth_element quando ordenar tudo é desperdício
Para a mediana, o percentil 99 ou os $k$ maiores, ordenar o vetor inteiro é comprar um boi para tomar um copo de leite:
std::ranges::nth_element(v, v.begin() + k);
// v[k] está na posição final; antes dele, só menores ou iguais
nth_element é $O(n)$ médio contra $O(n \log n)$ do sort, e a diferença aparece exatamente nos vetores grandes onde estatística de ordem costuma ser calculada. Se depois você precisar dos $k$ menores em ordem, ordene só o prefixo: continua mais barato.
Sort vs hash: a conta fecha no fim do workflow, não no meio
“Hash é $O(1)$, logo unordered_map ganha de sort” é o tipo de raciocínio que compara o primeiro quilômetro de duas rotas. E que eu vejo repetido, todos os dias, por alunos de outras disciplinas.
A pergunta certa cobre o workflow inteiro: a saída precisa ser ordenada? Então o sort que você evitou no meio reaparece no fim.
Os dados são pequenos? O fator constante do hash, alocação de buckets, ponteiros, misses de cache, supera o $\log n$ de um sort sobre memória contígua com folga. Um vector ordenado com busca binária derrota o hash map em uma quantidade constrangedora de benchmarks reais.
Lembre-se: O big-O de uma etapa não é o tempo do programa.
Execution policies: só após medição, só em iterações independentes
O C++17 deixou escrever std::sort(std::execution::par, ...) e o C++23 não revogou a tentação de espalhar par.
Um detalhe de compatibilidade antes das condições: execution policies vivem nos algoritmos clássicos baseados em iteradores; as versões de std::ranges não as aceitam no C++23.
Se a medição justificar paralelismo, é o std::sort de iteradores que recebe a policy, e essa é uma das raras situações em que voltar à interface antiga é a decisão moderna. As condições reais: as iterações precisam ser independentes, sem I/O, sem push_back em container compartilhado, sem ordem que importe, sem data race, e o trabalho precisa ser grande o bastante para amortizar o custo de coordenar threads, que não é pequeno.
Paralelizar um loop de mil elementos entrega toda a sincronização e nenhum ganho; paralelizar um loop com data race entrega UB em múltiplos núcleos, que é como UB normal, porém mais difícil de reproduzir. A ordem dos passos é fixa: medir, confirmar que a região domina o tempo, verificar independência, aplicar a policy e medir de novo. Paralelismo sem medição é otimismo, não engenharia.
O Rodapé de Tudo
Releia a lista e note o padrão: quase nenhuma regra é sobre truques. São tipos que aguentam os valores que o algoritmo produz, contas feitas em compile time quando a informação já existe, abstrações que o compilador dissolve, dados dispostos na ordem em que serão lidos e algoritmos escolhidos pela conta inteira do workflow. É a mesma tese do artigo de 2013, com treze anos a mais de biblioteca padrão: o hardware é estatístico, a intuição é um mapa desatualizado e as regras de bolso servem para podar o espaço de busca, não para substituir a busca.
A pergunta final diante de qualquer otimização continua sendo a que Alexandrescu fez em 2012 para uma plateia que achava que já sabia a resposta: você mediu? Se mediu, os números decidem. Se não mediu, você não está otimizando. Está torcendo.
(Updated: )