quarta-feira, 17 de dezembro de 2014

Faça como eu faço, não como eu digo (parte 1)

O título deste post iria ser "Coisas que te ensinamos errado", mas o fato é que não foi ensinado necessariamente errado, só não foi da melhor forma. Em função da heterogeneidade da turma, da limitação de tempo e da preocupação com que os alunos consigam resolver os problemas propostos, mesmo que não da melhor forma, os professores (eu, pelo menos) muitas vezes não codificam seus exemplos como eles próprios fariam ou recomendariam.

Primeiro caso


O que há de ruim na função abaixo? A maioria dos meus alunos devem ter visto este exemplo, ou semelhante:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Retorna a quantidade de dias que um mes tem
int diasmes(int mes, int ano)
{
    int dias[]= {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};

    if((ano%4==0 && ano%100!=0) || ano%400==0)  // É bissexto
        dias[1] = 29;                      // Fevereiro tem 29 dias

    return dias[mes-1];
}

Obs: Como o assunto se aplica tanto ao C como C++ não estou usando nada específico do C++11, como a sintaxe uniforme de inicialização.

Quem é da velha guarda, e como eu aprendeu a programar C com o compilador cc do Unix sabe que o problema está na linha 4. O cc que usei era um compilador pré-ANSI e daria erro de compilação com este código. Qual o problema? Inicialização de arrays automaticamente alocados.

Quando se define e inicializa uma variável automática (local não static) como n abaixo, ela será alocada em registrador ou na pilha a cada vez que a função for executada. Após isto, também a cada execução, o valor será copiado para este armazenamento.


