Enquanto trabalhava na Headlands Technologies, tive a sorte de escrever vários utilitários para simplificar a criação de código C ++ de alto desempenho. Este artigo oferece uma visão geral de um desses utilitários, OutOfLine
.
Vamos começar com um exemplo ilustrativo. Suponha que você tenha um sistema que lide com um grande número de objetos do sistema de arquivos. Podem ser arquivos comuns, denominados soquetes ou tubos UNIX. Por algum motivo, você abre muitos descritores de arquivos na inicialização, depois trabalha intensamente com eles e, no final, fecha os descritores e exclui os links dos arquivos (aprox. A faixa significa a função desvincular ).
A versão inicial (simplificada) pode ser assim:
class UnlinkingFD { std::string path; public: int fd; UnlinkingFD(const std::string& p) : path(p) { fd = open(p.c_str(), O_RDWR, 0); } ~UnlinkingFD() { close(fd); unlink(path.c_str()); } UnlinkingFD(const UnlinkingFD&) = delete; };
E esse é um bom design, logicamente correto. Ele conta com o RAII para liberar automaticamente o descritor e remover o link. Você pode criar uma grande variedade desses objetos, trabalhar com eles e, quando a matriz deixar de existir, os próprios objetos limparão tudo o que era necessário no processo.
Mas e o desempenho? Suponha que fd
usado com muita frequência e path
somente ao excluir um objeto. Agora a matriz consiste em objetos de tamanho 40 bytes, mas geralmente apenas 4 bytes são usados. Isso significa que haverá mais erros no cache, porque você precisa "pular" 90% dos dados.
Uma das soluções comuns para esse problema é a transição de uma matriz de estruturas para uma estrutura de matriz. Isso fornecerá o desempenho desejado, mas ao custo de abandonar o RAII. Existe uma opção que combine as vantagens de ambas as abordagens?
Um compromisso simples seria substituir std::string
tamanho de 32 bytes por std::unique_ptr<std::string>
, cujo tamanho é de apenas 8 bytes. Isso reduzirá o tamanho do nosso objeto de 40 bytes para 16 bytes, o que é uma grande conquista. Mas essa solução ainda perde ao usar várias matrizes.
OutOfLine
é uma ferramenta que permite, sem abandonar o RAII, mover completamente os campos raramente usados (frios) para fora do objeto. OutOfLine é usado como uma classe base CRTP , portanto, o primeiro argumento para o modelo deve ser uma classe filho. O segundo argumento é o tipo de dados raramente usados (frios) associados a um objeto (principal) usado com frequência.
struct UnlinkingFD : private OutOfLine<UnlinkingFD, std::string> { int fd; UnlinkingFD(const std::string& p) : OutOfLine<UnlinkingFD, std::string>(p) { fd = open(p.c_str(), O_RDWR, 0); } ~UnlinkingFD(); UnlinkingFD(const UnlinkingFD&) = delete; };
Então, como é essa classe?
template <class FastData, class ColdData> class OutOfLine {
A idéia básica de implementação é usar um contêiner associativo global que mapeie ponteiros para objetos principais e ponteiros para objetos que contêm dados frios.
inline static std::map<OutOfLine const*, std::unique_ptr<ColdData>> global_map_;
OutOfLine
pode ser usado com qualquer tipo de dados frios, cuja instância é criada e associada ao objeto principal automaticamente.
template <class... TArgs> explicit OutOfLine(TArgs&&... args) { global_map_[this] = std::make_unique<ColdData>(std::forward<TArgs>(args)...); }
A remoção do objeto principal implica a remoção automática do objeto frio associado:
~OutOfLine() { global_map_.erase(this); }
Ao mover (mover construtor / operador de atribuição de movimento) do objeto principal, o objeto frio correspondente será automaticamente associado ao novo objeto sucessor principal. Como resultado, você não deve acessar os dados frios de um objeto movido de.
explicit OutOfLine(OutOfLine&& other) { *this = other; } OutOfLine& operator=(OutOfLine&& other) { global_map_[this] = std::move(global_map_[&other]); return *this; }
No exemplo de implementação acima , o OutOfLine
é tornado impossível de copiar por simplicidade. Se necessário, é fácil adicionar operações de cópia; elas apenas precisam criar e vincular uma cópia de um objeto frio.
OutOfLine(OutOfLine const&) = delete; OutOfLine& operator=(OutOfLine const&) = delete;
Agora, para que isso seja realmente útil, seria bom ter acesso a dados frios. Ao herdar de OutOfLine
classe recebe os métodos constantes e não constantes de cold()
:
ColdData& cold() noexcept { return *global_map_[this]; } ColdData const& cold() const noexcept { return *global_map_[this]; }
Eles retornam o tipo apropriado de referência para dados frios.
Isso é quase tudo. Essa opção UnlinkingFD
terá 4 bytes de tamanho, fornecerá acesso amigável ao cache ao campo fd
e manterá os benefícios do RAII. Todo o trabalho relacionado ao ciclo de vida de um objeto é totalmente automatizado. Quando o objeto principal usado com frequência é movido, dados frios raramente usados são movidos com ele. Quando o objeto principal é excluído, o objeto frio correspondente também é excluído.
Às vezes, no entanto, seus dados são conspirados para complicar sua vida - e você se depara com uma situação em que os dados básicos devem ser criados primeiro. Por exemplo, eles são necessários para construir dados frios. É necessário criar objetos na ordem inversa em relação ao que o OutOfLine
oferece. Para esses casos, um "backup" é útil para controlarmos a ordem de inicialização e de inicialização.
struct TwoPhaseInit {}; OutOfLine(TwoPhaseInit){} template <class... TArgs> void init_cold_data(TArgs&&... args) { global_map_.find(this)->second = std::make_unique<ColdData>(std::forward<TArgs>(args)...); } void release_cold_data() { global_map_[this].reset(); }
Este é outro construtor OutOfLine
que pode ser usado em classes filho; ele aceita uma tag do tipo TwoPhaseInit
. Se você criar o OutOfLine
dessa maneira, os dados frios não serão inicializados e o objeto permanecerá parcialmente construído. Para concluir a construção de duas fases, você precisa chamar o método init_cold_data
(transmitindo os argumentos necessários para criar um objeto do tipo ColdData
). Lembre-se de que você não pode chamar .cold()
em um objeto cujos dados frios ainda não foram inicializados. Por analogia, os dados frios podem ser excluídos antes do agendamento antes de executar o ~OutOfLine
chamando release_cold_data
.
};
Agora é tudo. Então, o que essas 29 linhas de código nos fornecem? Eles são outra troca possível entre desempenho e facilidade de uso. Nos casos em que você tem um objeto, alguns dos quais membros são usados com muito mais frequência do que outros, o OutOfLine
pode servir como uma maneira fácil de otimizar o cache, com o custo de diminuir significativamente o acesso a dados raramente usados.
Conseguimos aplicar essa técnica em vários locais - muitas vezes há a necessidade de suplementar dados de trabalho usados intensivamente com metadados adicionais necessários no final do trabalho, em situações raras ou inesperadas. Sejam as informações sobre os usuários que estabeleceram a conexão, do terminal de negociação do qual o pedido veio ou o identificador do acelerador de hardware envolvido no processamento de dados de troca - o OutOfLine
manterá o cache limpo quando você estiver na parte crítica dos cálculos (caminho crítico).
Eu preparei um teste para que você possa ver e avaliar a diferença.
O script | Tempo (ns) |
---|
Dados frios no objeto principal (versão inicial) | 34684547 |
Dados frios completamente excluídos (melhor cenário) | 2938327 |
Usando OutOfLine | 2947645 |
Eu obtive uma aceleração de cerca de OutOfLine
ao usar o OutOfLine
. Obviamente, esse teste foi projetado para demonstrar o potencial do OutOfLine
, mas também mostra quanto da otimização de cache pode ter um impacto significativo no desempenho, assim como o OutOfLine
permite obter essa otimização. Manter o cache livre de dados raramente usados pode fornecer aprimoramentos complexos, mensuráveis e abrangentes ao restante do código. Como sempre com a otimização, confie em medições mais do que suposições, no entanto, espero que o OutOfLine
seja uma ferramenta útil na sua coleção de utilitários.
Nota do tradutor
O código fornecido no artigo serve para demonstrar a ideia e não é representativo do código de produção.