前一篇blog HPACK explained in detail里,我试着分析了下HPACK的设计细节。其中HPACK对于string literal数据采用static Huffman coding来编码,这一篇blog将继续拓展这个话题,写写关于HPACK中static huffman decode/encode算法实现的内容,虽然HPACK里用的static huffman coding不涉及根据数据构造huffman树,但是想要实现一个高效的HPACK huffman encoder/decoder并不是一个很直观的事情,尤其是将huffman 高效decoder的实现。接下来的内容分为两个部分,分别以netty HPACK的实现为例,介绍下目前工业界常见huffman decoder/encoder的算法。
Encoder部分比较简单,按个字节查static huffman码表,将byte encode成code,需要注意的有两点:
Netty HPACK encode String literal的代码如下:
private void encodeStringLiteral(ByteBuf out, CharSequence string) {
int huffmanLength;
if (string.length() >= huffCodeThreshold
&& (huffmanLength = hpackHuffmanEncoder.getEncodedLength(string)) < string.length()) {
encodeInteger(out, 0x80, 7, huffmanLength);
hpackHuffmanEncoder.encode(out, string);
} else {
encodeInteger(out, 0x00, 7, string.length());
if (string instanceof AsciiString) {
// Fast-path
AsciiString asciiString = (AsciiString) string;
out.writeBytes(asciiString.array(), asciiString.arrayOffset(), asciiString.length());
} else {
// Only ASCII is allowed in http2 headers, so its fine to use this.
// https://tools.ietf.org/html/rfc7540#section-8.1.2
out.writeCharSequence(string, CharsetUtil.ISO_8859_1);
}
}
}
可以看出netty 对数据进行huffman编码的条件有两个:
计算huffman 编码数据长度的完整代码如下,其中lengths是static huffman 码表中的code lengths数组,元素是每个byte对于的code的bits位数。
private int getEncodedLengthSlowPath(CharSequence data) {
long len = 0;
for (int i = 0; i < data.length(); i++) {
len += lengths[data.charAt(i) & 0xFF];
}
return (int) ((len + 7) >> 3);
}
对数据部分的huffman encoding完整代码如下:
private void encodeSlowPath(ByteBuf out, CharSequence data) {
long current = 0;
int n = 0;
for (int i = 0; i < data.length(); i++) {
int b = data.charAt(i) & 0xFF;
int code = codes[b];
int nbits = lengths[b];
current <<= nbits;
current |= code;
n += nbits;
while (n >= 8) {
n -= 8;
out.writeByte((int) (current >> n));
}
}
if (n > 0) {
current <<= 8 - n;
current |= 0xFF >>> n; // this should be EOS symbol
out.writeByte((int) current);
}
}
可以看出,其用一个long类型的变量current寄存器存放code bits,用变量n当前current寄存器里还有多少bits没有写入数据流里。由于hpack的huffman编码最长不超过4个字节,每个byte数据编码后的code可以用一个int类型表示。nbits是当前byte编码后的bits位数。
current <<= nbits;
current |= code;
这两句代码将current左移nbits,将current的低位腾出来存放code,然后在while循环里从current的高位开始依次将每个byte输出到out。最后如果n不为0,说明current里还有n bits剩余,但是不满一个字节,因此需要padding到一个字节,写入out中。
个人觉得HPACK的string literal中huffman编码这个先length再data的设计是个缺陷,为什么不利用EOS的huffman code呢?数据的末尾放一个huffman encoded EOS,这样解码解到EOS时候就停止就可以不用length字段了。
关于encoding,Exploring HTTP/2 Header Compression这篇论文中还给出了一个优化的算法。算法利用了一个先验知识:就是huffman编码后的数据长度可以根据原始数据长度猜个大概,他们研究发现huffman编码平均能压缩20%的数据,因此压缩后的数据长度大概是0.8 * 原始数据长度。得到这个猜测数据大小后,就知道其integer representation所需的字节数了。比如7-prefix编码0-126只需要一个字节,127-254需要两个字节,255-16510则需要三个字节。如果猜数据大小范围没错的话,可以先空出来几个字节用于后面写入length,然后直接遍历一次原始数据字节得到data,完成后再将length写到data之前空出来的字节处。
基于这一前提,他们得出这样的算法,能够以大概率做到只需遍历一次原始数据。
Get the length of an input string (lo) and its byte count (bo).
∙ Prepare a buffer whose length is lo + bo.
∙ Guess the result length with the factor of 0.8 (le) and its byte count (be).
∙ Huffman encode the string starting from the position of be.
– If we reach the end of the buffer during this encoding, encode lo in the beginning of the buffer and copy the input string as is.
– Otherwise, obtain the real length of the result (lr) and its byte count (br).
* In the rare case where be is not equal to br, move the result appropriately within the buffer and encode lr in the beginning of the buffer.
* Otherwise, encode lr in the beginning of the buffe
关于Huffman decoder的实现,工业界主流实现无一例外都是基于2003的年这篇论文Fast Prefix Code Processing中提出的Fast prefix code decoding算法(姑且简称为FPCD),下面先介绍下这个聪明并且有效的算法。
我们知道huffman decoding最naive的方法是对编码后的数据按照每个bit来访问huffman树,如果访问到叶子节点,说明成功解码出一个字符并输出,否则按照下一个bit访问二叉树上当前节点的左右子节点。这样做的问题在于只能按照一次一个bit来解码,显然不太能够利用现代硬件按照word处理数据的计算能力。
FPCD算法目的是要做到每次能够对若干个bits进行同时处理,其算法的提出是基于这样的观察:对于一个huffman二叉树而言,树上的每个节点都是一个状态,我们可以根据一颗给定的huffman树预先计算出从某个状态出发、接收k个bits的所有可能的输入后所处的状态、以及其产生的decoding输出。举个简单的例子,比如现在有这样一个huffman树:
如果我们每次处理两个bits的话,可以得出如下图所示的一个状态迁移图:
假设目前处于状态0, 接收的输入数据是00,会使得状态迁移至状态3 (经过1), 并且成功解码输出A字符;如果接收的输入11,会使得先经过状态2解码出C字符,然后停在状态1,以此类推,可以得到所有状态针对n bits 所有取值的状态迁移表。
可以看出所有叶子节点的状态其实和root节点是一致的,等价于状态0,因此对于alphabet大小为N的huffman码表,由于huffman树非叶子节点个数是N-1(huffman树是正则二叉树: N_0 = N_2 + 1), 所以状态迁移表中也就是有N-1个状态,每个状态有2^k个状态转移的可能,所以整个状态迁移表的大小是 (N-1)* 2^k。给定一颗huffman树,我们就可以预先计算出这个状态转移表来,表中的每个entry由三部分组成:
有了这个预先计算好的FSM,decode算法过程就是:
- 从root state开始,依次处理每k个bits
- 根据当前state以及当前k个bits的取值,在状态迁移表中找到对应的entry,跳转到entry的next state,并根据entry的标志位决定如下动作:
- 如果标志位中有输出标志,则输出entry中对应的byte
- 如果标志中有解码失败标志,则终止解码过程,直接通知上层解码失败
- 处理最后4个bits后,如果entry中没有解码完成标志,说明数据有丢失,通知上层解码失败
netty中使用k是4,即一次性处理4个bit输入,选择4是空间和时间的trade off,另外对于http2的静态huffman码表来锁还有个好处是每次状态转移至多输出一个byte(因为http2的huffman码表中最短的code也有5bit)。具体decode代码如下:
//处理单个byte的输入
public boolean process(byte input) {
return processNibble(input >> 4) && processNibble(input);
}
private boolean processNibble(int input) {
// The high nibble of the flags byte of each row is always zero
// (low nibble after shifting row by 12), since there are only 3 flag bits
int index = state >> 12 | (input & 0x0F);
state = HUFFS[index];
if ((state & HUFFMAN_FAIL_SHIFT) != 0) {
return false;
}
if ((state & HUFFMAN_EMIT_SYMBOL_SHIFT) != 0) {
// state is always positive so can cast without mask here
dest[k++] = (byte) state;
}
return true;
}
需要说明的是netty里状态迁移码表中的entry是由前面说的三个元素pack而成的32bit integer。 最高16位是next state,然后8位是flags,最后8位是输出byte。因此计算迁移表index的时候需要先将当前状态变量state 右移16,得到unpack后的实际state,所以index 需要通过(state » 16 * 16 + input & 0x0F)得到,用位运算简化下就是 index = state » 12 | (input & 0x0F)。
以上就是HPACK中static huffman codec的工业界实现,想实现一个高效的huffman codec需要一些trick