Desenvolvimento

16 abr, 2014

Configuração e otimização de compressão WebSocket

Publicidade

Boas notícias: o suporte no navegador para a última versão de “Compression Extensions” para o protocolo WebSocket – uma característica muito necessária e atrasada – será disponibilizado este ano: Chrome M32 + (já disponível no Canary). Implementações para Firefox e Webkit devem vir em seguida.

Especificamente, ele permite que o cliente e o servidor negociem um algoritmo de compressão e seus parâmetros, e então aplique-o seletivamente às cargas de dados de cada mensagem WebSocket: o servidor pode comprimir dados entregues para o cliente, e o cliente pode comprimir os dados enviados para o servidor.

Suporte para negociação de compressão e parâmetros

Compressão por mensagem (per-message) é uma extensão do protocolo WebSocket, o que significa que ele deve ser negociado como parte do handshake WebSocket. Além disso, ao contrário de uma solicitação HTTP normal (por exemplo, XMLHttpRequest iniciada pelo navegador), o WebSocket também nos permite negociar os parâmetros de compressão em ambas as direções (cliente-servidor e servidor-cliente). Dito isso, vamos começar com o caso mais simples possível:

GET /socket HTTP/1.1
Host: thirdparty.com
Origin: http://example.com
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Extensions: permessage-deflate
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Access-Control-Allow-Origin: http://example.com
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Extensions: permessage-deflate

O cliente inicia a negociação ao anunciar a extensão permessage-deflate no cabeçalho Sec-Websocket-Extensions. Por sua vez, o servidor deve confirmar a extensão anunciada no eco da sua resposta.

Se o servidor omitir a confirmação da extensão, então o uso de permessage-deflate é recusado, e tanto o cliente quanto o servidor prosseguem sem ele – ou seja, o handshake é concluído, e as mensagens não serão comprimidas. Por outro lado, se a negociação da extensão for bem sucedida, tanto o cliente quanto o servidor podem comprimir os dados transmitidos, conforme necessário:

  • Padrão atual utiliza compressão Deflate.
  • A compressão é aplicada somente aos dados de aplicação: frames de controle e cabeçalhos dos frames não são afetados.
  • Tanto o cliente quanto o servidor podem comprimir seletivamente frames individuais: se o frame é comprimido, o bit RSV1 no cabeçalho do frame WebSocket é definido.

Compressão seletiva de mensagem

Compressão seletiva é um recurso particularmente interessante e útil. Só porque negociamos suporte para compressão não significa que todas as mensagens devem ser compactadas! Afinal, se a carga já está comprimida (por exemplo, dados de imagem ou qualquer outra carga comprimida), então executar deflate em cada frame seria um desperdício desnecessário de ciclos de CPU em ambas as extremidades. Para evitar isso, o WebSocket permite que tanto servidor quanto o cliente comprimam mensagens individuais seletivamente.

ws-compression

Como o servidor e o cliente sabem quando comprimir dados? Esse é o momento em que a sua escolha de um servidor e API WebSocket pode fazer uma grande diferença: uma implementação ingênua irá simplesmente comprimir todas as cargas de mensagens, enquanto que um servidor inteligente pode oferecer uma API adicional para indicar quais cargas devem ser compactadas.

Da mesma forma, o navegador pode comprimir seletivamente cargas transmitidas ao servidor. No entanto, esse é o lugar onde nos deparamos com nossa primeira limitação: a API WebSocket do navegador não prevê qualquer mecanismo para sinalizar se a carga deve ser compactada. Como resultado, a implementação atual no Chrome comprime todas as cargas – se você já está transferindo dados compactados sobre WebSocket sem a extensão deflate, então isso é definitivamente algo que você deve considerar, uma vez que isso pode adicionar uma sobrecarga desnecessária em ambos os lados da conexão.

Em teoria, na ausência de uma API oficial, ou de uma bandeira por mensagem para indicar uma mensagem comprimida, a UA poderia executar um teste de “aleatoriedade de dados” para ver se eles devem ser comprimidos. No entanto, isso por si só pode adicionar uma sobrecarga não trivial de processamento.

Otimização e dimensionamento da compressão Deflate

Payloads comprimidos podem reduzir significativamente a quantidade de dados transmitidos, o que leva a uma economia de banda e uma entrega mais rápida de mensagens. Dito isso, existem alguns custos também! O Deflate utiliza uma combinação de LZ77 e codificação de Huffman para comprimir dados: primeiro, LZ77 é usada para eliminar cadeias duplicadas; segundo, a codificação de Huffman é usada para codificar as sequências de bits comum em representações mais curtas.

Por padrão, habilitar a compressão adicionará pelo menos 300KB de memória extra por conexão WebSocket – pode não ser muito, mas se o seu servidor está manipulando um grande número de conexões WebSocket, ou se o cliente estiver sendo executado em um dispositivo de memória limitada, então isso é algo que deve ser levado em conta. O cálculo exato com base na implementação zlib do Deflate é o seguinte:

     compressor = (1 << (windowBits + 2)) + (1 << (memLevel + 9))
   decompressor = (1 << windowBits) + 1440 * 2 * sizeof(int)
          total = compressor + decompressor

