Quinta-feira, Novembro 26, 2009

Otimização nas Sombras do C++

Eu não encontro pessoalmente esse tipo de coisa com muita frequência atualmente, mas foi tempo em que volta e meia me aparecia código-fonte muito esperto, ou críticas muito espertas sobre o meu código-fonte, com relação a quão "ótimo" ele é.

Em C, essas espertezas eram invariavelmente transformações de uma notação legível e imediatamente representativa do algoritmo lógico por uma notação ilegível, equivalente mas que executava mais rápido.

Truques de aritmética de ponteiros para escrever mais rápido na memória, truques horrendos com while e switch sem break pra desenrolar loops -- vários truques cujo objetivo é escrever o mesmo código gerando um programa melhor.

De certa forma, os truques dessa época eram motivados pelos compiladores dessa época, máquinas de transformação burra de C para assembler. Com o objetivo de gerar o melhor assembler, programadores produziam pior C -- mais rápido, sim, mas menos legível e de mais difícil manutenção.

Com o passar do tempo, os compiladores se tornaram otimizadores, capazes de inlining, remoção de código morto, desenrolamento de loops, progragação de valores constantes etc.

As máquinas, por sua vez, introduziram pipelines para otimizar o trabalho de processador mais e mais rápido que a memória, capazes de execução computações intermediárias simultaneamente a recuperação de dados da memória, especulação sobre valores futuros para adiantar computações etc.

Compiladores cientes do pipeline passaram a otimizar ainda mais, ordenando e reordenando instruções de modo a obter o melhor desempenho do otimizador alvo.

Quem tem à mão um excelente compilador otimizador não precisa mais se preocupar em produzir código esperto. O compilador quase invariavelmente é melhor do que o programador humano. Ele também é a ferramenta adequada para a esperteza -- porque projetos críticos frequentemente transformam um código-fonte em programas para diversas máquinas, onde as peculiaridades variam muito.

Mas então, um dia, surgiram as máquinas multi-processadas. A seguinte citação resume a tragédia da programação para tais máquinas:

Yeah, I know. "Multithreading is hard" is a cliché, and it bugs me, because it is not some truism describing a fundamental property of nature, but it’s something we did. We made multithreading hard because we optimized so heavily for the single threaded case.

O problema fundamental de um programa multithreaded em uma máquina onde os processadores compartilham a memória é o de garantir que um certo objeto lógico -- uma sequência de bytes na memória -- não será escrito e lido de forma incosistente por múltiplos processadores -- porque um está escrevendo pela metade quando outro está lendo, etc.

Por mais que um programador faça esforço para ordenar adequadamente as instruções em um programa -- de modo que qualquer ordem de leitura possível faça algum sentido -- o fato é que o otimizador do compilador, e o otimizador do pipeline, fará mágicas com a presença e a ordem das instruções, violando a expectativa do programador.

Desse modo, os truques espertos da atualidade não são truques para otimizar o assembler e sim truques para evitar inconsistência no acesso concorrente à memória.

As pessoas legais agora falam sobre atomics e memory barriers.

O que há de curioso aqui é que esse problema espirra em certas direções mesmo onde não há múltiplos threads. Ao programar uma máquina com suporte a memory mapped I/O o mesmo problema de consistência ocorre sobre um endereço memory mapped -- reordenar leituras e escritas ali resulta no caos.

Tendo chegado até aqui, releia seu livro favorito sobre C++ sobre o significado da palavra-chave volatile.

0 comentários: