Olá! Eu sou Oleksandr Kaleniuk e sou viciado em C++. Escrevo em C++ há 17 anos e durante todos esses 17 anos venho tentando me livrar desse vício devastador.
Tudo começou em 2005 com um motor de simulador espacial 3D. O mecanismo tinha tudo o que o C++ tinha em 2005. Ponteiros de três estrelas, oito camadas de dependência e macros no estilo C em todos os lugares. Havia pedaços de montagem também. Iteradores estilo Stepanov e metacódigo estilo Alexandrescu. O código tinha tudo. Exceto, é claro, pela resposta à pergunta mais importante: por quê?
Em pouco tempo, até essa pergunta foi respondida. Não apenas como “para quê”, mas sim como “como assim”. Como se viu, o motor foi escrito por cerca de 8 anos por 5 equipes diferentes. E cada equipe trouxe sua moda favorita para o projeto, envolvendo o código antigo em invólucros com novos estilos, adicionando apenas cerca de 10 a 20 microcarmacks de funcionalidade ao fazê-lo.
No começo, eu estava honestamente tentando grocar cada coisinha. Não foi uma experiência gratificante, nem um pouco, e em algum momento desisti. Eu ainda estava fechando tarefas e corrigindo bugs. Não posso dizer que fui muito produtivo, mas o suficiente para não ser demitido. Mas então meu chefe me perguntou: “você quer reescrever parte do código do shader de Assembly para GLSG?” Eu pensei que Deus sabe como é esse GLSL, mas não poderia ser pior do que C ++ e disse que sim. Não foi pior.
E isso meio que se tornou um padrão. Eu ainda estava escrevendo principalmente em C ++, mas toda vez que alguém me perguntava “você quer fazer aquela coisa que não é C ++?” Eu tinha certeza!" e eu fiz aquela coisa, seja lá o que fosse. Escrevi em C89, MASM32, C#, PHP, Delphi, ActionScript, JavaScript, Erlang, Python, Haskell, D, Rust e até mesmo naquela linguagem de script InstallShield escandalosamente ruim. Escrevi em VisualBasic, em bash e em algumas linguagens proprietárias, das quais não posso nem falar legalmente. Eu mesmo fiz um por acidente. Criei um interpretador simples no estilo lisp para ajudar os designers de jogos a automatizar o carregamento de recursos e saí de férias. Quando voltei, eles estavam escrevendo todas as cenas do jogo neste intérprete, então tivemos que apoiá-lo até, pelo menos, o final do projeto.
Então, nos últimos 17 anos, eu estava honestamente tentando sair do C++, mas toda vez, depois de tentar uma coisa nova e brilhante, eu estava voltando. No entanto, acho que escrever em C++ é um mau hábito. É inseguro, não é tão eficaz quanto se pensa e desperdiça uma quantidade terrível da capacidade mental de um programador em coisas que nada têm a ver com a criação de software. Você sabia que no MSVC uint16_t(50000) + uin16_t(50000) == -1794967296
? Você sabe por quê? Sim, foi o que pensei.
Acredito que é responsabilidade moral dos programadores C++ de longa data desencorajar a geração mais jovem de fazer do C++ sua profissão, assim como é responsabilidade moral dos alcoólatras que não conseguem parar de alertar os jovens sobre o perigo.
Mas por que não posso desistir? Qual é o problema? A questão é que nenhuma das linguagens, especialmente as chamadas “matadoras de C++”, oferece qualquer vantagem real sobre C++ no mundo moderno. Todas essas novas linguagens são focadas principalmente em manter um programador sob controle para seu próprio bem. Isso é bom, exceto que escrever código bom com programadores ruins é um problema do século XX, quando os transistores dobravam a cada 18 meses e o número de programadores dobrava a cada 5 anos.
Estamos vivendo em 2023. Temos mais programadores experientes no mundo do que nunca na história. E precisamos de software eficiente agora mais do que nunca.
As coisas eram mais simples no século XX. Você tem uma ideia, envolve-a em alguma interface do usuário e a vende como um produto de desktop. É lento? Quem se importa! Em dezoito meses, os desktops se tornarão 2x mais rápidos de qualquer maneira. O que importa é entrar no mercado, começar a vender funcionalidades, e de preferência sem bugs. Nesse clima, claro, se um compilador evita que os programadores criem bugs – ótimo! Porque os bugs não trazem dinheiro, e você tem que pagar aos seus programadores se eles adicionam recursos ou bugs de qualquer maneira.
Agora as coisas são diferentes. Você tem uma ideia, envolve-a em um contêiner Docker e a executa em uma nuvem. Agora você obtém sua receita de pessoas que executam seu software, se isso resolver seus problemas. Mesmo que faça uma coisa, mas faça certo, você será pago. Você não precisa encher seu produto com recursos inventados apenas para vender uma nova versão dele. Por outro lado, quem paga pela ineficácia do seu código agora é você mesmo. Cada rotina abaixo do ideal é exibida em sua conta da AWS.
Portanto, no novo clima, você agora precisa de menos recursos, mas de melhor desempenho para o que tiver.
E de repente acontece que todos os “assassinos do C++”, mesmo aqueles que eu amo e respeito de todo o coração, como Rust, Julia e D, não abordam o problema do século XXI. Eles ainda estão presos no XX. Eles ajudam você a escrever mais recursos com menos bugs, mas não são de muita ajuda quando você precisa espremer o último flop do hardware alugado.
Eles simplesmente não oferecem uma vantagem competitiva sobre o C++. Ou, aliás, até mesmo um sobre o outro. A maioria deles, por exemplo, Rust, Julia e Cland até compartilham o mesmo back-end. Você não pode ganhar uma corrida de carros se todos compartilharem o mesmo carro.
Então, quais tecnologias oferecem uma vantagem competitiva sobre C++ ou, falando de maneira geral, todos os compiladores tradicionais de vanguarda? Boa pergunta. Que bom que você perguntou.
Mas antes de irmos para o próprio Spiral, vamos verificar o quão bem sua intuição funciona. O que você acha que é mais rápido: uma função de seno C++ padrão ou um modelo polinomial de 4 peças de um seno?
auto y = std::sin(x); // vs. y = -0.000182690409228785*x*x*x*x*x*x*x +0.00830460224186793*x*x*x*x*x -0.166651012143690*x*x*x +x;
Próxima questão. O que funciona mais rápido, usando operações lógicas com curto-circuito ou enganando um compilador para evitá-lo e calcular a expressão lógica em massa?
if (xs[i] == 1 && xs[i+1] == 1 && xs[i+2] == 1 && xs[i+3] == 1) // xs are bools stored as ints // vs. inline int sq(int x) { return x*x; } if(sq(xs[i] - 1) + sq(xs[i+1] - 1) + sq(xs[i+2] - 1) + sq(xs[i+3] - 1) == 0)
E mais um. O que classifica trigêmeos mais rapidamente: uma classificação por troca ou uma classificação por índice?
if(s[0] > s[1]) swap(s[0], s[1]); if(s[1] > s[2]) swap(s[1], s[2]); if(s[0] > s[1]) swap(s[0], s[1]); // vs. const auto a = s[0]; const auto b = s[1]; const auto c = s[2]; s[int(a > b) + int(a > c)] = a; s[int(b >= a) + int(b > c)] = b; s[int(c >= a) + int(c >= b)] = c;
Se você respondeu a todas as perguntas de forma decisiva e sem nem mesmo pensar ou pesquisar no Google, sua intuição falhou com você. Você não viu a armadilha. Nenhuma dessas perguntas tem uma resposta definitiva sem contexto.
Qual CPU ou GPU o código visa? Qual compilador deve construir o código? Quais otimizações do compilador estão ativadas e quais estão desativadas? Você só pode começar a prever quando souber tudo isso, ou melhor ainda, medindo o tempo de execução de cada solução específica.
Um modelo polinomial é 3 vezes mais rápido que o seno padrão se construído com clang 11 com -O2 -march=native e executado no Intel Core i7-9700F. Mas se construído com nvcc com --use-fast-math e na GPU GeForce GTX 1050 Ti Mobile , o seno padrão é 10 vezes mais rápido que o modelo.
Trocar lógica de curto-circuito por aritmética vetorizada também faz sentido no i7. Faz com que o snippet funcione duas vezes mais rápido. Mas no ARMv7 com o mesmo clang e -O2, a lógica padrão é 25% mais rápida que a micro-otimização.
E com index-sort vs. swap-sort, o index-sort é 3 vezes mais rápido na Intel e o swap-sort é 3 vezes mais rápido na GeForce.
Portanto, as queridas microotimizações que todos amamos tanto podem acelerar nosso código em um fator de 3 e desacelerá-lo em 90%. Tudo depende do contexto. Como seria maravilhoso se um compilador pudesse escolher a melhor alternativa para nós, por exemplo, o index-sort se transformaria milagrosamente em swap-sort quando mudamos o destino de construção. Mas não podia.
Mesmo se permitirmos que o compilador reimplemente o seno como um modelo polinomial, para trocar precisão por velocidade, ele ainda não saberá nossa precisão de destino. Em C++, não podemos dizer que “essa função pode ter esse erro”. Tudo o que temos são sinalizadores de compilador como “--use-fast-math” e apenas no escopo de uma unidade de tradução.
No segundo exemplo, o compilador não sabe que nossos valores estão limitados a 0 ou 1 e não pode propor a otimização que podemos. Provavelmente poderíamos ter sugerido isso usando um tipo bool adequado, mas isso teria sido um problema completamente diferente.
E no terceiro exemplo, os pedaços de código são muito diferentes para serem reconhecidos como sinônimos. Nós detalhamos demais o código. Se fosse apenas std::sort, isso já daria ao compilador mais liberdade para escolher o algoritmo. Mas ele não teria escolhido index-sort nem swap sort, pois ambos são ineficientes em matrizes grandes e std::sort funciona com um contêiner iterável genérico.
E é assim que chegamos ao Spiral . É um projeto conjunto da Carnegie Mellon University e Eidgenössische Technische Hochschule Zürich. TL&DR: especialistas em processamento de sinal se cansaram de reescrever manualmente seus algoritmos favoritos para cada nova peça de hardware e escreveram um programa que faz esse trabalho para eles. O programa obtém uma descrição de alto nível de um algoritmo e uma descrição detalhada da arquitetura de hardware e otimiza o código até que ele faça a implementação de algoritmo mais eficiente para o hardware especificado.
Uma distinção importante entre Fortran e similares, Spiral realmente resolve um problema de otimização no sentido matemático. Ele define o tempo de execução como uma função de destino e procura seu ótimo global no espaço fatorial de variantes de implementação limitadas pela arquitetura de hardware. Isso é algo que os compiladores nunca fazem.
Um compilador não procura o verdadeiro ótimo. Otimiza o código guiado pelas heurísticas ensinadas pelos programadores. Essencialmente, um compilador não funciona como uma máquina em busca da solução ideal, mas sim como um programador de montagem. Um bom compilador funciona como um bom programador de assembly, mas é só isso.
Spiral é um projeto de pesquisa. É limitado em escopo e orçamento. Mas os resultados que mostra já são impressionantes. Na transformação rápida de Fourier, sua solução supera as implementações MKL e FFTW decisivamente. O código deles é ~2x mais rápido. Mesmo na Intel.
Apenas para destacar a escala de conquistas, MKL é a Biblioteca de Kernel Matemático da própria Intel, portanto, dos caras que mais sabem como usar seu hardware. E WWTF AKA “Fastest Fourier Transform in the West” é uma biblioteca altamente especializada dos caras que conhecem melhor o algoritmo. Ambos são campeões no que fazem e o próprio fato de Spiral vencê-los duas vezes é surpreendente.
Quando a tecnologia de otimização usada pela Spiral for finalizada e comercializada, não apenas C++, mas Rust, Julia e até Fortran enfrentarão uma concorrência que nunca enfrentaram antes. Por que alguém escreveria em C++ se escrever em linguagem de descrição de algoritmo de alto nível torna seu código 2x mais rápido?
A melhor linguagem de programação é aquela que você já conhece bem. Por várias décadas seguidas, a linguagem mais conhecida para a maioria dos programadores tem sido C. Ela também lidera o índice TIOBE com outras semelhantes a C, ocupando o top 10. No entanto, apenas dois anos atrás, algo inédito aconteceu. O C deu seu primeiro lugar a outra coisa.
O “algo mais” parecia ser Python. Uma linguagem que ninguém levava a sério nos anos 90 porque era mais uma linguagem de script que já tínhamos de sobra.
Alguém dirá: “Bah, Python é lento”, e parecerá um tolo, pois isso é um absurdo terminológico. Assim como um acordeão ou uma frigideira, uma linguagem simplesmente não pode ser rápida ou lenta. Assim como a velocidade de uma sanfona depende de quem está tocando, a “velocidade” de uma linguagem depende da velocidade de seu compilador.
“Mas Python não é uma linguagem compilada” alguém pode continuar e errar novamente. Existem muitos compiladores Python e o mais promissor deles é, por sua vez, um script Python. Deixe-me explicar.
Uma vez tive um projeto. Uma simulação de impressão 3D que foi originalmente escrita em Python e depois reescrita em C++ “para desempenho” e depois portada para GPU, tudo isso antes de eu entrar. Tesla M60, já que era o mais barato da AWS no momento, e validando todas as alterações no código C++/CU para acompanhar o código original em Python. Então, fiz tudo, exceto as coisas em que normalmente me especializo, ou seja, criar algoritmos geométricos.
E quando eu finalmente tinha tudo funcionando, um estudante de Bremen em meio período me ligou e perguntou: “então você é bom em coisas heterogêneas, pode me ajudar a executar um algoritmo na GPU?” Claro! Contei a ele sobre CUDA, CMake, compilação, teste e otimização do Linux; passou talvez uma hora conversando. Ele ouviu tudo muito educadamente, mas no final disse: “Isso tudo é muito interessante, mas eu tenho uma pergunta muito específica. Então eu tenho uma função, escrevi @cuda.jit antes de sua definição, e o Python diz algo sobre arrays e não compila o kernel. Você sabe qual poderia ser o problema aqui?
eu não sabia. Ele descobriu sozinho em um dia. Aparentemente, Numba não funciona com listas Python nativas, ele só aceita dados em matrizes NumPy. Então ele descobriu e executou seu algoritmo na GPU. Em Python. Ele não teve nenhum dos problemas com os quais passei meses. Você quer no Linux? Não é um problema, basta executá-lo no Linux. Você quer que seja consistente com o código Python? Não é um problema, é código Python. Deseja otimizar para a plataforma de destino? Não é um problema novamente. O Numba otimizará o código para a plataforma em que você executa o código, pois ele não compila antes do tempo, ele compila sob demanda quando já implantado.
Isso não é incrível? Bem não. Não para mim de qualquer maneira. Passei meses com C++ resolvendo problemas que nunca ocorrem em Numba, e um funcionário de meio período de Bremen fez a mesma coisa em poucos dias. Poderia ter sido algumas horas se não fosse sua primeira experiência com Numba. Então, o que é isso Numba? Que tipo de feitiçaria é?
Sem feitiçaria. Os decoradores do Python transformam cada parte do código em sua árvore de sintaxe abstrata para você, para que você possa fazer o que quiser com ela. Numba é uma biblioteca Python que deseja compilar árvores de sintaxe abstrata com qualquer back-end que possua e para qualquer plataforma que suporte. Se você deseja compilar seu código Python para rodar em núcleos de CPU de maneira massivamente paralela, basta dizer a Numba para compilá-lo. Se você deseja executar algo na GPU, novamente, você deve apenas perguntar .
@cuda.jit def matmul(A, B, C): """Perform square matrix multiplication of C = A * B.""" i, j = cuda.grid(2) if i < C.shape[0] and j < C.shape[1]: tmp = 0. for k in range(A.shape[1]): tmp += A[i, k] * B[k, j] C[i, j] = tmp
Numba é um dos compiladores Python que torna o C++ obsoleto. Em teoria, no entanto, não é melhor do que C++, pois usa os mesmos back-ends. Ele usa CUDA para programação de GPU e LLVM para CPU. Na prática, por não exigir reconstrução antecipada para cada nova arquitetura, as soluções Numba se adaptam melhor a cada novo hardware e suas otimizações disponíveis.
Obviamente, seria melhor ter uma clara vantagem de desempenho, como acontece com o Spiral. Mas Spiral é mais um projeto de pesquisa, pode matar C ++, mas apenas eventualmente, e apenas se tiver sorte. Numba com Python estrangula C++ agora, em tempo real. Porque se você pode escrever em Python e ter o desempenho de C++, por que você iria querer escrever em C++?
Vamos jogar outro jogo. Darei a você três trechos de código e você adivinhará qual deles, ou talvez mais, está escrito em assembly. Aqui estão eles:
invoke RegisterClassEx, addr wc ; register our window class invoke CreateWindowEx,NULL, ADDR ClassName, ADDR AppName,\ WS_OVERLAPPEDWINDOW,\ CW_USEDEFAULT, CW_USEDEFAULT,\ CW_USEDEFAULT, CW_USEDEFAULT,\ NULL, NULL, hInst, NULL mov hwnd,eax invoke ShowWindow, hwnd,CmdShow ; display our window on desktop invoke UpdateWindow, hwnd ; refresh the client area .while TRUE ; Enter message loop invoke GetMessage, ADDR msg,NULL,0,0 .break .if (!eax) invoke TranslateMessage, ADDR msg invoke DispatchMessage, ADDR msg .endw
(module (func $add (param $lhs i32) (param $rhs i32) (result i32) get_local $lhs get_local $rhs i32.add) (export "add" (func $add)))
v0 = my_vector // we want the horizontal sum of this int64 r0 = get_len ( v0 ) int64 r0 = round_u2 ( r0 ) float v0 = set_len ( r0 , v0 ) while ( uint64 r0 > 4) { uint64 r0 >>= 1 float v1 = shift_reduce ( r0 , v0 ) float v0 = v1 + v0 }
Então, qual deles, ou mais de um, está em assembléia? Se você acha que todos os três, parabéns! sua intuição já melhorou muito!
O primeiro está em MASM32. É um macroassembler com “se” e “enquanto” as pessoas escrevem aplicativos nativos do Windows. Isso mesmo, não “costumava escrever”, mas “escrever” até hoje. A Microsoft protege zelosamente a compatibilidade com versões anteriores do Windows com a API Win32 para que todos os programas MASM32 já escritos funcionem bem em PCs modernos também.
O que é irônico, C foi inventado para facilitar a tradução do UNIX de PDP-7 para PDP-11. Ele foi projetado como um montador portátil capaz de sobreviver à explosão cambriana das arquiteturas de hardware dos anos 70. Mas no século XXI, a arquitetura de hardware evolui tão lentamente, os programas que escrevi no MASM32 20 anos atrás são montados e executados perfeitamente hoje, mas não tenho certeza de que um aplicativo C++ que construí no ano passado com CMake 3.21 será construído hoje com CMake 3.25.
A segunda parte do código é o Web Assembly. Não é nem mesmo um montador de macro, não tem “se” e “enquanto” s, é mais um código de máquina legível por humanos para o seu navegador. Ou algum outro navegador. Conceitualmente, qualquer navegador.
O código Web Assembly não depende de sua arquitetura de hardware. A máquina a que serve é abstrata, virtual, universal, chame-a do que quiser. Se você pode ler este texto, você já tem um em sua máquina física.
Mas o trecho de código mais interessante é o terceiro. É ForwardCom – um montador Agner Fog, um renomado autor de C++ e manuais de otimização de montagem, propõe. Assim como com o Web Assembly, a proposição abrange não tanto um montador quanto o conjunto universal de instruções projetadas para permitir não apenas a compatibilidade com versões anteriores, mas também com versões futuras. Daí o nome. O nome completo do ForwardCom é “ uma arquitetura de conjunto de instruções compatível com encaminhamento aberto ”. Em outras palavras, não é tanto uma proposta de assembléia, mas uma proposta de tratado de paz.
Sabemos que todas as famílias arquitetônicas mais comuns: x64, ARM e RISC-V têm diferentes conjuntos de instruções. Mas ninguém conhece um bom motivo para mantê-lo assim. Todos os processadores modernos, exceto talvez os mais simples, executam não o código com o qual você os alimenta, mas o microcódigo para o qual eles traduzem sua entrada. Portanto, não é apenas o M1 que possui uma camada de compatibilidade com versões anteriores para Intel, todo processador tem essencialmente uma camada de compatibilidade com versões anteriores para todas as suas próprias versões anteriores.
Então, o que impede os projetistas de arquitetura de concordar com uma camada semelhante, mas para compatibilidade futura? Além das ambições conflitantes das empresas em competição direta, nada. Mas se os fabricantes de processadores em algum momento decidirem ter um conjunto de instruções comum em vez de implementar uma nova camada de compatibilidade para todos os outros concorrentes, a ForwardCom levará a programação de montagem de volta ao mainstream. Essa camada de compatibilidade futura curaria a pior neurose de todos os programadores de assembly: “e se eu escrever o código único na vida para essa arquitetura em particular e essa arquitetura em particular se tornar obsoleta em um ano?”
Com uma camada de compatibilidade futura, nunca se tornará obsoleto. Essa é a questão.
A programação em assembly também é retida por um mito de que escrever em assembly é difícil e, portanto, impraticável. A proposição de Fog também aborda esse problema. Se as pessoas pensam que escrever em assembly é difícil, e escrever em C não é, bem, vamos apenas fazer o assembler parecer com C. Sem problemas. Não há uma boa razão para uma linguagem assembly moderna ser exatamente igual à de seu avô nos anos 50.
Você mesmo acabou de ver três amostras de montagem. Nenhuma delas parece uma montagem “tradicional” e nenhuma deveria ser.
Portanto, ForwardCom é o assembly no qual você pode escrever um código ideal que nunca ficará obsoleto e que não faz você aprender um assembly “tradicional”. Para todas as considerações práticas, é o C do futuro. Não C++.
Vivemos em um mundo pós-moderno. Nada mais morre além de pessoas. Assim como o latim nunca realmente morreu, assim como COBOL, Algol 68 e Ada, - C++ está condenado à meia-existência eterna entre a vida e a morte. C++ nunca morrerá de verdade, apenas será empurrado para fora do mainstream por novas tecnologias mais potentes.
Bem, não “será empurrado”, mas “sendo empurrado”. Cheguei ao meu emprego atual como programador C++ e hoje meu dia de trabalho começa com Python. Eu escrevo as equações, o SymPy as resolve para mim e depois traduz a solução para C++. Em seguida, colo esse código na biblioteca C++ sem me preocupar em formatá-lo um pouco, já que o clang-tidy fará isso por mim de qualquer maneira. Um analisador estático verificará se não estraguei os namespaces e um analisador dinâmico verificará vazamentos de memória. O CI/CD cuidará da compilação entre plataformas. Um criador de perfil me ajudará a entender como meu código realmente funciona e o desmontador – por quê.
Se eu trocar C++ por “não C++”, 80% do meu trabalho permanecerá exatamente o mesmo. C++ é simplesmente irrelevante para a maior parte do que faço. Isso poderia significar que para mim o C++ já está 80% morto?
Imagem de chumbo desenvolvida por difusão estável.