Ambos os pontos mantêm compressão e descompressão em contextos separados, cada uma das quais requer uma janela de buffer LZ77 separada (como definido por windowBits), além de uma sobrecarga adicional para a árvore Huffman e outras cargas para outros compressores e descompressores. As configurações padrão são:

  • compressor: windowBits = 15, memLevel = 8 → ~ 256KB
  • descompressor: windowBits = 15 → ~ 44KB

A boa notícia é que o permessage-deflate nos permite personalizar o tamanho da janela do LZ77 e, assim, limitar a sobrecarga de memória através de dois parâmetros da extensão: {client, server}_no_context_takeover e {client, server}_max_window_bits. Vamos dar uma olhada sob o capô…

Otimização do tamanho da janela LZ77

Uma discussão completa sobre LZ77 e Huffman está fora do escopo deste artigo, mas para entender os parâmetros da extensão acima, vamos primeiro tomar um pequeno desvio para entender o que estamos configurando e as tensões inerentes entre a memória e o desempenho de compressão.

O parâmetro windowBits personaliza o tamanho da “sliding window” utilizada pelo algoritmo LZ77. O vídeo acima é uma grande demonstração visual do LZ77 em funcionamento: o algoritmo mantém uma “janela deslizante” de dados previamente vistos e substitui sequências repetidas (indicadas em vermelho) com referências anteriores (ex.: voltar x caracteres, copiar y caracteres) – isso é o LZ77 em poucas palavras. Como resultado, quanto maior for a janela, mais elevada a probabilidade de o LZ77 encontrar e eliminar strings duplicadas.

Qual o tamanho da janela do LZ77? Por padrão, a janela é inicializada para 15 bits, o que se traduz em 215 bits (32KB) de espaço. No entanto, podemos personalizar o tamanho da janela, como parte do handshake WebSocket:

GET /socket HTTP/1.1
Upgrade: websocket
Sec-WebSocket-Key: ...
Sec-WebSocket-Extensions: permessage-deflate;
  client_max_window_bits; server_max_window_bits=10
  • O cliente anuncia que suporta o tamanho personalizado da janela via client_max_window_bits
  • O cliente solicita que o servidor deve usar uma janela de tamanho 210 (1 KB)
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Sec-WebSocket-Accept: ...
Sec-WebSocket-Extensions: permessage-deflate;
  server_max_window_bits=10
  • O servidor confirma que vai usar um tamanho de janela de 210 (1 KB)
  • O servidor desiste de pedir um tamanho personalizado de janela ao cliente

Tanto o cliente quanto o servidor devem manter as mesmas janelas para a troca de dados: um buffer para o cliente → contexto de compressão do servidor e um buffer para o servidor → contexto do cliente. Como resultado, por padrão, vamos precisar de dois buffers de 32KB (tamanho da janela padrão é 15 bits), além de outros gastos do compressor. No entanto, podemos negociar um tamanho menor da janela: no exemplo acima, nós limitamos o tamanho da janela do servidor → cliente para 1 KB.

Por que não começar com o buffer de menor? Simples: quanto menor a janela, menos provável que o LZ77 vá encontrar uma referência anterior apropriada. Dito isso, o desempenho vai variar de acordo com o tipo e a quantidade de dados transmitidos, e não existe uma única regra de ouro para o melhor tamanho da janela. Para obter o melhor desempenho, teste diferentes tamanhos de janelas em seus dados! Em seguida, se necessário, diminua o tamanho da janela para reduzir a sobrecarga de memória.

Otimização do Deflate memLevel

O parâmetro memLevel controla a quantidade de memória alocada para o estado de compressão interna: quando definido como 1, ele usa menos memória, mas retarda o algoritmo de compressão e reduz a taxa de compressão; quando definido como 9, ele usa mais memória e oferece o melhor desempenho. O memLevel padrão é definido como 8, o que resulta em aproximadamente 133KB de sobrecarga de memória necessária para o compressor.

Note que o descompressor não precisa saber o memLevel escolhido pelo compressor. Como resultado, os nós não precisam negociar essa configuração no handshake. Ambos são livres para personalizar esse valor como quiserem. O servidor pode melhorar esse valor conforme necessário para troca de velocidade, taxa de compressão e memória – mais uma vez, a melhor configuração dependerá do seu fluxo de dados e requisitos operacionais.

Infelizmente, o cliente, que nesse caso é o user-agent do navegador, não fornece qualquer API para personalizar o memLevel do compressor: memLevel = 8 é usado como um valor padrão em todos os casos. Semelhante à falta da flag de compressão por mensagem, talvez esse seja um recurso que poderá ser adicionado a uma futura revisão da especificação WebSocket.

