31% menor e até 83% mais rápido que o LZ4
Engenharia Desempenho 11 de outubro de 2026

31% menor e até 83% mais rápido que o LZ4

Um decodificador que lê os blocos do liblz4 mais rápido que o próprio liblz4 em 20 de 27 perfis medidos, além de um formato nativo opcional 31% menor. A adoção começa com uma única chamada de função alterada, medida em quatro processadores com todas as derrotas publicadas.

C
Christian Schnapka
Macstab Engineering
6 min de leitura
Também no Medium Ler no Medium

Código-fonte no GitHub

O primeiro passo não custa nada

O lzvolt lê os blocos do próprio LZ4: não um formato parecido, mas exatamente os bytes que o liblz4 escreveu, de acordo com a especificação de blocos do LZ4. Em 20 de 27 perfis medidos, ele os lê mais rápido que o próprio liblz4.

Portanto, a adoção mais barata é: manter o liblz4 como compressor, trocar o decodificador e parar por aí. LZ4_decompress_safe passa a ser lzvolt_decompress_lz4, com os mesmos argumentos. Os dados armazenados não se movem, o caminho de escrita fica intocado e não há nada a reverter, porque nada foi convertido. Para muitos sistemas esse é o lugar certo para ficar: decodifica-se mil vezes para cada escrita.

Recodificar para o formato nativo é um segundo passo, opcional. É aí que estão os 31% e o fator dois. E ele é reversível: decodificar com o novo, comprimir com o antigo. O caminho de volta é limitado pelo codificador do liblz4 — 2,51 GiB/s contra 11,86 —, de modo que um terabyte de dados lógicos volta atrás em cerca de sete minutos-núcleo.

Duas regras se você converter. Primeiro os leitores: um decodificador LZ4 rejeita corretamente um fluxo nativo, então uma única réplica não reiniciada estraga o dia. E enquanto os dois formatos coexistirem, mantenha um indicador por valor: propositalmente não existe número mágico, então um blob não pode ser identificado pelo conteúdo.

Diagrama: bytes do liblz4 convertidos para o formato nativo e de volta para bytes do liblz4, com duas chamadas em cada sentido
A porta abre para os dois lados: nada é recodificado a menos que você queira, e voltar atrás é decodificar mais LZ4_compress.

O que você ganha: dois ganhos que se multiplicam

Capacidade e vazão se multiplicam em vez de se somarem: 44,7% mais dados ativos na mesma RAM e 36% mais rápido por núcleo. Em registros de 64 KiB, esse produto é +101,5% no M2 Max, +106,6% no Neoverse V2, +96,3% no Xeon e +79,1% no EPYC. Em termos de compras, um cache que mantém um dado working set a uma dada taxa de leitura precisa de cerca de metade das máquinas.

Isso também não se limita ao perfil que melhor comprime. Em dados variados de 64 KiB, a capacidade é 1,4% pior que a do LZ4, e a combinação ainda fica entre +62% e +89%, sustentada apenas pela decodificação.

Além disso, meus tamanhos incluem um cabeçalho de comprimento, enquanto os blocos crus do LZ4 não carregam comprimento algum. Um valor LZ4 independente significa o formato frame, que custa 23 bytes fixos. A única célula de registros que eu perco — 512 bytes, por 1,6% — eu ganho por 9,3% contra um valor LZ4 autocontido.

Gráfico: +44,7% de capacidade vezes +36% de vazão, e o ganho combinado no M2 Max, Neoverse V2, Xeon 8481C e EPYC 9B14
Capacidade e vazão se compõem. Em registros de 64 KiB, o produto fica perto do fator 2 nas quatro máquinas.

Como é feito: três decisões sobre bits

Nenhum algoritmo novo. LZ77 com as mesmas duas primitivas que todo mundo usa, sequências de literais e referências para trás, mais três decisões sobre como gastar bits que as implementações habituais não tomam.

Duas formas de token. O token do LZ4 é um byte: quatro bits de comprimento de literais, quatro de comprimento de correspondência. Em registros variados de 4 KiB, 49% dos comprimentos de correspondência ficam abaixo do teto de quatro bits e 47% acima — exatamente em cima da linha, de modo que o branch decisivo muda de direção o tempo todo e o preditor é inútil. Por isso o formato tem dois layouts, e um fluxo pode alternar entre eles exatamente uma vez. O layout largo troca dois bits de literais por um quinto bit de correspondência, levando o escape de 19 para 35 bytes, mais um bit para um indicador de repetição: o mesmo offset da última vez.

O comprimento vive no fluxo. Um cabeçalho de um a quatro bytes, construído como os inteiros de comprimento variável do QUIC, carrega o comprimento decodificado, e a codificação mais curta é obrigatória. O decodificador aloca uma única vez, com exatidão, e trata um truncamento como erro em vez de uma resposta curta plausível.

O compressor recusa. Uma sondagem rápida descarta dados não repetitivos sem executar nenhuma busca de correspondências, e um limiar de um oitavo decide o resto. Abaixo dele, o valor é armazenado cru e compress retorna false, porque ler um valor comprimido custa de quatro a cinco vezes mais do que ler um valor armazenado. O liblz4 não tem essa etapa.

text
saved >= 8 && saved * 8 >= original
Layout de bits da divisão par, idêntica ao token do LZ4, e da divisão larga com bit de repetição
Um byte, dividido de duas maneiras. Um fluxo começa par e pode passar para largo exatamente uma vez, numa posição registrada.

