Código de entropia rANS ou como escrever seu próprio arquivador

Este artigo pode ser de interesse para quem está envolvido em compactação de dados ou deseja gravar seu próprio arquivador.



O artigo está escrito principalmente nos materiais do blog , mantidos por Fabian Giesen.

1. Introdução


O método de codificação de entropia rANS ( r ange + ANS) é o irmão do algoritmo FSE, sobre o qual escrevi anteriormente . A abreviatura ANS significa Sistemas Numéricos Assimétricos , e o intervalo de palavras no nome sugere a semelhança desse método com a codificação por intervalo . O autor de rANS é Yarek Duda .

O método rANS permite alcançar uma compressão quase ideal a uma velocidade muito alta. Neste rANS não é pior que o FSE, o que não é surpreendente: ambos os algoritmos são baseados em uma base teórica comum. No entanto, o algoritmo rANS é muito mais simples de implementar que o FSE.

Primeiro, haverá uma longa parte "teórica", e depois tentaremos escrever um arquivador simples.

Descrição do método


A operação do algoritmo é determinada pelas seguintes fórmulas simples:

Codificação: C(s,x): x := (x / Fs) * M + Bs + (x % Fs)
Decodificação: D(x): s = sym[x % M], x := Fs * (x / M) + (x % M) - Bs

Vamos analisá-los em detalhes.

A função de codificação C (s, x) recebe o caractere s a ser codificado (seja um número inteiro) e o estado atual do codificador x (também um número inteiro).

F s - frequência do símbolo s . A divisão por Fs acima é um número inteiro.
M é a soma das frequências de todos os símbolos do alfabeto ( M = Σ F s ).
Em s , o início do intervalo correspondente ao caractere codificado (na figura abaixo).
x % Fs é o restante da divisão de x por F s .

O princípio de operação é o mesmo da codificação aritmética : pegamos o segmento [ 0, M) e o dividimos em partes para que cada caractere s corresponda a um intervalo de tamanho igual à frequência do caractere Fs . A ocorrência do valor x% M em qualquer intervalo indica a codificação do símbolo correspondente.


No início da codificação, inicialize x com um valor adequado arbitrário e calcule a função C (s, x) para todos os caracteres codificados em sequência.

