Rodrigo Strauss :: Blog
![]() |
Follow @rodrigostrauss |
Usando Win32 API para otimizar o I/O, parte 6
Parte Zero Parte 1 Parte 2 Parte 3 Parte 4 Parte 5 Fontes do parser
Depois de turbulências, mudanças de endereço e besteiras, é hora de dar continuidade à série sobre otimização de desempenho de I/O. Nos nossos exemplos, eu usei um interpretador de IDL que deve ler o conteúdo do arquivo em questão e vários imports, para mostrar o grau de otimização que é possível usando a API Win32 ao invés da runtime do C++.
Só para esclarecer: a runtime do C++ não é lenta, é até que bastante rápida. O grande negócio é que qualquer abstração deixa o código mais lento, já que são mais camadas intermediárias para fazer o que realmente se quer, e mais código será rodado para fazer a mesma coisa. É esse o motivo pelo qual, na esmagadora maioria das vezes, um programa feito em Visual C++ é bem mais rápido do que um feito em .NET ou Java. Além de abstrair a API nativa do sistema operacional - que a runtime do C++ também faz - o Java e o .NET abstraem também a arquitetura do computador onde o programa está rodando. A API nativa do Windows é a Win32, qualquer coisa que não use a API diretamente (VB6, Delphi, Java, .NET, [coloque-algo-que-não-seja-C-e-C++-puros-aqui] fica sempre mais lento do que usar a API diretamente. O fato é que as vezes a diferença é muito pequena (como no caso do Delphi) e não vale a pena o trabalho de usar Win32 (que não é pequeno). Na realidade, hoje todo mundo usa alguma abstração sobre Win32, seja ela uma grande abstração (.NET) ou uma pequena abstração (MFC).
Nessa nossa otimização, iremos usar um recurso do Windows chamado File Mapping. Esse recurso permite mapear um trecho de um arquivo diretamente na memória, e a medida que a memória é lida, o conteúdo do arquivo é colocado nela automaticamente. Para o programa, é como se o conteúdo do arquivo já estivesse na memória, mas na realidade o arquivo é lido a medida que o programa acessa essa memória. Isso evita buffers intermediários, e no final das contas, é mais simples do que alocar memória, ler o arquivo e depois tratar.
O File Mapping é implementado em kernel mode como um recurso chamado Section. Quando você mapeia um trecho do arquivo na memória (usando MapViewOfFile em user mode), o arquivo não é lido para memória na hora. As páginas de memória são marcadas como inválidas, o que faz com o que sejá disparada uma exceção quando essa memória é acessada. Essa exceção é tratada pelo Memory Manager, que faz a leitura do trecho acessado do arquivo para essa memória, marca a página como válida e retorna o controle para o programa, que agora terá o conteúdo esperado do arquivo nessa memória. O File Mapping também pode ser usado para compartilhar memória entre os programas. O Windows usa esse recurso para carregar executáveis e DLLs na memória, o que faz com que o conteúdo de uma DLL só seja carregado uma vez para todos os processos que estão usando-a.
Para simplificar o uso do File Mapping no nosso interpretador e para isolar um pouco o código Win32 do nosso código, criei uma classe simples para tratar o File Mapping:
// // Classe que mapeia um arquivo inteiro na memória // usando FileMapping // // Rodrigo Strauss - http://www.1bit.com.br // class Win32FileMapping { HANDLE m_hFile, m_hFileMapping; DWORD m_dwFileSize; void* m_p; void Clean() { m_hFileMapping = NULL; m_hFile = INVALID_HANDLE_VALUE; m_p = NULL; m_dwFileSize = 0; } public: Win32FileMapping() { Clean(); } ~Win32FileMapping() { Free(); } bool IsValid() { return m_p && m_hFileMapping && m_hFile != INVALID_HANDLE_VALUE; } void Free() { if(m_p) UnmapViewOfFile(m_p); if(m_hFileMapping) CloseHandle(m_hFileMapping); if(m_hFile != INVALID_HANDLE_VALUE) CloseHandle(m_hFile); Clean(); } // // Essa função mapeia o arquivo inteiro na memória // bool MapFile(const char* fileName) { Free(); m_hFile = CreateFile(fileName, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, NULL, NULL); if(m_hFile == INVALID_HANDLE_VALUE) return false; m_dwFileSize = GetFileSize(m_hFile, NULL); m_hFileMapping = CreateFileMapping(m_hFile, NULL, PAGE_READONLY, 0, 0, NULL); if(m_hFileMapping == NULL) { Free(); return false; } m_p = MapViewOfFile(m_hFileMapping, FILE_MAP_READ, 0, 0, 0); return true; } template<typename T> T GetStart() { return reinterpret_cast<T>(m_p); } template<typename T> T GetEnd() { if(!m_dwFileSize) return NULL; return reinterpret_cast<T>( ((unsigned char*)m_p) + m_dwFileSize ); } };
Essa classe mapeia o arquivo inteiro na memória, e retorna os ponteiros de início de de fim através das funções template GetStart
O boost::tokenizer tem duas opções de inicialização: passar uma string com o conteúdo a ser interpretado, ou um iterator inicial e um iterator final. No nosso caso, o iterator inicial é GetStart
Vamos ao código (é por isso que você está lendo isso, não é?):
void MidlParser::ParseMidlFile(const char* fileName) { ... Win32FileMapping fileMapping; if(m_bParseAsImportFile) { // // tenta abrir o imported file nas pastas de include // for(vector<string>::const_iterator i = m_IncludePaths.begin() ; i != m_IncludePaths.end() ; ++i) { string str; str = *i + fileName; fileMapping.MapFile(str.c_str()); if(fileMapping.IsValid()) break; } if(!fileMapping.IsValid()) throw ParseException(string("error opening import file \"") + fileName + "\"", *this, m_parsedFileName); // // inicializa o tokenizer com os ponteiros do FileMapping // m_Tokenizer.assign(fileMapping.GetStart<char*>(), fileMapping.GetEnd<char*>(), char_separator<char>("\t\r " , "\n\"*,;:{}/\[]()")); m_ParsedIncludeFiles.push_back(fileName); } else { fileMapping.MapFile(fileName); if(!fileMapping.IsValid()) throw ParseException(string("error opening file \"") + fileName + "\"", *this, m_parsedFileName); // // inicializa o tokenizer com os ponteiros do FileMapping // m_Tokenizer.assign(fileMapping.GetStart<char*>(), fileMapping.GetEnd<char*>(), char_separator<char>("\t\r " , "\n\"*,;:{}/\[]()")); m_parsedFileName = fileName; ... } }
Como visto, nosso código não ficou mais complicado por causa do File Mapping. Nossa classe facilitou muito e deixou o código claro, além da inteligente decisão de fazer uma classe simples que resolvesse nosso problema pontual ao invés de tentar fazer um framework genérico para uso avançado de File Mapping. Um péssimo costume de "programadores orientados à objetos" é fazer classes super genéricas que resolvam todas as situações possíveis e imagináveis naquela área (eu sei bem porque eu era assim...).
Chega de yada-yada, vamos aos números:
Média de tempo (50 chamadas):
228,01 ms - versão usando a runtime do C++
214,68 ms - versão usando CreateFile
204,17 ms - versão usando CreateFile com FILE_FLAG_SEQUENTIAL_SCAN
186,55 ms - versão usando File Mapping
Comparação entre a implementação com File Mapping e as anteriores
22,22% - Melhoria em relação versão que usa a runtime do C++ (versão inicial)
15,08% - Melhoria em relação versão que usa CreateFile
09,44% - Melhoria em relação versão que usa CreateFile com FILE_FLAG_SEQUENTIAL_SCAN
Nossa melhoria em relação à versão inicial foi de 22%, e as mudanças foram pequenas e pontuais. Nessa última tentativa, fizemos a classe de File Mapping (menos de 90 linhas de código e anos de estudo de C++ e Win32) e modificamos o código que carrega o arquivo (menos de 10 linhas de código e mais de 10 livros de programação depois). O nosso programa de exemplo não é tão I/O intensive, mas nossa otimização melhorou bastante o desempenho em termos pencentuais.
Mesmo assim, há algo para se pensar: será que valeu a pena? Essa série foi interessante para ilustrar conceitos de otimização de performance e Win32, mas agora chega a hora de explicar mais um conceito: comparação percentual não é suficiente para fazer um estudo sobre uma determinada otimização. 22% é uma otimização considerável, mas e em termos absolutos? Será que 41,46 milisegundos é algo que faça diferença no tempo de compilação? Nosso caso o esforço de programação foi pequeno (o de escrever os posts foi imensamente maior), mas mesmo assim deve ser levado em consideração.
Quando for mensurar alguma otimização, considere TODOS os parâmetros, inclusive os intangíveis, como o impacto que essa otimização causará no usuário. No caso do nosso interpretador, talvez ~40ms não faça tanta diferença. Mas em uma chamada de componente que roda no backend de uma bolsa de valores e que é chamado milhões de vezes em um único dia, 5ms faz uma diferença MUITO grande.
Lembre-se: programação é uma arte.
Em 27/09/2005 15:44, por Rodrigo Strauss
Olá, é a primeira vez que vejo seus artigos e gostaria de saber se você pode postar todos esles, desde o primeiro até o ultimo. Este é a parte 6 se poder postar para o e-mail sistemaufpa@yahoo.com.br eu agradeço.
Desde já obrigado!