C não é uma linguagem de baixo nível (2018)
(queue.acm.org)- C se encaixava bem na abstração de hardware da era do PDP-11, mas, nas CPUs modernas, a máquina abstrata de C baseada em execução sequencial e memória plana diverge bastante do hardware real
- Spectre e Meltdown estão ligados ao fato de que, para executar rapidamente o modelo sequencial de C, os processadores passaram a depender fortemente de predição de desvios, execução especulativa e paralelismo em nível de instrução
- Para tornar código C rápido, não basta uma tradução simples para linguagem de máquina: são necessárias otimizações complexas na escala de LLVM/Clang, e algumas delas podem entrar em conflito com a semântica de C
- Regras como origem de ponteiros, padding de structs, valores não inicializados e overflow de inteiro com sinal tornam o resultado da execução difícil de prever e também podem levar a vulnerabilidades de segurança
- Um modelo mais adequado ao hardware moderno aproveitaria muitas threads, unidades vetoriais largas e um modelo de memória mais simples, mas a compatibilidade com código C existente continua sendo a maior limitação
Por que C parecia “de baixo nível”
- Se uma linguagem é de baixo nível, as abstrações fornecidas pelo hardware e a máquina abstrata da linguagem deveriam corresponder facilmente
- No PDP-11, C podia ser visto como uma linguagem de baixo nível
- Os programas eram executados sequencialmente
- A memória era tratada como um espaço plano
- Os operadores pre-increment e post-increment combinavam bem com o modo de endereçamento do PDP-11
- Alan Perlis definiu que “uma linguagem é de baixo nível quando programas precisam prestar atenção ao que é irrelevante”, mas só essa definição não explica bem a expectativa de “proximidade com o hardware” que as pessoas costumam ter de uma linguagem de baixo nível
CPUs modernas funcionam como emuladores rápidos de PDP-11
- A causa fundamental de Spectre e Meltdown não está apenas em construir processadores rápidos, mas em projetá-los para expor rapidamente uma máquina abstrata parecida com a do PDP-11
- Até o C11, excetuando extensões proprietárias não padronizadas, o código C oferecia na prática uma máquina totalmente sequencial; mesmo após o C11, a máquina abstrata continua sendo majoritariamente sequencial
- CPUs modernas extraem paralelismo em nível de instrução (ILP) para manter as unidades de execução sempre ocupadas
- Elas analisam operações adjacentes e emitem em paralelo as operações independentes
- Em troca de permitir que programadores escrevam código majoritariamente sequencial, aumentam a complexidade e o consumo de energia
- GPUs conseguem alto desempenho sem essa lógica, mas exigem programas explicitamente paralelos
Spectre, Meltdown e o custo da execução especulativa
- Processadores Intel modernos podem manter até 180 instruções em voo ao mesmo tempo
- Em código C, pode-se considerar que há em média um desvio a cada 7 instruções
- Para manter o pipeline cheio em uma única thread, é preciso adivinhar o destino dos próximos 25 desvios
- Chutes errados geram trabalho que depois é descartado e ainda desperdiçam energia
- Spectre e Meltdown exploraram por side channel os efeitos colaterais visíveis desse trabalho descartado
- O register rename engine de núcleos modernos de alto desempenho é um dos grandes consumidores de área de chip e energia
- Quando uma instrução está em execução, é difícil desligá-la ou cortar sua energia
- GPUs não têm esse tipo de unidade; nelas, o paralelismo vem de várias threads
O modelo de memória plana de C não combina com a realidade do cache
- A memória plana, central na máquina abstrata de C, não corresponde ao hardware real há mais de 20 anos
- Processadores modernos normalmente têm 3 níveis de cache entre registradores e memória principal
- O cache, como o nome sugere, fica escondido do programador e não é visível em C
- Para produzir código rápido em processadores modernos, é preciso usar o cache com eficiência
- O programador em C precisa entender não só a máquina abstrata, mas também detalhes de implementação para obter desempenho
- Por exemplo, dois valores alinhados em 64 bytes podem acabar na mesma linha de cache
A complexidade de compilador necessária para deixar código C rápido
- Se C fosse uma linguagem de baixo nível, deveria ser fácil transformá-la em código rápido sem um compilador complexo, mas não é o caso
- O Clang e as partes relacionadas do LLVM somam cerca de 2 milhões de linhas
- Só as análises e passagens de transformação necessárias para executar C rapidamente chegam a quase 200 mil linhas, excluindo comentários e linhas em branco
- Em C, ao processar grandes volumes de dados, normalmente se escreve um loop que trata cada elemento sequencialmente
- Para executar isso de forma ideal em CPUs modernas, o compilador primeiro precisa determinar a independência entre as iterações do loop
- A palavra-chave
restrictpode garantir que uma escrita por meio de um ponteiro não interfere em leituras por meio de outro ponteiro
- Fortran leva vantagem sobre C nesse fornecimento de informação, e esse é um dos principais motivos pelos quais C não substituiu Fortran em computação de alto desempenho
O conflito entre vetorização e as garantias de layout de memória em C
- Se as iterações de um loop são independentes, o compilador tenta vetorizar o resultado
- Processadores modernos podem obter de 4 a 8 vezes mais throughput com código vetorial do que com código escalar
- Para um processador desses, seria natural que uma linguagem de baixo nível tivesse tipos vetoriais nativos de comprimento arbitrário
- O LLVM IR oferece esse modelo porque é mais fácil dividir operações em vetores grandes em operações menores do que fazer o contrário
- As garantias de layout de memória em C entram em conflito com a otimização
- Structs com o mesmo prefixo podem ser usadas de forma intercambiável
- Os offsets dos campos da struct ficam expostos na linguagem
- Fica difícil para o compilador reordenar campos ou inserir padding para melhorar a vetorização
- A possibilidade de controlar com precisão o layout de estruturas de dados pode ser uma vantagem de linguagens de baixo nível, mas ao mesmo tempo dificulta tornar C rápido
Problemas com padding, SROA e loop unswitching
- C exige padding no fim de structs para garantir que não haja padding dentro de arrays
- Como structs precisam permitir comparações sem considerar tipo, como
memcmp, cópias de struct também precisam preservar o padding- Em alguns experimentos, uma parcela perceptível do tempo total de execução de certas cargas foi gasta copiando padding
- SROA é uma otimização que tenta substituir structs e arrays de tamanho fixo por variáveis individuais
- Isso permite tratar os acessos de forma independente e eliminar operações cujo resultado não é observável
- Em alguns casos o padding desaparece, mas nem sempre
- Loop unswitching é uma otimização que move uma condição para fora do loop e cria um loop em cada caminho
- Isso entra em conflito com a ideia de que, ao executar código de baixo nível, o programador sabe qual código será executado e quando
- Também pode causar problemas com os conceitos de unspecified value e undefined behavior em C
Valores não inicializados e comportamento indefinido
- Em C, ler uma variável não inicializada produz um unspecified value, que pode ter um valor diferente a cada leitura
- Essa regra permite comportamentos como a reutilização tardia de páginas
- A implementação de
mallocdo FreeBSD informa ao sistema operacional quais páginas não estão sendo usadas no momento - O sistema operacional pode usar a primeira escrita na página como um sinal de que ela voltou a ser necessária
- A implementação de
- Quando um unspecified value é usado no controle de fluxo, isso vira undefined behavior
- Por exemplo, usar um valor não inicializado na condição de um
if
- Por exemplo, usar um valor não inicializado na condição de um
- Em loop unswitching, se o loop executa zero vezes, no código original todo o corpo do loop é código morto
- Após o unswitching, pode haver um desvio com base em uma variável que talvez não tenha sido inicializada
- Ou seja, código morto acaba se transformando em comportamento indefinido
- Dá para tornar código C rápido, mas construir um compilador inteligente o bastante exige milhares de anos-homem e, às vezes, obriga a violar parte das regras da linguagem
Por que ficou mais difícil entender C
- Em uma linguagem de baixo nível, o programador deveria conseguir entender facilmente a correspondência entre a máquina abstrata e a máquina física real
- No PDP-11, expressões em C eram mapeadas com facilidade para uma ou duas instruções, e variáveis locais e tipos primitivos também correspondiam de forma simples ao hardware
- Depois disso, as implementações de C ficaram cada vez mais complexas para manter a ilusão de código rápido e de correspondência com o hardware
- Uma pesquisa de 2015 com programadores C, autores de compiladores e membros de comitês de padronização revelou problemas de compreensibilidade em C
- Após inicializar uma struct com zero e depois definir alguns campos, 36% tinham certeza de que todos os bits de padding seriam zero, enquanto 29% responderam que não sabiam
- O resultado real pode variar conforme o compilador e o nível de otimização
Origem de ponteiros e vulnerabilidades de segurança
- O modelo do BCPL era relativamente simples: os valores eram palavras, e cada palavra era ou dado ou o endereço de um dado
- O modelo de C foi projetado para ser implementado em vários alvos, incluindo arquiteturas segmentadas e máquinas virtuais com garbage collection
- Para evitar problemas nesses sistemas, o padrão C restringe as operações válidas com ponteiros
- O C Defect Report 260 incorporou o conceito de pointer provenance à definição de ponteiro
- Uma implementação pode rastrear a origem de um padrão de bits
- Mesmo que os bits sejam idênticos, ponteiros de origens diferentes podem ser distinguidos
- A palavra
provenancenão aparece na especificação do C11, então cabe aos autores de compiladores decidir seu significado- GCC e Clang diferem sobre manter ou não a provenance quando um ponteiro é convertido em inteiro e depois de volta em ponteiro
- Já foram observadas vulnerabilidades de segurança em casos com overflow de inteiro com sinal e em código que desreferencia um ponteiro antes de verificar se ele é nulo
- Como desreferenciar ponteiro nulo é undefined behavior em C, o compilador pode assumir que um ponteiro já desreferenciado não pode ser nulo
- Um exemplo é o CVE-2009-1897
Imaginando processadores que não sejam para C
- As correções propostas para Spectre e Meltdown impõem uma penalidade de desempenho significativa e anulam boa parte do avanço microarquitetural dos últimos 10 anos
- Em vez de focar em tornar código C rápido, talvez seja hora de repensar modelos de programação adequados a processadores rápidos
- Chips altamente multithread, como os Sun/Oracle UltraSPARC Tx, não precisam de muito cache para manter as unidades de execução ocupadas
- Com paralelismo de alto nível suficiente, é possível parar threads que estão esperando memória e preencher as unidades de execução com instruções de outras threads
- O problema é que programas em C tendem a ter poucas threads realmente ocupadas
- O ARM SVE (Scalar Vector Extensions) é um exemplo de interface melhor entre programa e hardware
- Unidades vetoriais tradicionais expõem operações sobre vetores de tamanho fixo e esperam que o compilador adapte o algoritmo a esse tamanho
- O SVE permite que o programador descreva o grau de paralelismo disponível, e o hardware mapeia isso para a quantidade de unidades de execução
- Em C, o autovectorizer precisa inferir o paralelismo a partir da estrutura do loop, o que é complexo; já em uma operação
mapde estilo funcional, o comprimento do array a mapear já representa o paralelismo disponível, simplificando a geração de código
Um modelo de memória mais simples e programação paralela
- Em CPUs modernas, o cache coherency protocol é uma das partes mais difíceis de tornar rápidas e corretas
- Boa parte da complexidade vem de dar suporte a linguagens que esperam dados compartilhados e mutáveis
- Em uma máquina abstrata no estilo Erlang, todo objeto é local à thread ou imutável
- Erlang tem um modelo ainda mais simples, em que existe apenas um objeto mutável por thread
- O protocolo de coerência de cache em sistemas assim pode se dividir em apenas dois casos: mutável ou compartilhado
- Objetos imutáveis podem simplificar o cache e baratear várias operações
- O Project Maxwell, do Sun Labs, observou que os objetos no cache e os objetos alocados na young generation são quase o mesmo conjunto
- Se um objeto morre antes de ser expulso do cache, ele não precisa ser gravado de volta na memória principal, economizando energia
- Com objetos imutáveis no heap e uma stack mutável, o coletor de lixo pode se tornar uma máquina de estados simples, fácil de implementar em hardware
- Processadores projetados puramente para velocidade provavelmente dariam suporte a muitas threads, unidades vetoriais largas e um modelo de memória mais simples
- Executar código C nesses sistemas pode ser problemático
- Como existe muito código C legado no mundo, seria difícil ter sucesso comercial
- A noção comum de que programação paralela é difícil se aplica de forma mais precisa à programação paralela em linguagens com máquinas abstratas como a de C
- Alan Kay ensinou linguagens baseadas no modelo de atores para crianças, e elas escreveram programas funcionais com mais de 200 threads
- Programadores Erlang costumam escrever programas com milhares de componentes paralelos
- Em um cenário em que CPUs multicore e GPUs many-core são amplamente usadas, C não se mapeia bem ao hardware moderno
1 comentários
Opiniões no Hacker News
O motivo pelo qual C é de baixo nível é, no mínimo, o gerenciamento manual de memória
Especialmente no hardware moderno, o gerenciamento de memória está no centro da programação. O fato de Rust promover segurança de memória sem coletor de lixo também se deve, no fim das contas, ao fato de que a razão central da existência do Rust está bem próxima do gerenciamento de memória. O motivo de C ser rápido também é a memória, e o motivo de C não ser seguro também é, em grande parte, a memória. Um dos grandes motivos pelos quais computação paralela é difícil também é o acesso concorrente à memória. A programação funcional costuma ser cercada por conceitos matemáticos, mas uma parte considerável dela consiste em fazer os objetos parecerem imutáveis enquanto, internamente, o compilador lida com memória mutável
Em C, se você usa um alocador, todas essas chamadas são explícitas. No C++ antigo,
new/deletee ponteiros brutos também chamavam alocadores explicitamente, mas muita coisa acontecia automaticamente nos destrutores. Os smart pointers do C++ moderno são, em essência, parecidos com linguagens com coleta de lixo, no sentido de que tanto a alocação quanto a liberação acontecem automaticamenteNão dá para instruir o processador sobre em qual nível de cache colocar certos dados, o que enviar para a memória virtual etc. É mais baixo nível que Python, mas é difícil chamar isso de gerenciamento de memória de baixo nível como o C da época do PDP-11
Em sistemas do porte de microcontroladores ou em sistemas sem MMU, a história muda, mas isso já é outra questão
Até eu, que sou desenvolvedor Rust, trabalho dentro da fantasia de tratar ponteiros como objetos físicos reais, como endereços de memória. Rust e, em certa medida, C++ colocam à frente abstrações de gerenciamento como referências e empréstimos, mas o conceito central permanece o mesmo
Na realidade, o kernel do sistema operacional coloca uma enorme camada entre a memória física e o programa, e “endereços” e “ponteiros” são mais parecidos com handles sobre os quais o SO e a MMU fazem todo tipo de processamento
Mesmo “ponteiros brutos” na verdade não são brutos. São handles para offsets dentro de páginas, e as páginas reais podem estar espalhadas por vários lugares. Se você sair completamente da libc e do modelo de C e for para um mundo de referências puras que interagem diretamente com páginas do subsistema de VM, uma espécie de “handles de objeto”, talvez isso fique até mais próximo de como o subsistema real funciona
mallocefreesão funções de bibliotecaNo hardware não existe esse tipo de alocação byte a byte, então isso não é apenas uma abstração sobre o hardware, mas também abstrai a forma como o sistema operacional aloca memória
Em C, você também não consegue acessar diretamente a pilha. O stack frame é abstraído, e o que dá para usar é algo como
longjmpSe levarmos em conta comportamento indefinido e regras estritas de aliasing, também não há tanto acesso assim para ficar mexendo na memória à vontade
Como programador C e autor de compiladores, para quem entende C e o usa profissionalmente, C é claramente uma linguagem de baixo nível
Se você está procurando uma linguagem de baixo nível, C e seus parentes são as melhores opções
Se você está aprendendo C pela primeira vez e quer saber como usá-lo como um especialista, é melhor ignorar este texto. Ele pode só causar confusão e prejudicar sua capacidade de usar C com eficácia
Ela oferece acesso de baixo nível a uma máquina que as máquinas reais precisam emular com bastante esforço. Os dispositivos instáveis e remendos acrescentados ao longo dos anos para tentar acessar a máquina real são elementos relativamente estranhos dentro de C
Ainda assim, concordo que o título é retoricamente grosseiro. Ser a linguagem de baixo nível errada não a transforma em uma linguagem de alto nível. WASM também estaria “errado” se alegasse corresponder diretamente ao hardware moderno, mas isso não o torna de alto nível
O fato de C ser uma má correspondência, por si só, não é frustrante. É uma linguagem dos anos 1970, então isso é compreensível, e ela claramente ainda é útil em muitos casos. O mais frustrante é que C ainda influencia fortemente o design de linguagens e marca profundamente a forma como projetistas de linguagens enxergam o hardware. Por isso, com frequência demais, o design de linguagens modernas acaba apenas recombinando pedaços de C, em vez de criar linguagens que combinem bem com o hardware
Você terá problemas se achar que o código que escreveu terá uma relação um-para-um com assembly. Se quiser ver mais a fundo como esse tipo de coisa pode atrapalhar, assista a https://youtu.be/w3_e9vZj7D8
O ponto central do autor provavelmente não é “C não é uma boa linguagem para programação de sistemas”. É difícil escrever algo equivalente a
volatile int *dma_register = SCATTER_GATHER_BASE;em HaskellO argumento do autor é que o impulso de fazer C e outras linguagens que “modelam a máquina de von Neumann” executarem rapidamente tornou os compiladores muito complexos, e ele sugere que “se é de baixo nível, deveria precisar de um compilador simples”. Os processadores criados para rodar esse tipo de código rapidamente também são muito complexos, e essa complexidade tem custos
Em vários sentidos, este é um texto que pede uma mudança de modelo de programação, e cita a GPU como exemplo das possibilidades quando um “novo modelo de programação” e “o silício que o apoia” são criados juntos
O sentido original é mais próximo do usado no texto. Uma linguagem de baixo nível não é portável e fica presa ao hardware em que roda; uma linguagem de alto nível pode mirar várias plataformas. Por essa definição, C é claramente uma linguagem de alto nível
Minha reclamação não é tanto que o autor esteja fazendo jogo de palavras, mas que se apegar a uma terminologia antiga acaba atrapalhando a compreensão. A classificação por “gerações” costuma ser mais explicativa
A 1ª geração é linguagem de máquina, a 2ª é assembly, a 3ª são linguagens de propósito geral, e a 4ª são linguagens específicas de domínio de aplicação
A distinção entre 3ª e 4ª geração às vezes fica nebulosa, e nos anos 80 e 90 também se falava em uma 5ª geração que acabou não se consolidando. Ainda assim, vejo SQL, HyperCard e Mathematica como exemplos bastante claros de linguagens de 4ª geração
O bom dessa abordagem é que ela divide as linguagens de acordo com diferenças relativamente claras sobre quando usá-las. Depois disso, “alto nível/baixo nível” pode ser usado como termo relativo. Quanto mais alto o nível de uma linguagem, mais ela tende a abstrair os detalhes do que o computador realmente faz. Assim, ainda se preserva a ideia de que linguagens de gerações mais altas são, em geral, de nível mais alto, e o que se perde é apenas a discussão boba em torno de uma fronteira totalmente arbitrária e, francamente, inútil
Por esse método, também dá para ver .NET IL, WebAssembly e bytecode Java como linguagens de 2ª geração de nível muito alto, o que é interessante. E Forth é uma linguagem de 3ª geração. Chuck, pode vir brigar
Não é sobre como usar o martelo, mas mais sobre questionar se a forma de usar o martelo em todos os lugares, isto é, o design de C, está nos limitando
Não concordo com a afirmação do autor de que o conjunto de instruções da CPU deveria expor mais a implementação da CPU
Isso já foi tentado no passado e fracassou no longo prazo. Um exemplo são os slots de atraso de desvio em alguns processadores RISC projetados no fim dos anos 80 e início dos 90, como MIPS e SuperH. Para explicar a quem não conhece o conceito: a instrução logo após uma instrução de desvio é executada independentemente de o desvio ter sido tomado ou não
No curto prazo, isso permitiu tornar o processador mais simples e barato ao deixar para o programador a tarefa de evitar uma parada no pipeline depois de um desvio. Mas, com o tempo, o design dos processadores e os pipelines ficaram mais complexos, e uma única instrução deixou de ser suficiente para cobrir o atraso do desvio. No fim, isso virou um legado que processadores futuros precisavam tratar por compatibilidade, tornando a previsão de desvios e a lógica de pipeline mais complexas
Expor os detalhes errados obviamente é ruim. O ponto é que o modelo de C tem limitações importantes no mundo das CPUs modernas
Já ouvi uma apresentação de desenvolvedores que usaram um subsistema desses em determinado processador. Se não o usassem, gastariam 95% da janela de tempo apenas copiando dados; ao solicitar os dados antecipadamente com esse mecanismo, gastavam só 10% da janela na aquisição dos dados e conseguiam terminar o trabalho desejado em cerca de 50% da janela total, sobrando bastante tempo para recursos adicionais e melhorias
Se x86 tivesse algo assim, eu teria usado no doutorado para solicitar antecipadamente os dados de matriz que eu acessaria. O padrão que eu uso não é linear, mas é bem definido. Hoje, para acelerar mais esse código, eu teria que reorganizar as matrizes para agradar o prefetcher e refatorar a base de código inteira de cima a baixo
Se quiser, dá para projetar algo de forma péssima, mas fico curioso sobre quantos casos históricos existem para justificar uma generalização
Vejo baixo nível e alto nível não como uma dicotomia, mas como um espectro
Dá para dizer que C fica no terço mais baixo entre as linguagens, e expõe muitos elementos primitivos da máquina, como gerenciamento de memória e de threads. Mesmo que não seja tão baixo quanto assembly, é mais baixo que Java ou Go, e certamente está bem distante de Python ou JavaScript
Além disso, C é bastante inadequado para plataformas que usam memória segmentada ou endereços não planos. Há sinais de que essas coisas podem voltar à moda, e a ampla adoção de C é um enorme obstáculo a isso
Por isso, meu modelo mental sempre foi: “C é o nível mais baixo a que se pode descer antes de dar instruções diretamente ao processador”
“C não se comporta como uma linguagem típica de ‘alto nível’. Isso porque oferece vários recursos mais comumente associados a linguagens de ‘baixo nível’, como assembly. Entre eles estão a capacidade de escrever e ler dados em endereços de memória específicos, a capacidade de realizar operações sobre o conteúdo de posições de memória e instruções para incrementar e decrementar variáveis inteiras … Portanto, C oferece ao programador a flexibilidade e a eficiência de trabalhar em baixo nível, ao mesmo tempo que também oferece as vantagens de operações de alto nível típicas das linguagens de computador atuais, como estruturas de dados mais avançadas e controle de fluxo de programa. Por esse motivo, C às vezes é descrita como uma ‘linguagem de baixo nível de alto nível’ ou uma ‘linguagem de alto nível de baixo nível’.” - https://archive.org/details/computerprogramm0000ford/page/13...
A frase no fim do texto, “há um mito comum no desenvolvimento de software de que programação paralela é difícil”, é enganosa
O autor apresenta situações específicas em que ela não é difícil, mas, se a pergunta for aplicada de forma geral, programação paralela é difícil e não é um mito comum
Programação paralela é difícil? Se a pergunta for feita sem condições mais detalhadas, sim. É muito mais difícil conceituar instruções de código sendo executadas simultaneamente do que uma por vez, em sequência
(map inc [0 1 2 3]), a dificuldade de conceituar a funçãoincsendo executada sequencialmente para cada elemento é realmente diferente de conceituá-la sendo executada em paralelo?Acho que a dificuldade da programação paralela não é algo inerente; ela está mais próxima de duas coisas
Primeiro, as linguagens em geral usam execução sequencial como padrão, então, para fazer algo assíncrono, é preciso introduzir primitivas adicionais para o programador
Segundo, é preciso saber quando usar programação paralela de forma eficaz
Se você tem uma lista ou um stream de elementos independentes que só exigem cálculos independentes, programação paralela é intuitiva
Onde as pessoas travam é quando forçam assincronia onde ela não é necessária — isto é, onde a execução sequencial tem desempenho igual ou melhor —, ou quando inserem assincronia em cálculos que na prática são interdependentes e acabam quebrando o comportamento
Quando se diz “sem detalhes adicionais ou concretude”, na verdade se está usando a visão de mundo da família C/C como framework padrão
O ponto do autor é que programação sequencial é apenas um tipo de programação simples, não o único, e que ela não se encaixa facilmente no hardware moderno
O fato de Erlang existir e de pessoas o usarem com sucesso não significa que algo mais difícil deixe de ser difícil
Implementar algoritmos paralelos com infraestrutura de programação concorrente, como processos ou threads, também é difícil. Mas programação paralela, isto é, fazer muitos elementos de processamento trabalharem juntos na mesma tarefa, é muito mais fácil quando se tem a abstração correta
Ainda assim, há exceções em alguns casos de uso, como multiplicação de matrizes
O texto está certo no ponto de que computadores não são PDP-11 rápidos, mas está errado ao dizer que isso tem relação com C
Por exemplo, há a frase: “Outro núcleo do modelo de memória da máquina abstrata de C é a memória plana. Isso não é verdade há mais de 20 anos”
Isso não tem relação com C. O hardware impõe essa abstração. E ainda bem. Caso contrário, o programa pararia ao ser movido para uma máquina com outro cache
Exemplos incluem estruturas de memória hierárquica que fingem ser RAM plana, CPUs muito maiores do que o conjunto de instruções sugere, com execução fora de ordem e especulativa, e compiladores otimizadores que separam ainda mais o programa escrito da execução real
A IBM já trabalhava nessas coisas nos anos 1970, muito antes da ascensão de C. É válido criticar esse modelo e buscar alternativas, mas não é justo culpar C
Este texto já tem 5 anos e a premissa de que os computadores, em termos estruturais, já não se parecem muito com o PDP-11 se tornou ainda mais verdadeira; mas a conclusão de “vamos imaginar um processador que não seja C” parece menos forte
Estamos vendo uma separação forte entre código linear e código altamente paralelo, e isso já era assim em 2018. O exemplo mais claro é a ascensão do Python em machine learning e computação científica. Quando desempenho não é a prioridade máxima, ainda é muito conveniente escrever em um estilo single-thread e com um modelo de memória plano
Quando desempenho passa a importar, faz sentido migrar para uma linguagem mais adequada à programação paralela. Isso inclui linguagens de grafos computacionais de coisas como PyTorch, outros conjuntos de primitivas sobre CUDA, ou linguagens mais experimentais como Futhark. Código crítico para desempenho sempre recorreu a linguagens específicas de domínio, e elas parecem estar ficando mais comuns, não menos. O hardware também está sendo feito para isso. Exemplos são a combinação CPU+GPU comum em PCs desktop, as extensões vetoriais do x86 com primitivas que na prática formam sua própria DSL, e coisas como o M1, que acoplam a GPU à CPU para que ambas tenham acesso rápido à mesma memória do sistema
Em outras palavras, talvez o que esteja realmente ultrapassado não seja C, mas a ideia de uma linguagem de propósito geral que sirva igualmente bem para todos os tipos de tarefa
Se, por causa da sofisticação das CPUs modernas, C já não é uma linguagem de “baixo nível”, a mesma lógica também se aplica à linguagem assembly
Afinal, coisas como execução fora de ordem e renomeação de registradores também se aplicam ao assembly
O fato de os compiladores terem ficado mais sofisticados nas últimas décadas também reforça esse argumento. Mesmo o assembly gerado por um compilador C, isto é, o código objeto, pode sair diferente do esperado por causa de hoisting para fora de loops, eliminação de subexpressões comuns etc.
Ainda assim, acho que o conceito de chamar C de linguagem de “baixo nível” continua sendo um rótulo útil. Caso contrário, deveríamos aposentar essa denominação
É verdade que é uma abstração sobre o computador real, mas é muito menos do que o que C construiu sobre um modelo de computador virtual. O assembly de hoje está em um nível parecido com o de C quando C foi criado. O C atual é de nível alto demais e não oferece recursos que não se possa obter com linguagens melhores e modernas
Ainda assim, concordo que hoje em dia os nomes “baixo nível” e “alto nível” não são muito úteis
O texto parece desenvolver duas linhas de argumentação difíceis de harmonizar entre si
A primeira é a afirmação de que C não é uma linguagem de baixo nível, dando como exemplos o padding de structs e o fato de overflow de inteiros com sinal ser comportamento indefinido. Essa parte faz sentido e parece construtiva, no estilo de propor recursos de linguagem para uma linguagem hipotética de “baixo nível de verdade”
A segunda é a afirmação de que, por causa do domínio de C, projetistas de CPU tiveram de se esforçar para criar algo que executasse C de forma natural. Aqui entram exemplos como renomeação de registradores, memória plana e caching. Esse argumento também é compreensível, mas não sei bem como ele se conecta ao primeiro argumento no contexto do título do texto. Se tomado literalmente, parece significar que em hardware moderno é impossível criar uma linguagem de baixo nível, e que até a linguagem de máquina é “alto nível”. Então a conclusão seria que primeiro precisaríamos criar uma nova geração de hardware que exponha muito mais complexidade na arquitetura do conjunto de instruções, e só então projetar uma linguagem de baixo nível que tire proveito disso
Ambos os argumentos têm valor, mas colocá-los juntos em um único texto e dar a ele o título “C não é uma linguagem de baixo nível” deixa a coisa um pouco instável. O primeiro argumento combina com esse título; o segundo talvez ficasse melhor como um texto posterior chamado “nem a linguagem de máquina é uma linguagem de baixo nível”
Mas ouvi dizer que a compilação demorava muito e que os compiladores, no fim, não alcançaram o nível de otimização esperado. O fato de não ser compatível com x86 também não ajudou na adoção
VLIW vem à mente. Segundo o artigo da Wikipedia sobre Itanium:
“Uma palavra de instrução VLIW pode conter várias instruções independentes que podem ser executadas em paralelo, sem avaliação de independência. O compilador precisa tentar encontrar combinações válidas de instruções que possam ser executadas simultaneamente e, na prática, faz o escalonamento de instruções que processadores superescalares tradicionais realizam em hardware em tempo de execução.”
Se a CPU expõe o paralelismo de fluxo único na interface, isso poderia ser tratado em tempo de compilação ou decidido diretamente com assembly inline.
Fico curioso se isso não emplacou por causa da dinâmica comercial da indústria, ou se há razões técnicas reais pelas quais essa estratégia não é boa.
Primeiro, os compiladores não eram bons nesse tipo de escalonamento de instruções e, quando melhoraram depois, o Itanium já tinha afundado. Segundo, os conjuntos de instruções existentes, ou seja, x86, passaram a lidar com isso muito bem em hardware em tempo de execução e, na prática, obtinham resultados um pouco melhores do que o escalonamento estático. Isso porque, em tempo de execução, há dados de profiling.
Acho que o Linus deixou em [0] um bom rant um pouco relacionado a esse tema. “Enquanto o pessoal de RISC tentava otimizar compiladores para criar loops que usassem eficientemente todos os 32 registradores, os implementadores de x86, em vez disso, fizeram os chips rodarem rápido sob cargas diversas e usaram um hardware enorme de renomeação de registradores. Também estamos vendo renomeação de memória.”
[0] https://yarchive.net/comp/linux/x86.html
VLIW funciona muito bem em alguns nichos. É mais difícil de programar, manualmente ou por compilador, do que uma única instrução executada em ordem, mas simplifica o escalonamento no hardware. Funciona melhor quando as latências das instruções agrupadas são parecidas.
Hoje, o quebra-cabeça central de projeto está no fato de que acessos à memória consomem muito mais ciclos do que aritmética. Não faz muito sentido agrupar uma operação aritmética de poucos ciclos com uma carga de memória de algumas centenas de ciclos. Por isso, VLIW funciona bem quando se sabe que o acesso à memória é rápido, mais ou menos quando se sabe que vai caber no cache L1 ou em algo equivalente. Acho que esse é um dos motivos pelos quais ele se encaixa bem em sistemas no estilo DSP.
Pipelines expostos também são uma característica interessante de alguns desses sistemas. Se uma instrução dentro de um pacote VLIW escreve em um registrador, instruções posteriores que leem o mesmo registrador veem o valor anterior pelos próximos N ciclos, e só depois a escrita se torna visível. É realmente confuso programar isso à mão, mas o compilador consegue lidar com esse escalonamento.
Até recentemente, DSP e HPC eram uma parte muito pequena do mercado, então arquiteturas capazes de escalonamento dinâmico receberam mais investimento e acabaram dominando até esses mercados.
Em GPUs, claro, a situação mudou, e de fato as GPUs dependiam mais de escalonamento estático. Mas, à medida que as GPUs também se expandem para cargas mais variadas, elas vão ganhando cada vez mais elementos dinâmicos.
https://news.ycombinator.com/context?id=37900987
O Itanium foi a principal tentativa de lançar isso como CPU. Hoje AMD64 e ARM dominam, mas talvez vejamos isso de novo no futuro.