Cada cálculo da função C (s, x) aumenta o valor de x . Quando ficar muito grande, você deve despejar os dados na saída:

 while (x >= x_max) {   writeToStream(x % b); //     x /= b; //  x } 

Esta etapa é chamada renormalização . Depois disso, você pode continuar a codificação.

Acima no código, novas constantes apareceram: x_max e b . Em teoria, os números M , be x_max são relacionados por algumas relações, mas na prática é mais eficaz usar os seguintes valores se o estado uint32 for usado para o estado x :

M = 2 ^ k , onde k <= 16
b = 2 ^ 16 (metade do tamanho de uint32)

A escolha de M = 2 ^ k se deve ao fato de haver divisão por M na função de decodificação, portanto, a divisão com o restante pode ser substituída por operações bit a bit.

O valor de k é selecionado dentre as seguintes considerações: quanto maior, maior a precisão de Fs e mais eficiente a compressão. Nesse caso, algumas despesas gerais para armazenar a tabela de frequências devem ser levadas em consideração, portanto nem sempre vale a pena usar os valores máximos de k .

O valor de x_max deve ser tal que nenhum estouro ocorra. Com base na função de codificação, obtemos x < uint32_max * Fs / M ou uma maneira ligeiramente diferente: x_max <= ( b * L ) * Fs / M , em que L <= uint32_max / b . No código real, a condição assume a forma x / b> = L / M * Fs para evitar excesso nos cálculos.

O valor b = 2 ^ 16 (metade do tamanho de uint32) é escolhido de tal maneira que se x excede x_max , uma divisão por b é suficiente para continuar trabalhando. Como resultado, o while terminará após a primeira iteração, o que significa que pode ser substituído por um simples if .

Como resultado, a função de codificação assume o seguinte formato:

 typedef uint32_t RansState; constexpr uint32_t RANS_L = 1u << 16; constexpr uint32_t k = 10; //   constexpr uint32_t RANS_M = 1u << k; // M = 2^k //   s void RansEnc(RansState& x, uint32_t s, RansOutBuf& out) {   assert(x >= RANS_L); //        uint32 Fs = freq[s]; //   s   uint32 Bs = range_start[s]; //   s   assert(Fs > 0 && Fs <= RANS_M);     // renormalize   if ((x >> 16) >= (RANS_L >> k) * Fs) { // x / b >=  L / M * Fs       out.put( x & 0xffff );       x >>= 16;   }   x = ((x / Fs) << k) + Bs + (x % Fs); // C(s,x)     assert(x >= RANS_L); //      } 

No final da codificação, você deve salvar o valor de x , pois a decodificação será iniciada a partir dela. E sim, decodificaremos do fim ao começo, ou seja, do último caractere codificado ao primeiro. (Em um artigo sobre o FSE, este ponto é explicado em detalhes suficientes.)

Quero me aprofundar um pouco mais em como a fórmula de codificação funciona.

 x := (x / Fs) * M + Bs + (x % Fs) 

Após calcular ( x / Fs) * M , a variável x contém os k bits menos significativos (lembre-se de que M = 2 ^ k ). A adição de + Bs + (x % Fs) grava essencialmente nesses bits um determinado valor no intervalo do caractere s , porque Bs é o início do intervalo e (x% Fs) é o número nesse intervalo (o tamanho do intervalo é Fs). Assim, ao decodificar, podemos determinar o caractere codificado pelo intervalo em que ele cai (x% M).

Decodificação

Vamos para a função de decodificação.

 D(x): s = sym[x % M], x := Fs * (x / M) + (x % M) - Bs 

Como já entendemos acima, o caractere desejado s é determinado pelo restante da divisão x % M. Em que intervalo o valor (x% M) caiu, esse caractere foi codificado.

Anteriormente, definimos especificamente M = 2 ^ k para simplificar a função de decodificação. Acabou assim:

 uint32_t RansDecode(RansState& x, RansInBuf& in) {   assert(x >= RANS_L); //       uint32_t x_mod = x & (RANS_M - 1); // = x % M   //  ,    x_mod,     assert(x_mod < dct.size());   uint32_t s = dct[x_mod]; //     uint32 Fs = freq[s]; //   s   uint32 Bs = range_start[s]; //    s   x = (x >> k) * Fs + x_mod - Bs;     // renormalize   if (x < RANS_L) {       x = (x << 16) | in.read16(); //  16    }     assert(x >= RANS_L); //     return s; } 

A decodificação começa com o mesmo x que foi obtido no final da codificação. Para fazer isso, ele deve ser salvo junto com os dados codificados.

No final da decodificação, o estado do decodificador x deve ser exatamente o mesmo que a codificação. Em geral, em cada etapa x deve ser exatamente o mesmo que na etapa de codificação correspondente. Esse fato ajuda muito na depuração.

Como você pode ver, a decodificação funciona mais rápido que a codificação, pois não há operações de divisão.

O momento mais difícil na função de decodificação é o método para determinar em qual intervalo o valor caiu (x% M).

O método mais fácil e rápido é usar a tabela sym [] , tamanho M. Nesse caso, obtemos uma tabela do mesmo tamanho que no algoritmo FSE, com a diferença de que, em rANS, a tabela não está “misturada”, os caracteres estão em ordem e essa tabela é muito mais fácil de construir.

A principal desvantagem dessa abordagem é o tamanho da tabela sym , que cresce exponencialmente com o aumento de k .

Método de alias


Um método alternativo foi inventado para determinar com mais eficiência o acerto em um intervalo. Este método permite determinar rapidamente o intervalo desejado usando pequenas tabelas - pelo número de caracteres no alfabeto.


Uma explicação longa e longa pode ser encontrada aqui: Dardos, Dados e Moedas . Descreverei a essência do método resumidamente: pegamos um pedaço do intervalo mais longo e o anexamos ao intervalo mais curto para que o tamanho total seja exatamente M / N (onde N é o número de caracteres no alfabeto). Acontece que, se você fizer isso sequencialmente, sempre terminará com N pares de tamanho M / N.

Naturalmente, M deve ser divisível por N. E se lembrarmos que temos M = 2 ^ k , então o tamanho do alfabeto também será uma potência de dois. Isso não é um problema, pois você sempre pode suplementar o alfabeto ao tamanho desejado com caracteres não utilizados com uma frequência zero.

O fato de o intervalo de caracteres ser dividido em várias partes complica um pouco o procedimento de codificação, mas não muito. Se você se lembra do FSE, então os intervalos eram geralmente espalhados por todo o intervalo, como se um mixer louco tivesse trabalhado neles e nada funcionasse =)

Determinar o intervalo desejado não é difícil: divida x por N e caia em um dos pares. Em seguida, comparamos o restante da divisão de x% N com o limite entre os segmentos em um par e caímos em um intervalo ou em outro.

Tentamos na prática


Usaremos o código do exemplo finalizado .

Tomamos os dados para compactação do arquivo:
 size_t in_size; uint8_t* in_bytes = read_file("book1", &in_size); 

1. Primeiro, você precisa decidir sobre a estrutura de dados .

Usamos a opção mais simples: codificaremos um byte usando o alfabeto [0 ... 255].

2. O próximo passo é determinar a frequência dos caracteres do alfabeto:

(função stats.count_freqs )
 for (size_t i = 0; i < in_size; i++) {   freqs[in_bytes[i]]++; } 

3. Portanto, temos frequências de símbolos, mas agora precisamos normalizá-las , isto é, reduzir (ou aumentar) proporcionalmente para que, no total, obtenha M = 2 ^ k. Esta não é uma tarefa tão simples como pode parecer, por isso usamos uma função pronta:

 stats.normalize_freqs(...); 

A propósito, você precisa determinar o valor de k . Como nosso alfabeto consiste em 256 caracteres, k menor que 8 não deve ser utilizado. Primeiro, use o máximo - 16 e depois tente outros valores.

4. Crie uma tabela de alias :

 stats.make_alias_table(); 

5. Codificamos a partir do final e decodificamos na ordem normal.

 RansState rans; //  rANS,    x RansEncInit(&rans); //    uint8_t* ptr = out_buf + out_max_size; // *end* of output buffer for (size_t i = in_size; i > 0; i--) { // NB: working in reverse!   int s = in_bytes[i - 1];   RansEncPutAlias(&rans, &ptr, &stats, s, prob_bits); } //   .     . RansEncFlush(&rans, &ptr); 

Além disso, o exemplo por referência decodifica dados compactados usando estatísticas prontas. Na vida real, para decodificação, você precisa salvar uma tabela de frequências (estatísticas) junto com os dados compactados. No caso mais simples, você terá que gastar N * k bits nele.

Como prometido acima, vejamos os resultados da compactação para vários valores de k (no código é prob_bits ), levando em consideração o aumento de tamanho devido ao registro da tabela de frequências:

( Tamanho original do arquivo book1: 768771)
k = 16: 435059 + 512 = 435571 bytes
k = 15 : 435078 + 480 = 435558 bytes
k = 14: 435113 + 448 = 435561 bytes
k = 13: 435239 + 416 = 435655 bytes
k = 12: 435603 + 384 = 435987 bytes
k = 11: 436530 + 352 = 436882 bytes
k = 10: 440895 + 320 = 441215 bytes
k = 9: 453418 + 288 = 453706 bytes
k = 8: 473126 + 256 = 473382 bytes

Você pode ver que quanto maior k, melhor a compactação. Mas em um certo ponto (em k = 16), a sobrecarga da tabela de frequências começa a superar os benefícios de aumentar a precisão. Se você compactar um arquivo menor, esse efeito aparecerá em k menor.

Você também precisa dizer algumas palavras sobre o truque chamado "rANS intercalados", que também é implementado neste exemplo . A idéia é que, alternadamente, o uso de duas variáveis ​​de estado independentes faça melhor uso do paralelismo do processador. Como resultado, a decodificação é ainda mais rápida.

Concluindo, quero observar que o método de compactação de arquivos selecionado é muito simples. Ele não leva em consideração os recursos dos dados, e é por isso que a compactação está longe de ser ótima. Se você observar atentamente a entrada, poderá descobrir que algumas combinações de letras são mais comuns que outras, e outras nem sequer ocorrem. Usando esse fato, a compactação pode ser significativamente aprimorada. Mas este é um tópico para um artigo separado.

Posfácio


Por que escrever seu próprio arquivador quando existem muitos utilitários testados pelo tempo? A resposta é bem simples: arquivadores adaptados a um formato específico são muito melhores.

Ao desenvolver jogos na Playrix , geralmente confiamos na necessidade de reduzir o tamanho da compilação. Os jogos estão em constante evolução, a quantidade de conteúdo está aumentando e o espaço é limitado.

Mais uma vez , olhando ansiosamente para os recursos, percebemos que alguns arquivos podem ser compactados muito melhor do que o zip, devido à sua estrutura. Durante os experimentos, conseguimos reduzir significativamente o tamanho do nosso próprio formato de animação; também existem algumas mudanças na compactação de arquivos gráficos.

Ao desenvolver algoritmos de compressão, um codificador de entropia universal, como rANS ou FSE, é uma ferramenta indispensável. Ele assume completamente a tarefa de escrever caracteres com o menor número de bits, permitindo que o desenvolvedor se concentre nos principais detalhes do algoritmo. E também funciona muito rápido, tanto na codificação quanto na decodificação.

Espero que este artigo ajude você a conhecer o rANS e começar a usá-lo em seus projetos.

Referências


Aqui você pode ver exemplos de implementação de rANS (com diferentes opções de otimização):

Fabian Giesen: github.com/rygorous/ryg_rans

Você pode ler detalhes e detalhes interessantes no blog de Fabian (em inglês):

notas rANS
rANS com distribuições de probabilidade estática
rANS na prática

Source: https://habr.com/ru/post/pt441814/


All Articles