Context takeover

Por padrão, os contextos de compressão e descompressão são persistentes entre diferentes mensagens WebSocket – ou seja, a janela da mensagem anterior é usada para codificar o conteúdo da mensagem seguinte. Se as mensagens são semelhantes – como normalmente são –, isso melhora a taxa de compressão. No entanto, a desvantagem é que a sobrecarga de contexto tem um custo fixo para toda a vida útil da conexão – ou seja, a memória deve ser alocada no início e deve ser mantida até que a conexão seja fechada.

Bem, e se nós relaxarmos essa restrição e, em vez disso, permitirmos que os seus nós redefinam o contexto entre as diferentes mensagens? É disso que a opção “no context takeover” trata:

GET /socket HTTP/1.1
Upgrade: websocket
Sec-WebSocket-Key: ...
Sec-WebSocket-Extensions: permessage-deflate;
  client_max_window_bits; server_max_window_bits=10;
  client_no_context_takeover; server_no_context_takeover
  • Cliente anuncia que desativará o context takeover
  • O cliente requisita que o servidor também desabilite o context takeover
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Sec-WebSocket-Accept: ...
Sec-WebSocket-Extensions: permessage-deflate;
  server_max_window_bits=10; client_max_window_bits=12
  client_no_context_takeover; server_no_context_takeover
  • O servidor reconhece a recomendação “no context takeover” do cliente
  • O servidor indica que desativará o context takover

Desativar o “context takover” impede o nó de utilizar o contexto de compressão a partir de uma mensagem anterior para codificar o conteúdo da mensagem seguinte. Em outras palavras, cada mensagem é codificada com a sua própria janela deslizante e árvore Huffman.

A vantagem de desativar aquisição contexto é que tanto o cliente quanto o servidor podem redefinir seus contextos entre diferentes mensagens, o que reduz significativamente a sobrecarga total da conexão quando esta estiver ociosa. Dito isso, a desvantagem é que o desempenho de compressão provavelmente também vá sofrer. Quanto? Você adivinhou, a resposta depende dos dados de aplicativos reais que estão sendo trocados.

Note que, mesmo sem a negociação “no_context_takeover”, o descompressor deve ser capaz de decodificar os dois tipos de mensagens. Sendo assim, a negociação explícita é o que nos permite saber que ele é seguro para reiniciar o contexto no receptor.

Otimizar parâmetros de compressão

Agora que sabemos o que estamos aprimorando, um simples script em ruby pode nos ajudar a iterar sobre todas as opções (memLevel e tamanho da janela) para escolher as configurações ideais. Por uma questão de exemplo, vamos comprimir a timeline do GitHub:

gt; curl https://github.com/timeline.json -o timeline.json
gt; ruby compare.rb timeline.json
Original file (timeline.json) size: 30437 bytes
Window size: 8 bits (256 bytes)
   memLevel: 1, compressed size: 19472 bytes (36.03% reduction)
   memLevel: 9, compressed size: 15116 bytes (50.34% reduction)

Window size: 11 bits (2048 bytes)
   memLevel: 1, compressed size: 9797 bytes (67.81% reduction)
   memLevel: 9, compressed size: 8062 bytes (73.51% reduction)

Window size: 15 bits (32768 bytes)
   memLevel: 1, compressed size: 8327 bytes (72.64% reduction)
   memLevel: 9, compressed size: 7027 bytes (76.91% reduction)

O menor tamanho de janela permitido (256 bytes) fornece aproximadamente 50% de compressão, e levantando a janela para 2KB chegamos a aproximadamente 73%! A partir daí, os retornos diminuem: a janela de 32KB produz apenas uma pequena porcentagem adicional (veja o resultado completo). Hmm! Se eu estava fluindo esses dados ao longo de um WebSocket, um tamanho de janela de 2KB parece ser uma otimização razoável.

Implementando compressão WebSocket

Personalizar o tamanho da janela do LZ77 e o context takeover são otimizações avançadas. A maioria dos aplicativos provavelmente obterá o melhor desempenho simplesmente usando os padrões (tamanho da janela de 32KB e janela compartilhada). Então, é útil para entender a sobrecarga gerada (acima de 300 KB por conexão), e os botões que podem ajudá-lo a ajustar esses parâmetros!

À procura de um servidor WebSocket que suporta a compressão por mensagem? Há rumores de que Jetty, Autobahn e WebSocket++ já possuem suporte; outros servidores (e clientes) devem vir. Para se aprofundar no fluxo de trabalho de negociação, layouts de frames, e mais, confira a especificação oficial.

PS: Para mais dicas de otimização WebSocket: leia o capítulo WebSocket do livro High Performance Browser Networking.

***

Artigo traduzido pela Redação iMasters, com autorização do autor. Publicado originalmente em http://www.igvita.com/2013/11/27/configuring-and-optimizing-websocket-compression/