logo
Contato | Sobre...        
rebarba rebarba

Rodrigo Strauss :: Blog

follow us in feedly

Usando Win32 API para otimizar o I/O, parte 3

Parte Zero     Parte 1     Parte 2     Parte 3     Parte 4     Parte 5     Parte 6     Fontes do parser

Agora que já tivemos bastante bla-bla-bla e medições, está na hora de fazer alguma otimização. A primeira otimização que faremos consiste em mudar os acessos aos arquivos - que hoje são feitos usando a runtime do C++ - para usar a Win32 API diretamente. Duas modificações serão feitas:

  • Usar CreateFile/ReadFile para ler o conteúdo do arquivo;
  • Usar VirtualAlloc para alocar memória ao invés de alocar do heap (que é o que a std::string faz).

Como um trecho de código vale sempre mais do que ~340 m/s, vou mostrá-lo antes e explicar depois:

void MidlParser::ParseMidlFile(const char* fileName)
{
   HANDLE hFile;
   char* szFileContent;
   DWORD dwFileSize;
   DWORD dwReaded;

   if(m_bParseAsImportFile)
   {
      //
      // vamos procurar o arquivos nas pasta determinadas para include
      //
      for(vector::const_iterator i = m_IncludePaths.begin() ; 
          i != m_IncludePaths.end() ; 
          ++i)
      {
         string str = *i + fileName;

         hFile = CreateFile(str.c_str(), GENERIC_READ, FILE_SHARE_READ, 
                            NULL, OPEN_EXISTING, NULL, NULL);

         if(hFile != INVALID_HANDLE_VALUE)
            break;
      }

      if(hFile == INVALID_HANDLE_VALUE)
         throw ParseException(string("error opening import file \"") + fileName 
                              + "\"", *this, m_parsedFileName);

      dwFileSize = GetFileSize(hFile, NULL);

      //
      // ao invés de alocar do Heap (que é para alocações pequenas), 
      // vou alocar direto da memória virtual. O Heap Manager usa 
      // memória virtual para isso, então estaremos 
      // "pulando" um nível de abstração.
      //
      //szFileContent = (char*)HeapAlloc(GetProcessHeap(), NULL, dwFileSize);

      szFileContent = (char*)VirtualAlloc(NULL, dwFileSize + (dwFileSize % 4096), 
                                          MEM_COMMIT, PAGE_READWRITE);

      //
      // vamos ler o arquivo inteiro de uma vez
      //
      ReadFile(hFile, szFileContent, dwFileSize, &dwReaded, NULL);
      ...
   }
   else
   {
      hFile.Create(fileName, GENERIC_READ, FILE_SHARE_READ, OPEN_EXISTING);

      if(hFile.m_h == INVALID_HANDLE_VALUE)
         throw ParseException(string("error opening file \"") + fileName + 
                              "\"", *this, m_parsedFileName);

      dwFileSize = GetFileSize(hFile, NULL);

  //szFileContent = (char*)HeapAlloc(GetProcessHeap(), NULL, dwFileSize);
      szFileContent = (char*)VirtualAlloc(NULL, dwFileSize + (dwFileSize % 4096), 
                                          MEM_COMMIT, PAGE_READWRITE);
      
      ReadFile(hFile, szFileContent, dwFileSize, &dwReaded, NULL);

      ...
   }

Algumas coisas importantes devem ser notadas nessa implementação. A primeira é que, além de mudar o acesso para usar CreateFile/ReadFile, eu pego antes o tamanho do arquivo, para ler tudo de uma vez. Não sabemos (por enquanto) como a runtime do C++ faz a leitura, mas ler o arquivo todo de uma vez só é a melhor forma. Outro detalhe da implementação é que eu uso VirtualAlloc para alocar memória e não HeapAlloc/new/malloc.

Antes de explicar o motivo de usar VirtualAlloc, uma explicação sobre gerenciamento de memória no Windows. A função mais low-level para alocar memória em user mode é a VirtualAlloc. Ela aloca memória no endereçamento do processo, com a granulação de uma página de memória (no Windows, em user mode, e em arquitetura x86, uma página de memória é de 4kb). Sendo assim, só podemos alocar múltipos de uma página. Por isso eu coloquei um dwFileSize + (dwFileSize % 4096) no tamanho, para arredondar a quantidade alocada para o primeiro múltiplo de 4kb maior do que a quantidade necessária.

Como muitas vezes os programas precisam de áreas de memória menores do que 4kb, usar no mínimo esse valor em cada alocação seria um desperdício muito grande. Por isso existem os heaps, que são pools de memória que servem alocações em granulações menores. Apesar de (na maioria das vezes) reduzir o desperdício de memória, o heap é mais lento do que o VirtualAlloc, porque uma alocação envolve algorimos de procura de blocos livres e desfragmentação da memória alocada para o heap.

O Windows possui gerenciamento de heap nativo, tanto em user mode quanto em kernel mode. Em user mode a função que aloca memória de um heap é a HeapAlloc(HANDLE,DWORD,SIZE_T), onde o primeiro parâmetro é um HANDLE para um heap. Você pode usar o heap default do processo (chamando GetProcessHeap()) ou criar um heap usando HeapCreate(). A runtime do C/C++ usa o HeapAlloc, mas cria um heap separado do heap padrão do processo.

Usei o VirtualAlloc porque ele é mais rápido do que o heap (já que aloca uma página diretamente e não tem problemas de fragmentação) e não envolve mais esforço de programação. Além disso, o desperdício da memória que ficará entre o que usamos e o fim da página será praticamente desprezível. Leremos aproximadamente 400kb, ou seja, 100 páginas. Mesmo se 100% das páginas forem desperdiçadas (o que é impossível acontecer), estaríamos usando 400kb a mais. Eu medi o desperdício para os arquivos do SDK, e ele é de 5,21% (o que é realmente desprezzzzível, como diria o Patolino).

Voltando à otimização de I/O, aqui está a medição que eu fiz depois dessas otimizações:

Média de tempo (50 chamadas):

228,01 ms - versão usando a runtime do C++
214,68 ms - versão usando Win32 API

Melhoria: 6,21%

Para primeira tentativa de otimização, 6,21% não é tão mal... Na próxima parte veremos se podemos fazer melhor.


Em 17/08/2005 23:07, por Rodrigo Strauss


  
 
 
Comentários
Wanderley Caloni Junior | website | e-mail | em 19/08/2005 | #
Nessa altura do campeonato as idéias começam a borbulhar, e a vontade de estragar as surpresas que poderão vir nas próximas partes é grande. Contudo, como você não acabou, nada mais justo do que aguardar a evolução gradual do seu processo de otimização _consciente_, _localizado_ e _estatisticamente_ _comprovado_, coisas essenciais que fazem valer a pena o processo de otimizar o código, e não famigerados clichês como usar aritmética de ponteiro ao invés de subscrito etc. Mas, voltando, melhor ver as idéias antes de dar uma de "parpiteiro" de "prantão".
Rodrigo Strauss | website | em 19/08/2005 | #
É, quem já tem experiência na área acaba meio que sabendo o final da história :-)