1
2
3
void fn(){
   int n=0;
   // ...

Nada errado com uma variável de tipo fundamental como esta aí. Mas e com um array, como aquele do exemplo anterior? Arrays não podem ser armazenados em registrador. Irão ficar na memória, no segmento de pilha (stack). Mas o problema não é este, pois alocar espaço na pilha para um vetor usa uma única operação que subtrai o tamanho do vetor do Stack Pointer. Mas como ele é inicializado? Normalmente o compilador cria um vetor idêntico estático, e a cada entrada na função copia os dados daquele array para a área alocada.

Então, quando você escreve aquela função acima, é como se tivesse feito isto:


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
int __s129ax334[]= {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};

int diasmes(int mes, int ano)
{
    int dias[12], i;
    for(i=0; i<12; ++i)
        dias[i] = __s129ax334[i];

    if((ano%4==0 && ano%100!=0) || ano%400==0)  // É bissexto
        dias[1] = 29;                      // Fevereiro tem 29 dias

    return dias[mes-1];
}

Veja o for nas linhas 6 e 7. E pense que esta função pode estar sendo chamada N vezes durante a execução do programa. Está aí o problema!

A prova


Para os que só acreditam vendo, segue o assembly x86 gerado pelo GCC 4.7.1 32 bits, compilando com flag de otimização -O2. Algumas diretivas e instruções não relacionadas foram omitidas para facilitar a visualização. Se você não está afim de encarar código assembly, então jmp solucao.


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 .data
LC0:
 .long 31
 .long 28
 .long 31
 .long 30
 .long 31
 .long 30
 .long 31
 .long 31
 .long 30
 .long 31
 .long 30
 .long 31

 .text
_diasmes:
 ; ...
 sub esp, 48               ; Aloca espaco na pilha (12*4 bytes)
 mov edi, esp              ; Endereço destino (dias[])
 mov esi, OFFSET FLAT:LC0  ; Endereço origem (LC0)
 mov ecx, 12               ; Quantidade de palavras a serem copiadas
 rep movsd                 ; Repete 12 copias de inteiros 

Lá está o array estático LC0  no segmento de dados (linhas 2 a 14), a alocação do espaço na pilha (linha 19) e a cópia do array (linhas 20 a 23) com a instrução movsd que copia uma sequencia de inteiros do endereço apontado por esi (source index) para o endereço apontado por edi (destination index). O laço está implícito no prefixo rep, que repete a próxima instrução a quantidade de vezes especificada no ecx.

Solução


Antes de alguém aí pensar "Então é melhor eu mesmo definir o array global e dispensar a cópia" eu já adianto: O melhor, neste caso é deixá-lo local mas estático (static). Neste caso você mantém a visibilidade local, o que muito bom, mas o array é alocado estaticamente no segmento de dados, já com seus valores. Ou seja, quando o loader do SO carregar o binário para a memória, já estará pronto.

Aproveito para mais uma otimização secundária: como o maior valor do array é 31, não existe necessidade de ser int, que normalmente teria quatro bytes por valor. Pode muito bem ser um unsigned char, que ocupará apenas um byte por valor, na maioria das arquiteturas, e suporta um intervalo de valores de 0 a 255.

1
2
3
4
5
6
7
8
9
int diasmes(int mes, int ano)
{
    static unsigned char dias[]= {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
    dias[1] = ((ano%4==0 && ano%100!=0) || ano%400==0)? 29: 28;             
    return dias[mes-1];
}


Outras situações equivalentes


Agora lembre que uma string C também é um array (de char), então aqui temos novamente a mesma questão. Se for uma string C++ também terá um objeto sendo construído e a sequencia de caracteres copiados a cada execução.

1
2
char vogais[]="aeiou";
string nome="fulano";

Obs: Aí já aparece para outro episódio desta série que é a inicialização de uma std::string com o sinal de atribuição (=).

Quando não usar esta otimização?


Você pode ter problemas com isto quando estiver mudando os valores do vetor no corpo da função ou em outra função chamada que recebe o array por referência não constante e você quer descartar estas alteração a cada execução, garantindo que os valores originais sempre sejam encontrados, pois os valores estáticos são preservados entre chamadas da função. No exemplo acima, a única modificação é no segundo elemento do array, correspondente ao mês de fevereiro, então vale a pena. Melhor uma atribuição do que 12. Mas perceba que precisei (re)atribuir 28 para os anos não bissextos.

Se o array local está sendo inicializado e não é alterado, além de static defina-o constante (const).

Perceba que o problema está na inicialização, não na alocação. Então se o array não estiver sendo inicializado provavelmente é melhor que não seja estático.

Mas vale a pena?


Aqui nem é o caso de "tanto esforço por tão pouco ganho" pois o esforço é tão pequeno que deve valer a pena. Quanto mais a função for executada, maior o ganho de desempenho.

Eu tenho a opinião de que, com o atual desempenho e disponibilidade de memória nos computadores, podemos abdicar de algumas estratégias que levariam a um programa mais eficiente se isto resultar em código mais legível e manutenível. Não acho que este seja o caso.

2 comentários:

  1. Por favor, corrija-me se estiver errado professor :-) mas apenas uma pequena adição: pré C++11, se a variável estática for um objeto e a aplicação for multi-threaded, a inicialização do objeto não é thread-safe e pode acontecer mais de uma vez por diferentes threads. Já na nova versão, temos a seguinte passagem no standard

    "If control enters the declaration concurrently while the object is being initialized, the concurrent execution waits for completion of the initialization."

    o que garante sincronia.

    http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2008/n2660.htm
    http://stackoverflow.com/questions/8102125/is-local-static-variable-initialization-thread-safe-in-c11

    ResponderExcluir
    Respostas
    1. Correto, Natan! No caso de um tipo fundamental ou POD inicializado com expressão constante, como no exemplo, a alocação e a inicialização podem ambas serem feitas em tempo de compilação (estaticamente). Em outros casos a alocação é estatica mas a inicialização é em tempo de execução (dinâmica) e ocorre na primeira vez que a função é chamada. No caso de um objeto precisa ser executado o construtor, mas mesmo no caso de tipos fundamentais inicializados com expressões não-constantes, leva a uma race condition e não era thread-safe nas versões anteriores. Agora o compilador deve colocar a inicialização numa sessão crítica.
      Mas a ordem das inicializações ainda pode dar dor de cabeça em casos de dependências e compilação separada. É o chamado "static initialization order fiasco". Em geral não é uma boa inicializar dinamicamente variáveis locais estáticas.

      Excluir