Princípios da Simpatia Mecânica


Na última década, o {hardware} viu avanços tremendos, desde a memória unificada que redefiniu a forma como as GPUs de consumo funcionam, até motores neurais que podem executar modelos de IA de bilhões de parâmetros em um laptop computer.

E ainda assim, o software program é ainda lento, desde inicializações a frio de segundos para funções simples sem servidor até pipelines ETL de horas de duração que simplesmente transformam arquivos CSV em linhas em um banco de dados.

Em 2011, um engenheiro comercial de alta frequência chamado Martin Thompson percebeu esses problemas, atribuindo-os
à falta de Simpatia Mecânica. Ele pegou emprestada esta frase de um campeão de Fórmula 1:

Você não precisa ser engenheiro para ser piloto de corrida, mas precisa de simpatia mecânica.

– Sir Jackie Stewart, Campeão Mundial de Fórmula 1

Embora (normalmente) não dirijamos carros de corrida, essa ideia se aplica aos profissionais de software program. Ao termos “simpatia” pelo {hardware} em que nosso software program é executado, podemos criar sistemas com desempenho surpreendente. O mecanicamente simpático Arquitetura LMAX processa milhões de eventos por segundo em um único thread Java.

Inspirado pelo trabalho de Martin, passei a última década criando sistemas sensíveis ao desempenho, desde plataformas de inferência de IA que atendem milhões de produtos na Wayfair, até novas codificações binárias
que superam os buffers de protocolo.

Neste artigo, abordo os princípios da simpatia mecânica que uso todos os dias para criar sistemas como estes – princípios que podem ser aplicados em qualquer lugar, em qualquer lugar. qualquer escala.

Acesso à memória não tão aleatório

A simpatia mecânica começa com a compreensão de como as CPUs armazenam, acessam e compartilham memória.

Princípios da Simpatia Mecânica

Figura 1: Um diagrama abstrato de como a memória da CPU é organizada

A maioria das CPUs modernas – dos chips da Intel ao silício da Apple – organiza a memória em uma hierarquia de registros, buffers e cachescada um com diferentes latências de acesso:

  • Cada núcleo da CPU tem sua própria alta velocidade registradores e buffers que são usados ​​para armazenar coisas como variáveis ​​locais e instruções em andamento.
  • Cada núcleo da CPU tem seu próprio Cache Nível 1 (L1) que é muito maior que os registradores e buffers do núcleo, mas um pouco mais lento.
  • Cada núcleo da CPU tem seu próprio Cache Nível 2 (L2) que é ainda maior que o cache L1 e é usado como uma espécie de buffer entre os caches L1 e L3.
  • Vários núcleos de CPU compartilham um Cache Nível 3 (L3) que é de longe o maior cache, mas é muito mais lento que os caches L1 ou L2. Este cache é usado para compartilhar dados entre núcleos de CPU.
  • Todos os núcleos da CPU compartilham acesso à memória principal, também conhecida como BATER. Essa memória é, em uma ordem de magnitude, a mais lenta para uma CPU acessar.

Como os buffers das CPUs são muito pequenos, os programas frequentemente precisam acessar caches mais lentos ou memória principal. Para esconder o custo desse acesso, as CPUs fazem um jogo de apostas:

  • A memória acessada recentemente provavelmente será acessado novamente em breve.
  • Memória aproximar memória acessada recentemente será provavelmente será acessado em breve.
  • O acesso à memória será provavelmente siga o mesmo padrão.

Na práticaessas apostas significam que o acesso linear supera o acesso dentro do mesmo
páginaque por sua vez supera amplamente o acesso aleatório entre páginas.

Prefira algoritmos e estruturas de dados que permitam acesso previsível e sequencial aos dados. Por exemplo, ao criar um pipeline ETL, execute uma verificação sequencial em todo um banco de dados de origem e filtre chaves irrelevantes em vez de consultar entradas, uma de cada vez, por chave.

Linhas de cache e compartilhamento falso

Dentro dos caches L1, L2 e L3, a memória geralmente é armazenada em “pedaços” chamados Linhas de cache. As linhas de cache são sempre uma potência contígua de dois de comprimento e geralmente têm 64 bytes.

As CPUs sempre carregam (“ler”) ou armazenam (“escrever”) memória em múltiplos de uma linha de cache, o que leva a um problema sutil: o que acontece se duas CPUs escrevem em duas variáveis ​​separadas na mesma linha de cache?

Figura 2: Um diagrama abstrato de como duas CPUs acessando duas variáveis ​​diferentes ainda podem entrar em conflito se as variáveis ​​estiverem na mesma linha de cache.

Você consegue Compartilhamento falso: Duas CPUs brigando pelo acesso a duas variáveis ​​diferentes na mesma linha de cache, forçando as CPUs a se revezar no acesso às variáveis ​​através do cache L3 compartilhado.

Para evitar o compartilhamento falso, muitos aplicativos de baixa latência “preenchem” as linhas de cache com dados vazios para que cada linha contenha efetivamente um
variável. A diferença
pode ser surpreendente:

  • Sem preenchimento, o compartilhamento falso da linha de cache causa um aumento quase linear na latência à medida que threads são adicionados.
  • Com o preenchimento, a latência é quase constante à medida que os threads são adicionados.

É importante ressaltar que o falso compartilhamento só aparece quando as variáveis ​​estão sendo
escrito para. Quando eles estão sendo lercada CPU pode copiar a linha de cache para seus caches ou buffers locais e não precisará se preocupar em sincronizar o estado dessas linhas de cache com as cópias de outras CPUs.

