Jank Busters Parte Dois: Orinoco
Em um post anterior do blog, apresentamos o problema da queda de desempenho causada pela coleta de lixo que interrompe uma experiência de navegação fluida. Neste post do blog, introduzimos três otimizações que estabelecem as bases para um novo coletor de lixo no V8, codinome Orinoco. Orinoco é baseado na ideia de que implementar um coletor de lixo principalmente paralelo e simultâneo, sem limites geracionais rígidos, reduzirá a queda de desempenho na coleta de lixo e o consumo de memória enquanto fornece alta produtividade. Em vez de implementar Orinoco por trás de uma opção como um coletor de lixo separado, decidimos lançar os recursos do Orinoco incrementalmente no V8 diretamente a partir da árvore principal para beneficiar os usuários imediatamente. Os três recursos discutidos neste post são compactação paralela, processamento paralelo de conjunto registrado e alocação preta.
O V8 implementa um coletor de lixo geracional onde os objetos podem se mover dentro da geração jovem, da geração jovem para a geração antiga e dentro da geração antiga. Mover objetos é caro, pois a memória subjacente dos objetos precisa ser copiada para novos locais e os ponteiros desses objetos também precisam ser atualizados. A Figura 1 mostra as fases e como elas eram executadas antes do Orinoco. Essencialmente, os objetos eram movidos primeiro e os ponteiros entre esses objetos eram atualizados posteriormente, tudo em ordem sequencial, resultando em queda de desempenho observável.
O V8 divide sua memória de heap em blocos de tamanho fixo, chamados páginas, que são atribuídos ao espaço da geração jovem ou antiga. Os objetos são inicialmente alocados na geração jovem. Durante a coleta de lixo, os objetos vivos são movidos dentro da geração jovem uma vez. Os objetos que sobrevivem a outra coleta de lixo são promovidos para a geração antiga. Para ambas as fases, que chamamos coletivamente de evacuação da geração jovem, paralelizamos a cópia da memória com base nas páginas. Dentro da geração jovem, mover objetos sempre envolve alocar memória em páginas novas (e liberar as páginas antigas), resultando em um layout de memória compacto. Na geração antiga, esse processo ocorre de maneira ligeiramente diferente, pois a memória morta deixa buracos inutilizáveis (ou fragmentação). Alguns desses buracos podem ser reutilizados por meio de listas livres, mas outros são deixados para trás, exigindo compactação para mover objetos vivos para uma página melhor organizada (potencialmente nova). Semelhante à geração jovem, este processo é paralelizado em nível de páginas.
Como não há dependências entre evacuação da geração jovem e compactação da geração antiga, o Orinoco agora executa essas fases em paralelo, conforme mostrado na Figura 2. O resultado dessas melhorias é uma redução de 75% no tempo de compactação de ~7ms para menos de 2ms em média.
A segunda otimização introduzida pelo Orinoco melhora como a coleta de lixo rastreia os ponteiros. Quando um objeto muda de local no heap, o coletor de lixo precisa encontrar todos os ponteiros que contêm a localização antiga do objeto movido e atualizá-los com a nova localização. Como iterar pelo heap para encontrar os ponteiros seria muito lento, o V8 usa uma estrutura de dados chamada conjunto lembrado para rastrear todos os ponteiros interessantes no heap. Um ponteiro é interessante se aponta para um objeto que pode ser movido durante a coleta de lixo. Por exemplo, todos os ponteiros da geração antiga para a geração jovem são interessantes, porque os objetos da geração jovem se movem em cada coleta de lixo. Ponteiros para objetos em páginas altamente fragmentadas também são interessantes porque esses objetos serão movidos para outras páginas durante a compactação.
Anteriormente, o V8 implementava conjuntos lembrados como arrays de endereços de ponteiros, ou buffers de armazenamento. Havia um buffer de armazenamento para a geração jovem e um para cada página fragmentada da geração velha. O buffer de armazenamento de uma página contém endereços de todos os ponteiros de entrada, como mostrado na Figura 3. As entradas são anexadas a um buffer de armazenamento em uma barreira de escrita, que protege operações de escrita no código JavaScript. Isso pode resultar em entradas duplicadas, já que um buffer de armazenamento pode incluir um ponteiro várias vezes, e dois buffers de armazenamento diferentes podem incluir o mesmo ponteiro. Entradas duplicadas dificultam a paralelização da fase de atualização de ponteiros devido a condições de corrida de dados causadas por dois threads tentando atualizar o mesmo ponteiro.
O Orinoco remove essa complexidade reorganizando o conjunto lembrado para simplificar a paralelização e garantir que os threads obtenham conjuntos disjuntos de ponteiros para atualizar. Em vez de armazenar ponteiros interessantes de entrada em um array, cada página agora armazena os deslocamentos dos ponteiros interessantes originados dessa página em compartimentos de mapas de bits, como mostrado na Figura 4. Cada compartimento está vazio ou aponta para um mapa de bits de comprimento fixo. Um bit no mapa de bits corresponde a um deslocamento de ponteiro na página. Se um bit está definido, então o ponteiro é interessante e está no conjunto lembrado. Usando essa estrutura de dados, podemos paralelizar as atualizações de ponteiros com base em páginas. A ausência de entradas duplicadas e a representação densa dos ponteiros também nos permitiu remover códigos complexos para lidar com a saturação do conjunto lembrado. Em nosso benchmark de longa duração no Gmail, essa alteração reduziu o tempo máximo de pausa da coleta de lixo compactadora em 45%, de 42ms para 23ms.
A terceira otimização que o Orinoco introduz é a alocação negra, uma melhoria na fase de marcação do coletor de lixo. A alocação negra (implementada no V8 5.1) é uma técnica de coleta de lixo em que todos os objetos alocados na geração velha (por exemplo, alocações pré-tenured ou objetos promovidos pelo coletor de lixo) são marcados como pretos imediatamente para designá-los como "vivos". A intuição por trás da alocação negra é que objetos alocados na geração velha têm alta probabilidade de longa duração. Portanto, objetos que foram recentemente alocados na geração velha devem pelo menos sobreviver à próxima coleta de lixo da geração velha; caso contrário, foram promovidos de maneira equivocada. Após colorir os objetos recém-alocados como pretos, o coletor de lixo não os visitará. Aceleramos a coloração de objetos pretos alocando-os em páginas negras onde todos os objetos são pretos por padrão. Outro benefício das páginas negras é que elas não precisam ser varridas, já que todos os objetos alocados nelas são (por definição) vivos. A alocação negra acelera o progresso da marcação incremental, pois o trabalho de marcação não aumenta com novas alocações. O impacto da alocação negra é claramente visível no benchmark Octane Splay, onde a taxa de transferência e o score de latência melhoraram cerca de 30%, enquanto usaram cerca de 20% menos memória devido ao progresso mais rápido da marcação e menos trabalho geral de coleta de lixo.
Planejamos lançar mais recursos do Orinoco em breve. Fique ligado, ainda estamos ajustando!