O que eu mais quero destacar é a importância da otimização "estatísticamente comprovada", e dar uns toques de como obter estatísticas e coisas assim. Vai ter até WinDbg no meio...
Wanderley Caloni Junior | website | e-mail | em 21/08/2005 | #
Esse lance de otimização por otimização e atirar pra todos os lados é triste, principalmente quando existem "pseudo-consultores" em empresas que se acham no nível de conhecimento necessário do seu código para criticá-lo em pequenas partes. Pior é quando essa crítica vem antes mesmo da finalização de uma versão beta, ou seja, antes mesmo de serem empregados os recursos para a finalização do projeto já se gasta na otimização das partes já implementadas, e muitas vezes, sem qualquer análise empírica, falam que uma parte do código está lenta sem sequer comprovar isso.
Rodrigo Pinho | e-mail | em 31/08/2005 | #
Bem,

Olhando o código, eu vi que vc aloca a memória necessária para ler todo o conteúdo do arquivo.

Se ao inves de alocar a memória, você não utilizasse um pool de memória, você não otimizaria mais ainda ?

Isso poderia fazer diferença, considerando uma hipótese de um parser que faz leitura de diversos arquivos, a cada hora. E supondo que os arquivos tenham mais ou menos o mesmo tamanho. Acho que o tempo para alocar e desalocar memória a cada leitura de arquivo, poderia ser otimizado, utilizando um pool de memória.

Seria interessante ver o resultado deste parser com uma memória ja alocada.

O Boost, como vc deve saber, tem uma lib especifica para pool de memória.
http://www.boost.org/libs/pool/doc/index.html

Abraços.
Rodrigo Strauss | website | em 31/08/2005 | #
Sim, essa é uma boa otimização a ser feita. Mas um simples pool não funcionaria, já que o objetivo dele é reusar memória, e na maioria dos casos do parser, não há reuso. A função ParseMidlFile é chamada recursivamente na maioria das vezes, e isso faz com que ele mantenha os arquivos em memória enquanto tenho que ler os outros. O que ajudaria seria alocar +- 1MB de uma vez só e usar um sistema de pilha para controlar quem usa o quanto. Só tem que testar se o algoritmo, mesmo que simples, não é mais lento do que a chamada simples ao VirtualAlloc.

Mesmo assim essa otimização precisa ser medida, já que eu não tenho certeza se o próprio Memory Manager do Windows já não faz esse tipo de pool. Algumas implementações de heap já fazem pool, é preciso medir se a implementação de memória virtual também faz. Mas é uma boa ideía.
Algo a dizer?
Nome:


Site:


E-mail:


Escreva o número vinte e seis:


 Não mostre meu e-mail no site, não serve pra nada mesmo...

Comentário





Os comentários devem ser sobre assuntos relativos ao post, eu provavelmente apagarei comentários totalmente offtopic. Se quiser me enviar uma mensagem, use o formulário de contato. E não esqueça: isso é um site pessoal e eu me reservo o direito de apagar qualquer comentário ofensivo ou inapropriado.
rebarba rebarba
  ::::