Por que é rápido

Um decodificador é um laço sobre sua entrada. Em registros de 64 KiB, o meu percorre 5524 bytes onde o liblz4 percorre 7992, para os mesmos 65536 de saída. Dê a um laço 31% menos entrada e você terá feito 31% menos de quase tudo. O ganho de tamanho e o ganho de velocidade são um único resultado medido duas vezes.

Mais três mecanismos. Um terço dos blocos nunca carrega um offset: 493 de 1567 levam o indicador de repetição. Um único load de 32 bits traz token, offset e comprimento de correspondência quando a sequência de literais está vazia: 56% dos blocos em 4 KiB variados, 88% em registros de 64 KiB. E acima de um limiar, o memcpy supera um laço escrito à mão, porque o da glibc é ajustado por pessoas que não fazem outra coisa.

O kernel é limitado pela latência, não pela emissão: o laço de cópia roda a um IPC de 5,37 contra uma largura de emissão de seis. Remover uma instrução não rende nada; remover um load dependente da cadeia crítica rende três ciclos.

Uma ressalva honesta: não fiz ablação desses mecanismos. Os números individuais são medidos; a ordem de importância entre eles é uma inferência, não uma decomposição medida.

Os números, e o que eu não afirmo

Três máquinas, três execuções cada, mediana de três, intercaladas com o liblz4 em um único processo — mesmos bytes, mesma máquina, mesmo minuto. Decodificando o formato nativo: dezoito células, dezoito vitórias, de +14% a +83%. Lendo os blocos do próprio LZ4, vence 20 de 27; as sete derrotas se concentram em valores pequenos e incompressíveis no x86, onde cedo 12–15%.

Em tamanho, quatro de seis perfis são menores que com o LZ4 e dois são maiores: 64 KiB variados por 1,4%, registros de 512 bytes por 1,6%. O liblz4 é o pacote padrão da distribuição, enquanto do meu lado há assembly escrito à mão e selecionado por linha de processador — honesto para implantação, mas não uma comparação de esforço equivalente. Governador da CPU, turbo e fixação de núcleos não foram controlados, então trate margens de um dígito com desconfiança.

O codec traz cinco corpos de decodificador montados separadamente, porque mudanças idênticas mediram sinais opostos em processadores diferentes: alinhar a origem antes da cópia dá +49% no Neoverse V2 e −7,1% no M2 Max, ambos aarch64. Um número de benchmark sem o nome da máquina é ruído.

Medi contra o liblz4 e o lz4_flex — não contra o Selkie da Oodle, lzturbo, Snappy, LZO ou zstd em níveis negativos. Então, com precisão: no formato que a indústria padronizou, em quatro processadores, o LZ4 não é mais o que o lê mais rápido. É tudo o que os números sustentam.

Gráfico de barras da vantagem de vazão de decodificação sobre o liblz4 para seis perfis de dados no Xeon 8481C, EPYC 9B14 e Neoverse V2
Decodificando o formato nativo contra o liblz4: 18 de 18 células à frente, de +14% a +83%.

O que os testes verdes não encontraram

Cerca de vinte mudanças deliberadas deram resultado nulo ou negativo. Três sustentaram o projeto, e todas as três eliminaram trabalho em vez de baratear o trabalho existente.

O harness de medição mentiu quatro vezes, a última por aliasing de 4 KiB: mesmo binário, 32% de diferença, porque o malloc posicionava os buffers de forma diferente em cada processo. Agora ele usa uma única arena com offsets fixos, toma a mediana de cinco razões e imprime um limite de resolução de 6% por célula.

Três bugs sobreviveram a suítes de testes verdes, entre eles um decodificador que executava 512 bytes da própria tabela de consulta como código de máquina e uma violação de proveniência que só o Miri encontrou. Todo teste começava de um fluxo escrito pelo compressor; um atacante não tem essa restrição. Agora mudanças no assembly só são aceitas depois que uma corrupção deliberada deixa a suíte vermelha, e quatro alvos de cargo-fuzz rodaram 115.090.390 execuções sob AddressSanitizer sem falhas — o que não é prova de ausência.

A lição de fundo: cada problema tardio era uma afirmação sobre o código que ninguém havia executado. Execute a frase antes de escrevê-la.

Key Takeaways

lzvolt = "1.0" requer rustc 1.85 ou mais recente, e a release traz bibliotecas pré-compiladas .so, .dylib e .dll com cabeçalho C para Linux, macOS e Windows em amd64 e arm64. O Windows roda o Rust portável em vez do assembly e é mais lento; a API informa qual caminho você recebeu.

Comece com make quick: cerca de sete segundos, e ele imprime o nome do seu processador e um veredito. Depois aponte lzvolt_decompress_lz4 para blocos que você já tem, deixe o liblz4 comprimindo e compare. Nada recodificado, nada migrado — uma tarde para descobrir se estou errado. O formato tem uma especificação normativa e 16 vetores de teste para decodificadores, e o registro de medições no repositório contém cada número e todas as vinte refutações.

#compression #lz4 #rust #linux #performance
C

Christian Schnapka

Macstab Engineering

Christian Schnapka constrói as ferramentas de engenharia abertas por trás da Macstab. Ele publica as versões completas destas investigações no Medium, com cada medição, cada ressalva e cada derrota incluídas.