domínio: regras de baixa latência
Uma medição declara se foi executada a quente ou a frio, e corresponde à produção [R:uma-medição-corresponde-ao-regime-que-relata]
Escrito para o público de Rust e Java.
Escrito a partir do JMH e seus exemplos (Aleksey Shipilev), cuja existência é a afirmação permanente de que um microbenchmark feito à mão na JVM está errado por padrão.
Declare em qual regime a medição foi executada e verifique se é o regime em que a produção é executada. Dois erros vivem aqui e apenas um deles tem reputação.
Medir a frio quando a produção é quente. Uma primeira execução é executada interpretada ou a partir de um cache de instruções frio, com um preditor de desvio não treinado, tabelas de páginas não populadas e, em um runtime gerenciado, nenhum código compilado. O valor pode ser uma a duas ordens de grandeza pior do que o estado estacionário e descreve o aquecimento, não o trabalho. Na JVM, a escalada é em estágios -- interpretador, depois C1, depois C2, com substituição em pilha para loops longos -- e leva milhares de iterações, razão pela qual um loop de temporização feito à mão está errado por padrão e uma estrutura de teste existe.
Medir quente quando a produção é fria, que é a metade que não recebe atenção porque parece rigor. Um caminho executado uma vez por minuto nunca está quente. Uma rajada de abertura de mercado chega em código frio. Um manipulador de erros é executado pela primeira vez naquele dia no pior momento possível. Para cada um deles, o valor quente é um número que nenhum usuário verá, e o aquecimento que foi cuidadosamente descartado é o comportamento de produção. Descartá-lo não é higiene lá; é deletar a medição.
Portanto, a regra é uma correspondência de escopo, não um procedimento: diga qual regime foi medido, diga em qual regime a produção está e mostre que são os mesmos. Onde a produção é genuinamente ambos -- um fluxo constante com rajadas frias -- isso são duas medições e dois números relatados, não um único calculado em uma média sem sentido.
Isso é [R:meça-a-alegação-não-um-subconjunto] aplicado ao tempo em vez de a um conjunto: as amostras de aquecimento são um subconjunto, descartá-las é uma decisão de escopo, e uma decisão de escopo tomada por reflexo é aquela que fabrica um número errado com a autoridade de ter sido medida.
Verificação de modo de falha: o caminho de produção está quente ou frio quando importa, e qual eu medi? Se o benchmark descartou as primeiras N iterações, nomeie o que a produção tem que corresponde a essas iterações. Se nada corresponder, a parte descartada era a resposta.
Dois vizinhos, deliberadamente separados. [R:sem-omissão-coordenada] é o instrumento falhando em coletar amostras durante os eventos que importam; este é o instrumento coletando-as no regime errado. E [R:verifique-se-a-abstração-foi-compilada] cobre o caso em que o otimizador removeu o trabalho que o benchmark estava cronometrando -- uma medição quente de nada.
Uma fila sem limite converte sobrecarga em latência em vez de recusa [R:uma-fila-sem-limite-não-tem-comportamento-de-sobrecarga]
Escrito para o público de Rust e Java.
Escrito a partir da Lei de Little (J. D. C. Little, 1961): L = lambda W, então para uma fila que nunca recusa, a espera cresce sem limite uma vez que a taxa de chegada excede a taxa de serviço.
Decida o que a fila faz quando o consumo fica atrás da produção. Crescer não é uma decisão.
O tamanho da fila é latência -- isso é a Lei de Little e não uma heurística: com chegadas na taxa lambda e comprimento médio da fila L, o tempo médio de espera é L/lambda, então um L sem limite é uma espera sem limite. Uma fila sem limite de capacidade, portanto, não tem limite de latência, e um requisito de latência declarado em qualquer lugar a montante dela não é mantido por nada.
A falha tem uma forma característica que a torna tardia para diagnosticar. Os produtores nunca bloqueiam, então nada a montante relata um problema; a vazão parece correta, porque cada item é eventualmente processado; e o único sintoma é que a latência aumenta monotonicamente. Quando se torna visível, o backlog está fazendo um trabalho que ninguém quer -- cada item consumido já está obsoleto, então a capacidade está sendo gasta entregando respostas a perguntas cujo momento já passou, razão pela qual o sistema não se recupera quando a carga retorna ao normal. Ele precisa drenar primeiro, na mesma taxa que ficou para trás.
Portanto, limite a fila e escolha o comportamento no limite. Quatro são legítimos e a escolha é uma decisão de domínio, não técnica:
- Bloquear o produtor. Pressão reversa real -- o consumidor mais lento define a taxa para todos, e a pressão se propaga para quem pode realmente descartá-la. Errado onde o produtor não deve ser interrompido, que é o regime sobre o qual
[R:sem-parada-dentro-de-uma-janela-de-publicação]trata. - Descartar o mais antigo. Correto para dados de mercado, leituras de sensores, telemetria: um tick obsoleto tem valor negativo e o mais novo é o único que vale a pena ter.
- Descartar o mais novo. Correto onde o backlog é uma fila justa e os retardatários não receberam nenhuma promessa.
- Rejeitar e avisar. Correto para entrada de pedidos e qualquer coisa que um chamador precise saber o destino. Um pedido descartado silenciosamente é pior do que um recusado por toda a largura da confiança que o chamador depositou na chamada.
Não escolher seleciona um quinto comportamento que ninguém escolheria deliberadamente: absorver tudo, degradar a latência de cada item igualmente e, eventualmente, falhar ao exaurir a memória -- ou, em um runtime gerenciado, por uma pausa de coleta causada pelo próprio backlog, que desacelera ainda mais o consumidor e é a coisa mais próxima em produção de um loop de realimentação com o sinal errado.
A estrutura ilimitada continua sendo a escolha certa onde o limite genuinamente reside em outro lugar -- uma fila cujos produtores têm taxa limitada a montante, ou cujo consumidor é comprovadamente mais rápido do que qualquer taxa de chegada possível. Diga qual, no código, ao lado da fila. Uma fila ilimitada com uma razão declarada é uma decisão; uma sem é a ausência de uma decisão vestindo a mesma forma.
Verificação de modo de falha: se o consumo ficar atrás da produção por dez segundos, o que esta fila faz e quem descobre? Se a resposta for "ela cresce" e "ninguém", não há comportamento de sobrecarga aqui, apenas um sintoma de sobrecarga esperando.
Uma estrutura é correta apenas sob o regime de concorrência que sua prova assumiu [R:uma-estrutura-mantém-o-regime-sob-o-qual-foi-provada]
Escrito para o público de Rust e Java.
Escrito a partir de Dmitry Vyukov, Fila MPSC baseada em nós intrusiva, 1024cores -- onde o regime é declarado no nome do algoritmo e em nenhum lugar em seu código.
Leia o regime a partir do argumento de correção da estrutura e nomeie o que no código impede violá-lo. "A documentação diz consumidor único" não é um mecanismo.
MPSC significa multi-produtor, único-consumidor, e o único é uma pré-condição em vez de uma descrição. O mpscq_pop de Vyukov lê self->tail, caminha a partir dele e o escreve de volta -- uma carga simples e um armazenamento simples, não sincronizados, porque exatamente uma thread foi assumida como executando-os. Dois consumidores se intercalam lá e ambos podem retornar o mesmo nó, ou um pode perder um nó inteiramente. Nenhum dos resultados levanta nada. Não há asserção, nenhum bloqueio para disputar, nenhum caminho de erro: a fila retorna valores plausíveis e o programa continua com um item duplicado ou desaparecido.
Esse silêncio é o problema inteiro, e é agravado pela carga. Dois consumidores executando cem operações podem nunca se intercalar na janela que importa; a suíte de testes passa, o soak de staging passa, e a intercalação chega com o tráfego de produção. Um defeito que só aparece sob as condições que você não pode reproduzir é indistinguível, do lado de fora, de um defeito em algum outro lugar completamente.
Portanto, coloque o regime no tipo, onde a linguagem permitir. Distribua exatamente um handle de consumidor na construção; faça com que ele não seja nem Clone nem Sync; use &mut self em pop para que o borrow checker imponha exclusividade. Então "dois consumidores" não é um bug a ser encontrado, mas um programa que não compila -- [R:torne-estados-ilegais-irrepresentáveis] e [R:tipestado-para-protocolos] onde o regime muda ao longo da vida de uma estrutura. Esta é a instância de maior alavancagem dessa disciplina em todo o domínio, porque o controle alternativo é uma frase em um comentário de cabeçalho e a falha que ele previne é não atribuível.
Onde a linguagem não suportar -- Java não tem tal handle -- o controle tem que ser algo que realmente execute: registre a thread proprietária no primeiro uso e a afirme em cada chamada subsequente em uma build de depuração, ou roteie todo o consumo através de um único objeto cuja construção é o único lugar onde o invariante é declarado. Um comentário não é um controle ([R:garantia-precisa-de-um-leitor]).
A classe é mais ampla do que filas, e os outros membros falham da mesma forma. Um ring SPSC com dois produtores. Uma estrutura documentada como não reentrante, acessada a partir de um manipulador de sinal ou um callback. Um HashMap acessado de duas threads porque o campo que o continha se tornou compartilhado três refatorações depois. Um iterador mantido durante uma mutação. Em cada caso, o regime era uma hipótese do argumento de correção e nada no artefato o registra.
Verificação de modo de falha: qual regime o argumento de correção desta estrutura assume, e o que falharia se eu o violasse agora? Se a resposta honesta for "o nome diz" ou "somos cuidadosos", a estrutura está desprotegida e a próxima pessoa a alcançá-la não saberá.
Memória que nunca foi tocada ainda não é memória [R:alocado-não-é-residente]
Escrito para o público de Rust e Java.
Escrito a partir de Ulrich Drepper, "O Que Todo Programador Deveria Saber Sobre Memória" (2007), sobre paginação por demanda e o custo de um primeiro toque.
Pré-alocar não é preparar. Uma alocação bem-sucedida compra espaço de endereço; a página chega no primeiro toque, e o toque é o que custa.
malloc e mmap devolvem endereços virtuais. Páginas físicas são fornecidas preguiçosamente: a primeira escrita em cada página armadilha no kernel, que encontra um frame, o zera -- porque os dados do inquilino anterior não devem vazar -- e o mapeia. Portanto, um buffer de um gigabyte alocado na inicialização e escrito pela primeira vez no caminho crítico contém aproximadamente um quarto de milhão de falhas de página, chegando uma por página, espalhadas pela primeira passagem sobre ele. Cada asserção de 'sem alocação' nesse caminho é satisfeita e cada uma delas está medindo a coisa errada: nada chamou o alocador.
A residência também não é permanente uma vez alcançada. As páginas podem ser recuperadas sob pressão de memória ou trocadas (swap), então um processo de longa duração que tocou seus buffers na inicialização e depois os deixou ociosos pode falhar novamente em um caminho que estava livre de falhas há uma semana. É por isso que o remédio tem duas metades e por que fazer apenas a primeira é um erro comum e caro.
Portanto, para qualquer coisa em um caminho com prazo: escreva em cada página que o caminho tocar, na inicialização, antes de qualquer trabalho real chegar -- o toque tem que ser uma escrita, já que uma leitura de uma página não tocada pode ser servida pela página zero compartilhada e não provar nada. Em seguida, prenda-a: mlockall(MCL_CURRENT | MCL_FUTURE) e sem swap na máquina. Na JVM, -XX:+AlwaysPreTouch faz a primeira metade para o heap e vale a inicialização mais lenta precisamente porque o custo é movido para fora do caminho que tem um limite.
Páginas grandes mudam a aritmética em ambas as direções e merecem sua própria decisão em vez de um padrão. Menos páginas, maiores, significa menos falhas e menos pressão na TLB; cada falha é maior, e páginas grandes transparentes trazem um caminho de desfragmentação que pode parar uma thread por milissegundos no exato momento errado. Páginas grandes explícitas reservadas na inicialização evitam isso; deixar THP em always e esperar é a forma que este domínio continua tendo que nomear.
Toque a partir da thread que será a dona da memória, não da thread que está fazendo a configuração. Em uma máquina multi-socket, a primeira escrita também decide a colocação: uma página é alocada no nó de quem sofre a falha, então a maneira óbvia de satisfazer esta regra -- um loop na thread de inicialização percorrendo cada buffer -- coloca cada página no nó daquela thread, e um trabalhador fixado no outro socket então lê tudo através da interconexão com aproximadamente o dobro da latência local. O caminho agora está livre de falhas e inteiramente remoto: um estado estacionário mais lento comprado com uma primeira mensagem mais rápida. Fixe as threads proprietárias primeiro, então deixe cada uma tocar o que usará.
Verificação de modo de falha: cada página que este caminho tocar foi escrita, pela thread que a lerá, e o que impede que seja recuperada? Três perguntas, porque responder apenas à primeira deixa um sistema que é rápido até ficar ocioso, e responder apenas às duas primeiras deixa um que é rápido em um único socket.
[R:sem-alocação-no-caminho-crítico] é o irmão e nenhum substitui o outro -- aquele é sobre entrar no alocador, este sobre a memória que ele já retornou. [R:responda-ao-requisito-em-sua-camada] é por que os remédios são todos uma política de memória, um bloqueio ou um parâmetro de inicialização: nenhuma escolha de contêiner ou crate alcança isso. [R:sem-parada-dentro-de-uma-janela-de-publicação] é onde morde mais forte, porque uma falha de página sofrida entre dois armazenamentos de um escritor mantém a janela aberta por toda a sua duração.
Responda a um requisito na camada em que ele vive [R:responda-ao-requisito-em-sua-camada]
Escrito para o público de Rust e Java.
Declare em qual camada um requisito vive antes de escolher qualquer coisa e satisfaça-o lá.
Um requisito sobre a máquina -- não perturbe um núcleo isolado vizinho, não cause uma falha de página no caminho de envio, não adquira um bloqueio pelo qual uma thread de tempo real dispute -- não é respondido escolhendo um crate mais rápido, um transporte de melhor reputação ou uma API assíncrona. Essas são decisões de nível de biblioteca. Elas podem ser corretas e podem até ajudar, mas não mantêm uma restrição de nível de sistema, e tratá-las como se o fizessem converte uma questão aberta em uma fechada com nada por trás.
A falha não é a escolha errada; é a entrada de contabilidade errada. Uma restrição marcada como satisfeita deixa de ser examinada. Uma restrição aberta ainda ser examinada. [R:sem-chamada-de-sistema-em-um-caminho-limitado] é sobre a camada de sistema; [R:sem-alocação-no-caminho-crítico] é sobre o alocador de tempo de execução; [R:alocado-não-é-residente] é sobre o subsistema de memória física; e [R:uma-estrutura-mantém-o-regime-sob-o-qual-foi-provada] é sobre o modelo de memória da linguagem. Nenhum deles é resolvido com 'async/await' ou ConcurrentHashMap.
O padrão recorrente: uma restrição na camada de sistema é frequentemente encontrada observando um efeito sintomático em uma camada superior, mas a correção não existe lá. A latência alta na fila de rede não é resolvida com um pool de threads maior; a perda de cache alto não é resolvida com um tipo de dados diferente. A lição, repetida em quase todo este domínio, é que a camada do efeito raramente é a camada da causa, e a camada da causa é a única que pode conter a correção.
Verificação de modo de falha: em qual camada este requisito realmente vive? Se a resposta for "no sistema operacional" ou "no hardware", pare de escolher bibliotecas e comece a escolher parâmetros de configuração, flags de compilador e medidas de isolamento.
Sem alocação no caminho crítico [R:sem-alocação-no-caminho-crítico]
Escrito para o público de Rust e Java.
Escrito a partir do conselho de Cliff Click sobre coleta de lixo e compilação JIT: "alocar é barato até não ser", e a observação de Martin Thompson de que o custo de alocação não é o
new, é a coleta.
Não aloque no caminho quente. A alocação em si não é gratuita, mas seu custo marginal é baixo; o problema é o que ela torna inevitável.
Em um runtime gerenciado, alocar no caminho quente garante coleta de lixo no caminho quente. O custo do new não é o new -- é a descoberta eventual de que a memória acabou, que desencadeia uma pausa de mundo inteiro, que para threads não relacionadas, que elimina o cache de instruções, que atrasa o processamento de rede e que, se o pressionar por tempo suficiente, acabará fazendo com que o GC seja executado com tanta frequência que o trabalho útil nunca é concluído.
Em um sistema sem GC (Rust), os efeitos são diferentes: o alocador de reserva (jemalloc, mimalloc) toma bloqueios internos, o que significa que a alocação em um caminho quente de thread única pode ser barata, mas a alocação em um caminho quente multiprocessado não é. O cache de bloco do alocador é um recurso compartilhado e, sob contenção, a latência de alocação dispara. E alocado não é residente aplica-se recursivamente: alocar no caminho quente significa que a página virtual para o bloco pode não ser mapeada, adicionando uma falha de página à latência de alocação.
A correção direta é pré-alocar: pools de objetos, buffers de anel, alocação de arena. Mas onde a pré-alocação é muito complexa (a estrutura exata do objeto não pode ser conhecida antecipadamente), a abordagem correta é mover a alocação para um caminho de configuração separado que seja executado antes que qualquer trabalho comece e do qual o caminho quente é garantidamente livre. Se você não pode responder "este caminho nunca chama malloc" sem uma qualificação, o caminho falhou em sua garantia.
O remédio não é "alocar menos" -- é "não alocar neste caminho, ponto final".
Toda espera em uma fila compartilhada introduz latência e esconde o verdadeiro gargalo [R:sem-loop-de-repetição-em-um-caminho-disputado]
Escrito para o público de Rust e Java.
Escrito a partir das palestras de Martin Thompson sobre mecânicas de concorrência e do princípio de que "espera ocupada é só um nome diferente para consumo de CPU enquanto espera".
Não repita uma operação em um recurso compartilhado esperando que ele mude de estado. A repetição não resolve a contenção; apenas a consome.
Um loop que tenta adquirir um bloqueio ou ler um sinalizador de estado repetidamente (spin-lock) até ter sucesso está consumindo ciclos de CPU que poderiam estar sendo usados pela thread que mantém o recurso. Sob contenção, isso piora o problema que está tentando resolver: a thread em espera ocupa um núcleo, possivelmente impedindo que a thread proprietária execute e complete sua operação. O resultado é um sistema parecendo ocupado enquanto faz progresso zero.
Os remédios dependem do contexto. Em um sistema operacional geral, futex ou park/unpark (ou seus equivalentes em Java LockSupport.park) são a abordagem correta: a thread em espera cede seu quantum e não é agendada novamente até ser notificada. Em um sistema de núcleo único de baixa latência (isolado, sem interrupções), a espera ocupada pode ser a abordagem correta, desde que o tempo de espera esperado seja da ordem de nanossegundos e a carga de trabalho seja conhecida como de previsível. Mas a espera ocupada não deve ser o padrão.
O loop de repetição também aparece em contextos não obviamente de espera: tentar ler de um socket não bloqueante e repetir se EWOULDBLOCK for retornado; tentar enfileirar em uma fila SPSC e repetir se estiver cheia; tentar alocar de um pool e repetir se estiver vazio. Em cada caso, a repetição transfere o custo do operação falhada para a latência do chamador e, sob contenção, torna a fila ou o pool um gargalo pior.
A regra é: uma operação tentada em um recurso compartilhado que não está pronto deve desistir e notificar o chamador ou bloquear (no nível do SO). Repetir não é uma opção.
Uma janela de publicação não deve conter uma parada [R:sem-parada-dentro-de-uma-janela-de-publicação]
Escrito para o público de Rust e Java.
A janela de publicação é o intervalo entre dois armazenamentos que formam uma operação atômica em um protocolo de passagem de mensagens sem bloqueio. Enquanto a janela está aberta, o estado é inconsistente e nenhum consumidor deve lê-lo.
Não faça nada que possa parar ou atrasar a execução entre as duas escritas que formam uma operação de publicação sem bloqueio.
Em algoritmos sem bloqueio, uma 'publicação' é frequentemente uma sequência de armazenamentos: os dados são escritos primeiro, depois um ponteiro ou contador de versão é armazenado para sinalizar sua disponibilidade. O intervalo entre essas escritas é a janela de publicação. Se um consumidor lê durante esta janela, ele vê um estado inconsistente. Os mecanismos para evitar isso (barreiras de memória, marcas de versão) são para gerenciar a visibilidade, não para encurtar a janela. Encurtar a janela significa reduzir o número de operações que podem atrasá-la.
Portanto, dentro da janela de publicação: sem alocações ([R:sem-alocação-no-caminho-crítico]), sem bloqueios, sem falhas de página ([R:alocado-não-é-residente]), sem chamadas de sistema ([R:sem-chamada-de-sistema-em-um-caminho-limitado]), sem tanques de iteração longa. Se a publicação falhar (por exemplo, compare-and-swap falha), a janela inteira pode precisar ser repetida, e repetir também não deve tomar bloqueios ou alocar.
A regra é um corolário da observação de que a janela de publicação é o período mais frágil em um caminho sem bloqueio: qualquer coisa que possa parar o editor mantém a janela aberta para uma duração arbitrária, e uma janela mantida aberta é uma promessa de que um consumidor verá um estado inválido.
Nenhuma chamada de sistema em um caminho limitado [R:sem-chamada-de-sistema-em-um-caminho-limitado]
Escrito para o público de Rust e Java.
Escrito a partir da observação de que transições de modo kernel são ordens de magnitude mais caras do que qualquer operação de modo de usuário e que seu custo é imprevisível.
Não faça chamadas de sistema em um caminho cuja latência máxima é limitada.
Uma chamada de sistema (read, write, sendto, recvfrom, epoll_wait, mmap, munmap) envolve uma transição para o kernel, uma troca de contexto (no mínimo), e o kernel pode decidir bloquear a thread ou executar trabalho em nome dela (escrita em disco, envio de pacote de rede). O custo de uma chamada de sistema não é uma constante pequena, é uma variável que pode ter ordens de magnitude de variação, dependendo do estado do kernel naquele momento.
Portanto, um caminho limitado não pode conter uma chamada de sistema, a menos que o pior caso da chamada de sistema seja garantidamente menor que o orçamento de latência restante. Mas tal garantia não existe para chamadas de sistema comuns: o kernel não fornece limites superiores para suas próprias operações. A única garantia é que não haverá chamada de sistema.
O que substitui as chamadas de sistema? Pré-alocar e fixar ([R:alocado-não-é-residente]), usar buffers de anel e memória compartilhada para E/S, usar rotação de polling em vez de epoll para redes de baixa latência, e usar io_uring (onde disponível e onde o caminho de submissão é sem bloqueio). Mas a regra permanece: se um caminho é limitado, nenhuma chamada de sistema.
Esta regra também cobre gettimeofday e clock_gettime no Linux, que costumavam ser chamadas de sistema e agora são frequentemente vdso (executando em modo de usuário), mas nem sempre. Verifique qual mecanismo seu SO e sua biblioteca usam. E futex (e suas variantes) são chamadas de sistema; então bloquear (como em bloqueio de kernel) em um caminho limitado é uma chamada de sistema, e qualquer biblioteca que esconda futex atrás do que parece ser uma função de modo de usuário ainda está fazendo uma chamada de sistema quando o bloqueio é disputado.
Sem omissão coordenada [R:sem-omissão-coordenada]
Escrito para o público de Rust e Java.
Escrito a partir do artigo de Gil Tene sobre o assunto e das observações que levaram ao HdrHistogram.
Não omita as amostras de latência durante o período em que o sistema está sob carga. A omissão coordenada é quando o instrumento de medição não coleta amostras durante os eventos que causam latência.
O padrão clássico: um benchmark envia uma solicitação, espera a resposta e imediatamente envia a próxima. Se a solicitação leva 10ms, a taxa é de 100 solicitações/s. Se o sistema fica sobrecarregado e uma solicitação leva 1s, o benchmark coleta uma amostra de 1s e espera 1s antes de enviar a próxima. Durante esses 1s, o sistema se recupera e a próxima solicitação é processada em 10ms. O resultado é que o benchmark coleta uma amostra de 1s e milhares de amostras de 10ms, e o percentil 99,9 é baixo -- mas no mundo real, um sistema que leva 1s para responder uma solicitação está sob carga, e as próximas mil solicitações chegam em rajadas (porque ninguém espera a resposta para enviar a próxima). A latência real que os usuários veem é muito maior do que o benchmark relata.
A correção é coletar amostras de latência de forma assíncrona (por exemplo, usando um loop de polling separado que registra o tempo e o índice da solicitação) ou usar a coordenada de saída (registro de tempo no ponto de envio e no ponto de recebimento). A falha está no design da medição, não no sistema que está sendo medido.
Sem compartilhamento falso em uma linha quente [R:sem-compartilhamento-falso-em-uma-linha-quente]
Escrito para o público de Rust e Java.
Escrito a partir do entendimento da coerência de cache e do protocolo MESI, onde escrever em uma linha de cache invalida essa linha em todas as outras CPUs.
Não coloque dois campos que são escritos por threads diferentes na mesma linha de cache.
Quando duas threads escrevem em endereços que residem na mesma linha de cache, o hardware de coerência de cache força cada escrita a invalidar a linha na outra CPU. A próxima leitura por qualquer thread sofre um cache miss e precisa buscar a linha da outra CPU ou da memória principal. Isso é compartilhamento falso: nenhuma das threads lê ou escreve o campo da outra, mas o hardware as trata como se estivessem compartilhando, porque estão na mesma linha.
Os sintomas são uma lentidão inexplicável que escala com o número de threads, não com o trabalho. A correção é preencher (ou alinhar) os campos para que não compartilhem uma linha de cache. Em Java, @Contended (JEP 142). Em Rust, #[repr(align(64))] ou std::cell::CachePadded. A regra se aplica a quaisquer campos hot (escritos com frequência) que pertencem a threads diferentes.
Verifique se a abstração foi compilada [R:verifique-se-a-abstração-foi-compilada]
Escrito para o público de Rust e Java.
Escrito a partir da observação de que os compiladores JIT e otimizadores podem remover código que parece ser trabalho, mas que não tem efeito observável.
Um benchmark que cronometra uma operação que o otimizador eliminou não está medindo nada, mas relata um número pequeno e o programador interpreta como "rápido".
Em linguagens de alto nível, especialmente na JVM, o compilador JIT pode provar que o resultado de um cálculo não é usado, ou que a computação pode ser reduzida a uma constante, e remover todo o trabalho. O benchmark então cronometra a sobrecarga do loop e do chamado, não o trabalho real.
A correção é usar o resultado da operação de forma que o otimizador não possa removê-la. Em JMH, Blackhole.consume ou retornar o resultado do método. Em Rust, std::hint::black_box impede que o otimizador prove que um valor não é usado. Em benchmarks feitos à mão, o padrão de erro é: cronometrar a + b e não usar a + b.
Esta regra é um complemento a [R:uma-medição-corresponde-ao-regime-que-relata]: mesmo que a medição seja feita quente, se o otimizador removeu o trabalho, o número quente descreve uma operação que não existe na produção.
Meça a alegação, não um subconjunto [R:meça-a-alegação-não-um-subconjunto]
Escrito para o público de Rust e Java.
Escrito a partir do princípio de que uma medição só é válida se mede exatamente o que está sendo alegado, e não uma versão simplificada.
Quando você alega "X é mais rápido que Y", você deve medir o tempo de X e Y em condições idênticas, executando o trabalho X e Y completos. Medir apenas uma parte de X ou Y e comparar é inválido.
Exemplo: alegar que um algoritmo de classificação A é mais rápido que B em arrays pequenos, e então medir apenas o tempo de loop interno de A versus o loop interno de B, ignorando a sobrecarga de chamada de função, a inicialização e a alocação. A alegação é sobre o algoritmo completo, não sobre seu núcleo.
O erro é sutil porque parece mais rigoroso: isolar a parte "importante" do cálculo e cronometrá-la. Mas se essa parte não é o que o usuário chama, a medição não corresponde ao que o usuário experimenta. A medição deve corresponder à operação que está sendo alegada, não a uma sub-operação que se supõe ser o gargalo.
Esta regra envolve a maioria das outras neste domínio: [R:uma-medição-corresponde-ao-regime-que-relata] é sobre escopo de tempo; esta é sobre escopo de trabalho. [R:responda-ao-requisito-em-sua-camada] é sobre camada de abstração; esta é sobre escopo de operação.
Garantia precisa de um leitor [R:garantia-precisa-de-um-leitor]
Escrito para o público de Rust e Java.
Escrito a partir da observação de que uma garantia não documentada é uma garantia que não existe, e uma garantia documentada apenas em um comentário é uma garantia que ninguém verá.
Uma garantia de correção que é transmitida apenas através de documentação (comentários, README, palavras do autor) não é uma garantida. O código deve impor a garantia onde possível (tipos, asserções) e onde não é possível, a documentação deve ser tão proeminente que não possa ser perdida.
Em Rust, o sistema de tipos pode impor garantias: Send, Sync, &mut, unsafe. Em Java, anotações como @Immutable, @ThreadSafe e asserções de runtime são o mecanismo disponível. Mas uma regra documentada apenas em um comentário de cabeçalho, longe do local onde é violada, não é uma garantia.
O caso clássico: uma estrutura documentada como "não reentrante" que é usada de um callback ou sinalizador. O comentário está no cabeçalho da estrutura; o código que a viola está em uma função em outro arquivo. Nenhum mecanismo impede a violação. O resultado é um bug intermitente que aparece apenas sob condições específicas.
A lição: se uma garantia não pode ser codificada, coloque a garantia perto de seu ponto de violação, torne-a executável (asserção) e torne-a tão barulhenta que não possa ser ignorada.
Ataque o design em uma segunda passagem [R:ataque-o-design-em-uma-segunda-passagem]
Escrito para o público de Rust e Java.
Escrito a partir da observação de que um design é frequentemente otimizado para sua primeira implementação e não para a carga de trabalho de produção.
Não pare no primeiro design que funciona. Pegue o design que você tem e ataque-o: onde uma suposição poderia estar errada, onde uma otimização poderia ser introduzida, onde uma abstração poderia esconder custo, onde a incerteza poderia ser eliminada.
Uma segunda passagem não é sobre otimização prematura, é sobre remover a ingenuidade do primeiro rascunho. O primeiro design é frequentemente o mais óbvio, e o mais óbvio é frequentemente o mais caro. Uma segunda passagem não é sobre micro-otimização, é sobre verificar suposições: suposições sobre frequência de caminho, sobre contenção, sobre alocação, sobre regimes.
Após a segunda passagem, o design está mais próximo de sua forma de produção, e é esse design que deve ser medido, não o primeiro rascunho. Medir o primeiro rascunho e otimizar é perder tempo; medir o design atacado e otimizar é o trabalho real.
Esta regra é um meta-princípio: todas as outras regras neste domínio são verificações que devem ser aplicadas durante a segunda passagem, não durante a primeira.
A geometria de uma linha de cache não muda com o tempo [R:a-geometria-de-uma-linha-de-cache-não-muda-com-o-tempo]
Escrito para o público de Rust e Java.
Escrito a partir da observação de que a linha de cache é uma unidade fundamental e imutável de coerência e que sua largura (tipicamente 64 bytes) é uma constante no modelo de programação.
Não projete estruturas de dados concorrentes que dependam de um tamanho de linha de cache diferente do real, e não assuma que o layout da memória permanece o mesmo entre execuções se o compilador, o alinhamento ou o layout do objeto for alterado.
A linha de cache é a unidade de transferência e coerência. Dois campos que devem ser escritos independentemente devem estar em linhas diferentes. Dois campos que devem ser lidos juntos devem estar na mesma linha. Um campo que é apenas lido por muitas threads pode estar na mesma linha que um campo que é apenas escrito por uma, e isso pode ser um problema ou um benefício, dependendo do padrão de acesso.
A regra é sobre consciência: saiba o tamanho da sua linha de cache e verifique se seu layout a respeita. Use @Contended (Java) ou CachePadded (Rust) para separar, e garanta que estruturas de dados destinadas a serem lidas juntas estejam no mesmo bloco de cache.
O design é para o enésimo percentil, não para a mediana [R:o-design-é-para-o-enésimo-percentil-não-para-a-mediana]
Escrito para o público de Rust e Java.
Escrito a partir da observação de que projetar para a mediana ignora a cauda, e a cauda é onde as falhas vivem e onde os usuários experimentam o sistema no seu pior.
Não projete para o caso comum apenas. Projete para o enésimo percentil que seu sistema precisa suportar. Se o requisito é que 99,9% das solicitações sejam servidas dentro de 5ms, o design deve ser verificado nesse percentil, não na mediana.
A mediana esconde todo o sofrimento. Um sistema pode ter uma mediana excelente e um percentil 99,9 terrível, e o percentil 99,9 é o que os usuários experimentam como "lento" ou "quebrado". A mediana é para relatórios; o enésimo percentil é para design.
Esta regra se aplica a toda a latência, filas, alocação, páginas, conteúdo -- tudo deve ser projetado para o percentil do sistema, não para o caso comum.