Devido a esse comportamento, uma das vítimas mais comuns do falso compartilhamento são as variáveis ​​atômicas. Estes são um dos poucos tipos de dados (na maioria das linguagens) que podem ser compartilhados com segurança e modificado entre threads (e por extensão, núcleos de CPU).

Se você está buscando o desempenho ultimate em um aplicativo multithread, verifique se há qualquer estrutura de dados sendo gravada por vários threads – e se essa estrutura de dados pode ser vítima de compartilhamento falso.

O princípio do único escritor

O compartilhamento falso não é o único problema que surge na construção de sistemas multithread. Existem problemas de segurança e correção (como condições de corrida), o custo da troca de contexto quando os threads superam os núcleos da CPU e o sobrecarga brutal de mutexes (“bloqueios”).

Essas observações me levam ao princípio mecanicamente simpático que uso o mais: O Princípio do Escritor Único.

Em termos conceituais, o princípio é simples: se houver algum dado (como uma variável na memória) ou recurso (como um soquete TCP) nos quais um aplicativo grava, todas essas gravações deverão ser feitas por um único thread.

Vamos considerar um exemplo mínimo de um serviço HTTP que consome texto e produz incorporações vetoriais desse texto. Essas incorporações seriam geradas dentro do serviço por meio de um modelo de IA de incorporação de texto. Para este exemplo, assumiremos que é um modelo ONNX, mas Tensorflow, PyTorch ou qualquer outro tempo de execução de IA funcionaria.

Figura 3: Um diagrama abstrato de um serviço ingênuo de incorporação de texto

Este serviço rapidamente se depararia com um problema: a maioria dos tempos de execução de IA só pode executar um chamada de inferência para um modelo por vez. Na arquitetura ingênua acima, usamos um mutex para contornar esse problema. Infelizmente, se várias solicitações chegarem ao serviço ao mesmo tempo, elas entrarão na fila para o mutex e sucumbirão rapidamente ao bloqueio de linha.

Figura 4: Um diagrama abstrato de um serviço de incorporação de texto usando o princípio de gravador único com lote

Podemos eliminar esses problemas refatorando com o princípio do gravador único. Primeiro, podemos agrupar o acesso ao modelo em um arquivo dedicado
Ator fio. Em vez de threads de solicitação competirem por um mutex, eles agora enviam mensagens assíncronas ao ator.

Como o ator é o único escritor, ele pode agrupar solicitações independentes em um solteiro chamada de inferência em lote para o modelo subjacente e, em seguida, envia os resultados de forma assíncrona para threads de solicitação individuais.

Evite proteger recursos graváveis ​​com um mutex. Em vez disso, dedique um único thread (“ator”) para possuir cada gravação e use mensagens assíncronas para enviar gravações de outros threads ao ator.

Dosagem Pure

Usando o princípio do gravador único, removemos o mutex do nosso serviço simples de IA e adicionamos suporte para chamadas de inferência em lote. Mas como o ator deveria criar esses lotes?

Se esperarmos por um tamanho de lote predeterminado, as solicitações poderia bloquear por um período ilimitado de tempo até que solicitações suficientes cheguem. Se criarmos lotes em um intervalo fixo, as solicitações vai bloco por um período limitado de tempo entre cada lote.

Existe uma maneira melhor do que qualquer uma dessas abordagens: Dosagem Pure.

Com o processamento em lote pure, o ator começa a criar um lote assim que as solicitações estão disponíveis em sua fila e o conclui assim que o tamanho máximo do lote é atingido. ou a fila está vazia.

Tomando emprestado um exemplo prático da postagem unique de Martin sobre processamento em lote pure, podemos ver como ele amortiza a latência por solicitação ao longo do tempo:

EstratégiaMelhor (µs)Pior (µs)
Tempo esgotado200400
Pure100200

Este exemplo assume que cada lote tem uma latência fixa de 100µs.

Com uma estratégia de lote baseada em tempo limite, assumindo um tempo limite de 100µsa latência do melhor caso será 200µs quando todas as solicitações do lote são recebidas simultaneamente (100µs para a solicitação em si, e 100µs
aguardando mais solicitações antes de enviar um lote). A latência do pior caso será 400µs quando algumas solicitações são recebidas um pouco tarde.

Com uma estratégia de lote pure, a latência supreme será 100µs
quando todas as solicitações do lote são recebidas simultaneamente. A latência do pior caso será 200µs quando algumas solicitações são recebidas um pouco tarde.

Em ambos os casos, o desempenho do processamento em lote pure é duas vezes melhor do que uma estratégia baseada em tempo limite.

Se um único gravador lida com lotes de gravações (ou leituras!), construa cada lote com avidez: inicie o lote assim que os dados estiverem disponíveis e termine quando a fila de dados estiver vazia ou o lote estiver cheio.

Esses princípios funcionam bem para aplicativos individuais, mas podem ser dimensionados para sistemas inteiros. O acesso a dados sequencial e previsível aplica-se tanto a um massive information lake quanto a um array na memória. O princípio do gravador único pode aumentar o desempenho de um aplicativo com uso intensivo de IO ou fornecer uma base sólida para um
Arquitetura CQRS.

Quando escrevemos software program que é mecanicamente compatível, o desempenho segue naturalmente, em todas as escalas.

Mas antes de prosseguir: priorize a observabilidade antes da otimização.
Você não pode melhorar o que não pode medir. Antes de aplicar qualquer um desses princípios, defina seu SLIs, SLOs e SLAs para que você saiba onde focar e quando parar.

Priorize a observabilidade antes da otimização, antes de aplicar esses princípios, meça o desempenho e entenda seus objetivos.


Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *