土法炼钢兴趣小组的算法知识备份

zstd 深度解剖:FSE 与字典训练

目录

2015 年,Yann Collet 在 Facebook 内部发布了 Zstandard(简称 zstd)。 彼时 gzip 统治了二十余年,lz4 刚占领实时场景,brotli 还在 Google 内部打磨。 zstd 的目标很大胆:在 gzip 级别的压缩率下,达到 lz4 级别的速度。 如今 Linux 内核、MySQL、RocksDB、HTTP/3 都已将 zstd 作为默认或推荐压缩方案。 本文从帧结构讲到 FSE 编码器的 C 实现,剖析这个”同时赢得速度和压缩率”的算法。

一、整体架构与设计哲学

zstd 的设计哲学可以归纳为三个关键词:分层、可调、流式

分层设计

整个压缩流水线被清晰地分为三层:

  1. 帧层(Frame):定义数据边界、校验和、字典 ID 等元信息。
  2. 块层(Block):每个帧包含一个或多个块,每块最大 128 KB,独立可解码。
  3. 序列层(Sequence):每个块内部由”字面量 + 匹配序列”构成,最终交给熵编码器。

这种分层使得 zstd 的流式处理成为可能——解码器只需要一个块大小的缓冲区即可开始输出。

可调参数

zstd 提供 1-22 共 22 个压缩级别,此外还有负级别(-1 到 -7)用于超快速压缩。 每个级别实际上是一组参数的组合:

windowLog       搜索窗口大小(10-31,对应 1KB-2GB)
chainLog        哈希链长度
hashLog         哈希表大小
searchLog       搜索深度
minMatch        最小匹配长度
targetLength    目标匹配长度(用于 lazy 策略)
strategy        匹配策略(fast/dfast/greedy/lazy/lazy2/btlazy2/btopt/btultra/btultra2)

流式接口

zstd 的 streaming API 允许增量压缩和解压:

// 压缩流
ZSTD_CStream* cstream = ZSTD_createCStream();
ZSTD_initCStream(cstream, compressionLevel);
ZSTD_compressStream(cstream, &output, &input);
ZSTD_endStream(cstream, &output);

// 解压流
ZSTD_DStream* dstream = ZSTD_createDStream();
ZSTD_initDStream(dstream);
ZSTD_decompressStream(dstream, &output, &input);

这比 zlib 的 inflate/deflate 接口简洁得多,也更不容易出错。

二、帧与块结构

下面是 zstd 压缩数据的完整帧结构:

zstd pipeline

帧头(Frame Header)

+-------------------+-------------------+-----+-----+
| Magic Number      | Frame Header      | ... | ... |
| 4 bytes           | 2-14 bytes        |     |     |
| 0xFD2FB528        |                   |     |     |
+-------------------+-------------------+-----+-----+

帧头的结构如下:

Frame_Header_Descriptor  (1 byte)
  bit 7-6:  Frame_Content_Size_flag  (0/1/2/3 -> 0/2/4/8 bytes)
  bit 5:    Single_Segment_flag
  bit 4:    未使用(保留位)
  bit 3:    Content_Checksum_flag
  bit 2:    Dictionary_ID_flag (0/1/2/3 -> 0/1/2/4 bytes)
  bit 1-0:  未使用

关键点在于 Single_Segment_flag:如果设置为 1,则整个帧只包含一个段, Window_Descriptor 被省略,Frame_Content_Size 就是窗口大小。 这对于小数据压缩非常重要——省去了不必要的字节开销。

块头(Block Header)

每个块有一个 3 字节的头部:

Block_Header (3 bytes, little-endian 24 bits)
  bit 0:     Last_Block      是否为最后一个块
  bit 2-1:   Block_Type      块类型
              00 = Raw_Block          未压缩
              01 = RLE_Block          单字节重复
              10 = Compressed_Block   压缩块
              11 = Reserved           保留
  bit 23-3:  Block_Size      块内容大小(最大 128KB - 1)

三种块类型的设计体现了 zstd 的实用主义:

压缩块内部

一个 Compressed_Block 包含三个主要部分:

+---------------------+-----------------------+
| Literals_Section    | Sequences_Section     |
| (字面量)            | (匹配序列)            |
+---------------------+-----------------------+

字面量区域(Literals Section) 存储了所有未被匹配覆盖的原始字节。 它自身有四种编码模式:

模式 标识 说明
Raw_Literals 00 不压缩,直接存储
RLE_Literals 01 单字节重复
Compressed_Literals 10 使用 Huffman 压缩
Treeless_Literals 11 复用前一个块的 Huffman 树

序列区域(Sequences Section) 存储了一系列三元组 (LitLen, Offset, MatchLen)

这三个值各自使用独立的 FSE 表进行编码。

三、FSE:有限状态熵编码

FSE(Finite State Entropy)是 zstd 最核心的创新之一。 它基于 Jarek Duda 在 2009 年提出的 ANS(Asymmetric Numeral Systems)理论, 是 ANS 的表驱动变体,称为 tANS(table-based ANS)。

为什么不用 Huffman

Huffman 编码有一个根本性限制:每个符号的编码长度必须是整数位。 假设某个符号的理论最优编码长度是 2.3 bit,Huffman 只能取 2 或 3 bit, 这意味着对于概率分布不是 2 的幂次的符号,Huffman 总会浪费一些空间。

对于高熵数据(如压缩后的字面量),Huffman 的损失较小。 但对于低熵、偏斜分布的数据(如匹配长度、偏移量编码), 每个符号平均浪费 0.1-0.5 bit 的情况下,累积损失可达数个百分点。

ANS 的核心思想

ANS 的关键洞察是:可以用一个整数状态来编码信息

传统算术编码维护一个区间 [low, high),每编一个符号就缩小区间。 ANS 则维护一个整数 state,每编一个符号就通过一个可逆变换更新状态。

数学上,如果符号 s 的概率为 p(s),那么编码后状态值大约变为:

state' = C(state, s) ~ state / p(s)

解码则是反过来:

(state, s) = D(state') 使得 state' = C(state, s)

这种对称性使得 ANS 在理论上能够达到熵极限。

tANS 表构建

FSE 将 ANS 的数学运算转化为查表操作: 选择表大小 tableLog(通常 5-12 bit,表有 1 << tableLog 个槽位), 按照符号概率将符号散布到表的各个位置(使用步长公式 step = (tableSize >> 1) + (tableSize >> 3) + 3), 然后为每个表位构建编码条目(输出位数、状态转移)和解码条目(符号、读取位数、新状态基线)。

编码与解码过程

编码(从最后一个符号开始,反向编码): 读取当前状态的编码条目,输出状态低位(nbBitsOut 位), 然后根据编码表更新到新状态。

解码(正向解码): 从比特流头部读取初始状态(tableLog 位), 然后每次查表获取符号和所需位数,读取位更新状态。

这就是 tANS 的精髓:编码和解码都只需要一次表查找和一次位操作。 没有分支预测失败,没有除法运算,对现代 CPU 极其友好。

FSE 的表头压缩

FSE 表本身也需要序列化。zstd 使用紧凑的变长编码格式: 4 bit 的 Accuracy_Log 加上符号概率数组, 相邻的零概率符号可以跳过。整个 FSE 表头通常只有几十字节。

四、Huffman 编码在 zstd 中的角色

虽然 FSE 是 zstd 的标志性技术,但 Huffman 编码在 zstd 中仍有重要地位。

为什么字面量用 Huffman

字面量(Literals)的字母表较大(256 个可能的字节值), 且分布通常比序列数据更均匀。在这种场景下:

  1. Huffman 的整数位限制造成的损失较小(每符号 < 0.1 bit)。
  2. Huffman 解码更快:直接用前缀码查表,不需要维护状态。
  3. Huffman 支持 4 路并行解码(zstd 的关键优化)。

4 路 Huffman 并行解码

zstd 将字面量流分成 4 段,使用 4 个独立的比特流同时解码。 这在现代超标量 CPU 上效果显著,因为 4 条解码路径之间没有数据依赖, 流水线可以充分利用。实测中,4 路并行使字面量解码速度提升约 2-3 倍。

Huffman 与 FSE 的量化比较

特性 Huffman FSE (tANS)
位精度 整数位 分数位(1/tableSize 精度)
编码速度 极快(查表+移位) 快(查表+移位+状态更新)
解码速度 极快
并行解码 容易(无状态依赖) 困难(状态有依赖)
压缩率(均匀分布) 接近最优 接近最优
压缩率(偏斜分布) 有损失 接近最优
表大小 较大(最多 2048 entries) 较小(通常 64-4096 entries)
适用场景 大字母表、均匀分布 小字母表、偏斜分布

zstd 的选择非常务实:字面量用 Huffman,序列用 FSE,两者各取所长。

五、匹配查找器

匹配查找是 LZ 压缩的核心环节,直接决定了压缩率和速度的平衡点。 zstd 提供了多种匹配策略,对应不同的压缩级别。

哈希链(Hash Chain)

最基本的匹配查找方式。对每个位置计算哈希,用链表链接哈希冲突的位置:

// 简化的哈希链匹配查找
uint32_t hash = ZSTD_hashPtr(ip, hashLog, mls);
uint32_t matchIndex = hashTable[hash];
hashTable[hash] = (uint32_t)(ip - base);

while (matchIndex >= lowLimit) {
    const BYTE* match = base + matchIndex;
    if (MEM_read32(match) == MEM_read32(ip)) {
        // 找到匹配,尝试扩展
        size_t matchLen = ZSTD_count(ip + 4, match + 4, iend) + 4;
        // 记录最佳匹配
        if (matchLen > bestLen) {
            bestLen = matchLen;
            bestOffset = (size_t)(ip - match);
        }
    }
    matchIndex = chainTable[matchIndex & chainMask];
    if (--searchCount == 0) break;  // 限制搜索深度
}

二叉树(Binary Tree)

高压缩级别使用二叉树替代哈希链。 二叉树的每个节点维护两个子指针——“比当前前缀小”和”比当前前缀大”。 搜索时同时向两个方向扩展,能更高效地找到最长匹配, 且搜索深度可精确控制(searchLog 参数)。

长距离匹配(Long Range Matching)

zstd 在高级别时启用独立的长距离匹配器(LDM):

LDM 参数:
  ldmHashLog       长距离哈希表大小(默认 0 = 禁用)
  ldmMinMatch      最小匹配长度(默认 64 字节)
  ldmBucketSizeLog 每个桶的容量(默认 3 -> 8 entries)
  ldmHashRateLog   采样率(默认 6 -> 每 64 字节采样一次)

LDM 使用滚动哈希(类似 Rabin-Karp),在大窗口(可达 2 GB)中查找匹配。 它与常规匹配器独立运行,找到的长距离匹配被优先使用。

Lazy 策略与 Optimal Parsing

Lazy 匹配是 zstd 中级压缩的核心策略:

greedy:  找到第一个足够好的匹配就使用
lazy:    在当前位置和下一个位置都搜索,选更好的
lazy2:   在当前位置和接下来两个位置都搜索,选最好的

最优解析(Optimal Parsing) 在 level 17+ 启用:

使用动态规划计算最优的 literal/match 序列:
  cost[i] = min(
    cost[i-1] + literalCost(byte[i]),          // 作为字面量
    min over all matches at position i:
      cost[i - matchLen] + matchCost(offset, matchLen)
  )

最优解析的时间复杂度显著增加,但能额外提升 3-5% 的压缩率。

六、压缩级别策略详解

zstd 的 22 个正向级别加上负级别,覆盖了极宽的速度-压缩率权衡范围。

级别策略映射

级别范围 策略 窗口大小 哈希表 搜索深度 特点
-7 到 -1 fast 16-19 15-17 1 超快,适合实时日志
1 fast 19 17 1 比 lz4 略慢,压缩率好很多
2-3 dfast 20 17 1 双哈希表,兼顾短长匹配
4-6 greedy 20-21 17-18 3-4 贪心匹配
7-9 lazy 21-22 18-19 5-6 延迟一步决策
10-12 lazy2 22-23 19-20 6-8 延迟两步决策
13-15 btlazy2 23-24 20-21 8-16 二叉树 + lazy2
16-18 btopt 24-25 22-23 32-256 二叉树 + 最优解析
19-22 btultra/btultra2 25-27 23-25 256-4096 超密集搜索

fast 与 dfast 的区别

fast 策略使用单哈希表,每个位置只检查一个候选匹配:

// fast 策略的核心循环
size_t hash = ZSTD_hashPtr(ip, hBits, mls);
size_t matchIndex = hashTable[hash];
hashTable[hash] = (size_t)(ip - base);
if (MEM_read32(base + matchIndex) == MEM_read32(ip)) {
    // 匹配成功,编码
} else {
    ip += step;  // step 会逐渐增大(加速跳过不可压缩区域)
}

dfast(double fast)使用两个哈希表,分别针对短匹配(4字节)和长匹配(6-8字节):

size_t hashSmall = ZSTD_hashPtr(ip, hBitsS, 4);
size_t hashLong  = ZSTD_hashPtr(ip, hBitsL, 8);
size_t matchSmall = smallTable[hashSmall];
size_t matchLong  = longTable[hashLong];
// 优先选择长匹配

btopt 与 btultra 的代价模型

最优解析策略使用代价模型评估每种编码选择的”代价”(近似比特数), 综合 FSE 编码代价和额外位来决定最优 literal/match 划分。

btultra2btultra 基础上增加了第二轮优化: 在第一轮最优解析完成后,用实际的统计数据重新计算代价模型, 然后再跑一轮最优解析。这就是为什么 level 22 比 level 19 慢很多但压缩率更高。

七、字典压缩

字典压缩是 zstd 最具实用价值的特性之一。 当压缩大量小文件(如 JSON API 响应、日志条目、数据库记录)时, 单个文件往往太小,无法建立有效的统计模型和匹配历史。

字典训练算法

zstd 的字典训练(zstd --train)使用 COVER 或 fastCOVER 算法: 收集大量样本文件,找到频繁出现的子串, 使用贪心选择覆盖最多样本的子串集合,拼接成字典内容, 并在字典末尾附加预计算的 Huffman 表和 FSE 表。

# 基本训练
zstd --train samples/* -o mydict.zstd

# 指定字典大小
zstd --train --maxdict=110000 --dictID=1 -o mydict.zstd samples/*

# 使用 fastCOVER 算法
zstd --train-fastcover=k=64,d=8 samples/* -o mydict.zstd

字典格式

zstd 字典有两种格式:原始字典标准字典

标准字典的结构:

+------------------+------------------+------------------+------------------+
| Magic Number     | Dictionary ID    | Entropy Tables   | Content          |
| 4 bytes          | 4 bytes          | variable         | variable         |
| 0xEC30A437       |                  | (HUF+FSE tables) | (raw content)    |
+------------------+------------------+------------------+------------------+

Entropy Tables 包含预计算的: - Huffman 表(用于字面量) - FSE 表 x3(用于 LitLen、Offset、MatchLen) - 3 个重复偏移的初始值

Content 部分是字典的原始内容,用于 LZ 匹配的回溯参考。 编码时,字典内容被”预加载”到滑动窗口之前的位置, 使得压缩器可以引用字典中的字节序列作为匹配。

字典压缩效果

以下是字典压缩在不同场景下的效果(数据来自 Facebook 生产环境):

场景                  无字典       有字典       提升
------------------------------------------------------
JSON API (avg 800B)   1.8:1       4.5:1       +150%
日志条目 (avg 200B)    1.2:1       3.8:1       +217%
Protobuf (avg 500B)   2.1:1       5.2:1       +148%
HTML 片段 (avg 2KB)   3.2:1       5.8:1       +81%
数据库行 (avg 300B)    1.5:1       4.1:1       +173%

字典使用注意事项

// 加载字典
ZSTD_CDict* cdict = ZSTD_createCDict(dictBuffer, dictSize, level);

// 使用字典压缩
ZSTD_CCtx* cctx = ZSTD_createCCtx();
size_t compressedSize = ZSTD_compress_usingCDict(
    cctx, dst, dstCapacity, src, srcSize, cdict);

// 解压时也需要同一个字典
ZSTD_DDict* ddict = ZSTD_createDDict(dictBuffer, dictSize);
ZSTD_DCtx* dctx = ZSTD_createDCtx();
size_t decompressedSize = ZSTD_decompress_usingDDict(
    dctx, dst, dstCapacity, src, srcSize, ddict);

在分布式系统中,压缩端和解压端必须使用完全相同的字典。 字典通过 dict_id(zstd 帧头原生支持)进行版本管理, 解压端维护字典缓存 map<dict_id, ZSTD_DDict*>, 新字典发布时需保证双端同时可见,旧字典保留至少一个过渡期。

八、解压速度优化

zstd 的解压速度是其核心竞争力之一。在默认级别下, zstd 的解压速度通常在 800-1500 MB/s 范围内,远超 gzip 的 300-400 MB/s。

无分支设计(Branchless)

分支预测失败在现代 CPU 上的代价是 10-20 个周期。 zstd 的解码热路径尽可能避免条件分支:

// 传统有分支写法
if (offset > 8) {
    memcpy(op, match, 8);
} else {
    op[0] = match[0];
    op[1] = match[1];
    // ... 逐字节复制
}

// zstd 的无分支写法
ZSTD_copy8(op, match);  // 总是复制 8 字节
// 如果 offset < 8,后续用覆盖写修正
ZSTD_overlapCopy8(op, match, offset);

FSE 解码同样采用无分支设计:

// FSE 解码热循环(简化)
typedef struct {
    size_t newState;
    BYTE   symbol;
    BYTE   nbBits;
} FSE_decode_t;

// 解码一个符号:完全无分支
#define FSE_DECODE_SYMBOL(statePtr, bitDPtr) \
    { \
        const FSE_decode_t entry = table[*statePtr]; \
        *statePtr = entry.newState + BIT_readBitsFast(bitDPtr, entry.nbBits); \
        return entry.symbol; \
    }

预取(Prefetch)

zstd 在解码序列时使用软件预取来隐藏内存延迟:

// 在处理当前序列之前,预取下一个匹配位置的内存
PREFETCH_L1(oLitEnd + sequence.offset);

// 在 FSE 解码中,预取下一个状态的表项
PREFETCH_L1(&table[nextState]);

这在处理大窗口(如 8 MB、32 MB)时尤其重要, 因为匹配引用可能指向很远的位置,导致 cache miss。

批量解码与四路交错

zstd 的序列解码器一次处理一整个序列(LitLen + Match), 而不是像 zlib 那样逐字节处理。配合前文提到的 4 路 Huffman 解码, 字面量和序列两条路径都被充分优化。四路交错执行时, CPU 在等待第一路表查找结果时可以开始第二路的查找,有效隐藏指令延迟。

九、简单 FSE 编码器的 C 实现

下面是一个完整的简化 FSE 编码器实现, 展示了 tANS 编码的核心原理。为了清晰起见,省略了一些边界检查和优化。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>

/* ============================================================
 * Simple FSE (Finite State Entropy) Encoder
 * 基于 tANS (table-based Asymmetric Numeral Systems)
 *
 * 本实现用于教学目的,展示 FSE 的核心原理。
 * 生产代码请使用 Yann Collet 的 FiniteStateEntropy 库。
 * ============================================================ */

#define FSE_MAX_TABLELOG    12
#define FSE_MAX_TABLESIZE   (1 << FSE_MAX_TABLELOG)
#define FSE_MAX_SYMBOL      255
#define FSE_MIN_TABLELOG    5

/* ---- 比特流写入器 ---- */
typedef struct {
    uint64_t  bitContainer;
    unsigned  bitPos;
    uint8_t*  startPtr;
    uint8_t*  ptr;
    uint8_t*  endPtr;
} BitWriter;

static void BW_init(BitWriter* bw, void* dst, size_t dstSize) {
    bw->bitContainer = 0;
    bw->bitPos = 0;
    bw->startPtr = (uint8_t*)dst;
    bw->ptr = bw->startPtr;
    bw->endPtr = bw->startPtr + dstSize;
}

static void BW_addBits(BitWriter* bw, uint64_t value, unsigned nbBits) {
    bw->bitContainer |= (value & ((1ULL << nbBits) - 1)) << bw->bitPos;
    bw->bitPos += nbBits;
}

static void BW_flush(BitWriter* bw) {
    /* 将完整字节写出 */
    while (bw->bitPos >= 8) {
        if (bw->ptr < bw->endPtr)
            *(bw->ptr++) = (uint8_t)(bw->bitContainer & 0xFF);
        bw->bitContainer >>= 8;
        bw->bitPos -= 8;
    }
}

static size_t BW_close(BitWriter* bw) {
    /* 标记结束位:写入一个 1-bit 作为哨兵 */
    BW_addBits(bw, 1, 1);
    BW_flush(bw);
    if (bw->bitPos > 0) {
        if (bw->ptr < bw->endPtr)
            *(bw->ptr++) = (uint8_t)(bw->bitContainer & 0xFF);
    }
    return (size_t)(bw->ptr - bw->startPtr);
}

/* ---- 比特流读取器(简化版) ---- */
typedef struct {
    uint64_t      bitContainer;
    unsigned      bitsConsumed;
    const uint8_t *ptr;
    const uint8_t *start;
} BitReader;

static void BR_init(BitReader* br, const void* src, size_t srcSize) {
    br->start = (const uint8_t*)src;
    br->ptr = br->start + srcSize;
    br->bitsConsumed = 0;
    br->bitContainer = 0;
    size_t nbBytes = srcSize < 8 ? srcSize : 8;
    const uint8_t* p = br->ptr - nbBytes;
    br->ptr = p;
    for (size_t i = 0; i < nbBytes; i++)
        br->bitContainer |= ((uint64_t)p[i]) << (i * 8);
    unsigned lastByte = ((const uint8_t*)src)[srcSize - 1];
    unsigned sentinel = 0;
    while (sentinel < 8 && !((lastByte >> (7 - sentinel)) & 1))
        sentinel++;
    br->bitsConsumed = (unsigned)(8 * (8 - nbBytes)) + sentinel + 1;
}

static uint64_t BR_readBits(BitReader* br, unsigned nbBits) {
    uint64_t value = (br->bitContainer >> br->bitsConsumed) & ((1ULL << nbBits) - 1);
    br->bitsConsumed += nbBits;
    return value;
}

static void BR_reload(BitReader* br) {
    while (br->bitsConsumed >= 8 && br->ptr > br->start) {
        br->ptr--;
        br->bitContainer |= ((uint64_t)(*br->ptr)) << (64 - br->bitsConsumed);
        br->bitsConsumed -= 8;
    }
}

/* ---- FSE 编码表 ---- */
typedef struct {
    int16_t   deltaFindState;
    uint16_t  deltaNbBits;
} FSE_CTableEntry;

typedef struct {
    uint8_t   symbol;
    uint8_t   nbBits;
    uint16_t  newStateBaseline;
} FSE_DTableEntry;

typedef struct {
    unsigned        tableLog;
    unsigned        maxSymbol;
    int16_t         normalizedCounter[FSE_MAX_SYMBOL + 1];
    uint8_t         symbolTable[FSE_MAX_TABLESIZE];
    FSE_CTableEntry encodeTable[FSE_MAX_TABLESIZE];
    FSE_DTableEntry decodeTable[FSE_MAX_TABLESIZE];
} FSE_Table;

/* ---- 统计频率并归一化 ---- */
static void FSE_countFrequencies(const uint8_t* src, size_t srcSize,
                                  unsigned* count, unsigned* maxSymbolPtr)
{
    memset(count, 0, sizeof(unsigned) * (FSE_MAX_SYMBOL + 1));
    unsigned maxSymbol = 0;
    for (size_t i = 0; i < srcSize; i++) {
        count[src[i]]++;
        if (src[i] > maxSymbol) maxSymbol = src[i];
    }
    *maxSymbolPtr = maxSymbol;
}

static void FSE_normalizeCount(int16_t* normalizedCounter,
                                unsigned tableLog,
                                const unsigned* count,
                                size_t srcSize,
                                unsigned maxSymbol)
{
    unsigned tableSize = 1u << tableLog;
    uint64_t scale = ((uint64_t)tableSize << 16) / (uint64_t)srcSize;
    int32_t distributed = 0;

    for (unsigned s = 0; s <= maxSymbol; s++) {
        if (count[s] == 0) {
            normalizedCounter[s] = 0;
            continue;
        }
        if (count[s] == srcSize) {
            /* 唯一符号:全部分配 */
            normalizedCounter[s] = (int16_t)tableSize;
            return;
        }
        int32_t proba = (int32_t)((count[s] * scale) >> 16);
        if (proba < 1) proba = 1;   /* 确保每个出现的符号至少分配 1 */
        normalizedCounter[s] = (int16_t)proba;
        distributed += proba;
    }

    /* 修正总和使其等于 tableSize */
    int32_t diff = (int32_t)tableSize - distributed;
    /* 将差值加到最频繁的符号上 */
    unsigned bestSymbol = 0;
    unsigned bestCount = 0;
    for (unsigned s = 0; s <= maxSymbol; s++) {
        if (count[s] > bestCount) {
            bestCount = count[s];
            bestSymbol = s;
        }
    }
    normalizedCounter[bestSymbol] += (int16_t)diff;
}

/* ---- 构建符号分配表 ---- */
static void FSE_buildSymbolTable(FSE_Table* table) {
    unsigned tableSize = 1u << table->tableLog;
    unsigned highThreshold = tableSize - 1;
    unsigned position = 0;

    /* 步长采用 zstd 使用的散布公式 */
    unsigned step = (tableSize >> 1) + (tableSize >> 3) + 3;
    unsigned tableMask = tableSize - 1;

    /* 首先处理概率为 -1 的符号(低概率) */
    for (unsigned s = 0; s <= table->maxSymbol; s++) {
        if (table->normalizedCounter[s] == -1) {
            table->symbolTable[highThreshold--] = (uint8_t)s;
            /* -1 实际代表概率 < 1,占用 1 个位置 */
        }
    }

    /* 按概率散布其他符号 */
    for (unsigned s = 0; s <= table->maxSymbol; s++) {
        int16_t nc = table->normalizedCounter[s];
        if (nc <= 0) continue;
        for (int16_t i = 0; i < nc; i++) {
            table->symbolTable[position] = (uint8_t)s;
            /* 跳过已被低概率符号占用的位置 */
            do {
                position = (position + step) & tableMask;
            } while (position > highThreshold);
        }
    }
}

/* ---- 构建编码表 ---- */
static void FSE_buildEncodeTable(FSE_Table* table) {
    unsigned tableSize = 1u << table->tableLog;
    unsigned symbolNext[FSE_MAX_SYMBOL + 1];

    /* 初始化每个符号的起始状态 */
    for (unsigned s = 0; s <= table->maxSymbol; s++) {
        int16_t nc = table->normalizedCounter[s];
        if (nc <= 0) {
            symbolNext[s] = 0;
            continue;
        }
        symbolNext[s] = (unsigned)nc;
    }

    /* 为每个表位构建编码条目 */
    for (unsigned i = 0; i < tableSize; i++) {
        uint8_t symbol = table->symbolTable[i];
        unsigned stateValue = symbolNext[symbol]++;

        /* 计算该状态需要输出多少位 */
        unsigned nbBitsOut = table->tableLog
                             - __builtin_clz(stateValue)
                             + __builtin_clz(1);
        /* 简化:用对数关系计算 */
        unsigned k = 0;
        while ((1u << k) <= stateValue) k++;
        k = table->tableLog - k + 1;
        if (stateValue == (1u << (table->tableLog - k)))
            k--;

        table->encodeTable[stateValue].deltaNbBits =
            (uint16_t)((table->tableLog - k) << 16);
        table->encodeTable[stateValue].deltaFindState =
            (int16_t)(i - stateValue);
    }
}

/* ---- 构建解码表 ---- */
static void FSE_buildDecodeTable(FSE_Table* table) {
    unsigned tableSize = 1u << table->tableLog;
    unsigned symbolNext[FSE_MAX_SYMBOL + 1];

    for (unsigned s = 0; s <= table->maxSymbol; s++) {
        int16_t nc = table->normalizedCounter[s];
        if (nc <= 0) {
            symbolNext[s] = 0;
            continue;
        }
        symbolNext[s] = (unsigned)nc;
    }

    for (unsigned i = 0; i < tableSize; i++) {
        uint8_t sym = table->symbolTable[i];
        unsigned stateVal = symbolNext[sym]++;

        /* 确定需要读取的位数 */
        unsigned highBit = 0;
        {
            unsigned v = stateVal;
            while (v > 0) { highBit++; v >>= 1; }
        }
        unsigned nbBits = table->tableLog - highBit;

        table->decodeTable[i].symbol = sym;
        table->decodeTable[i].nbBits = (uint8_t)nbBits;
        table->decodeTable[i].newStateBaseline =
            (uint16_t)((stateVal << nbBits) - tableSize);
    }
}

/* ---- FSE 编码(反向遍历) ---- */
static size_t FSE_encode(void* dst, size_t dstCapacity,
                          const uint8_t* src, size_t srcSize,
                          const FSE_Table* table)
{
    BitWriter bw;
    BW_init(&bw, dst, dstCapacity);

    unsigned state = (unsigned)(1u << table->tableLog);
    /* 初始化状态 */
    uint8_t firstSym = src[srcSize - 1];
    /* 简化:找到符号在表中的第一个位置作为初始状态 */
    for (unsigned i = 0; i < (1u << table->tableLog); i++) {
        if (table->symbolTable[i] == firstSym) {
            state = i;
            break;
        }
    }

    /* 反向编码所有符号(跳过最后一个,已用于初始化) */
    for (size_t idx = srcSize - 1; idx > 0; idx--) {
        uint8_t symbol = src[idx - 1];

        /* 找到当前状态对应的编码信息 */
        unsigned tableSize = 1u << table->tableLog;
        unsigned nbBitsOut = 0;
        unsigned threshold = tableSize;

        /* 计算需要输出的位数 */
        int16_t nc = table->normalizedCounter[symbol];
        if (nc > 0) {
            unsigned k = 0;
            unsigned v = (unsigned)nc;
            while ((1u << k) < (int)v) k++;
            unsigned maxBitsOut = table->tableLog - k;
            unsigned minBitsOut = maxBitsOut;
            if ((unsigned)(1 << k) > (unsigned)nc)
                minBitsOut = maxBitsOut + 1;

            threshold = (unsigned)nc << (maxBitsOut + 1);
            nbBitsOut = (state < threshold) ? minBitsOut : (minBitsOut + 1);
        }

        /* 输出状态低位 */
        BW_addBits(&bw, state, nbBitsOut);
        BW_flush(&bw);

        /* 查找新状态 */
        unsigned decodedState = state >> nbBitsOut;
        /* 在符号表中找对应位置 */
        unsigned count = 0;
        unsigned target = decodedState;
        for (unsigned i = 0; i < tableSize; i++) {
            if (table->symbolTable[i] == symbol) {
                if (count == target) {
                    state = i;
                    break;
                }
                count++;
            }
        }
    }

    /* 写出最终状态 */
    BW_addBits(&bw, state, table->tableLog);
    return BW_close(&bw);
}

/* ---- FSE 解码 ---- */
static size_t FSE_decode(uint8_t* dst, size_t dstCapacity,
                          const void* src, size_t srcSize,
                          size_t originalSize,
                          const FSE_Table* table)
{
    BitReader br;
    BR_init(&br, src, srcSize);

    /* 读取初始状态 */
    unsigned state = (unsigned)BR_readBits(&br, table->tableLog);

    size_t written = 0;
    while (written < originalSize && written < dstCapacity) {
        /* 解码符号 */
        FSE_DTableEntry entry = table->decodeTable[state];
        dst[written++] = entry.symbol;

        if (written >= originalSize) break;

        /* 更新状态 */
        BR_reload(&br);
        uint64_t bits = BR_readBits(&br, entry.nbBits);
        state = entry.newStateBaseline + (unsigned)bits;
    }

    return written;
}

/* ---- 演示主函数 ---- */
int main(void) {
    const char* testStr =
        "aaaaaaaaaaaabbbbbbcccddddddddddddddddeeeeeeee"
        "aaaaaaaaabbbbcccccdddddddddddddddddddeeeee"
        "aaaaaaaaaaabbbbbccddddddddddddddddddeeeeeeee";
    const uint8_t* src = (const uint8_t*)testStr;
    size_t srcSize = strlen(testStr);

    printf("=== Simple FSE Encoder Demo ===\n");
    printf("Input:  %zu bytes\n", srcSize);

    unsigned count[FSE_MAX_SYMBOL + 1];
    unsigned maxSymbol;
    FSE_countFrequencies(src, srcSize, count, &maxSymbol);

    FSE_Table table;
    table.tableLog = 6;
    table.maxSymbol = maxSymbol;
    FSE_normalizeCount(table.normalizedCounter, table.tableLog,
                        count, srcSize, maxSymbol);
    FSE_buildSymbolTable(&table);
    FSE_buildEncodeTable(&table);
    FSE_buildDecodeTable(&table);

    uint8_t compressed[4096];
    size_t compSize = FSE_encode(compressed, sizeof(compressed),
                                  src, srcSize, &table);
    printf("Compressed: %zu bytes (ratio: %.2f:1)\n",
           compSize, (double)srcSize / compSize);

    uint8_t decompressed[4096];
    size_t decSize = FSE_decode(decompressed, sizeof(decompressed),
                                 compressed, compSize, srcSize, &table);
    printf("Decode: %s\n",
           (decSize == srcSize && memcmp(src, decompressed, srcSize) == 0)
           ? "PASSED" : "FAILED");

    double entropy = 0.0;
    for (unsigned s = 0; s <= maxSymbol; s++) {
        if (count[s] > 0) {
            double p = (double)count[s] / srcSize;
            entropy -= p * log2(p);
        }
    }
    printf("Theoretical minimum: %.1f bytes (entropy: %.3f bits/symbol)\n",
           entropy * srcSize / 8.0, entropy);
    return 0;
}

这段代码完整展示了 FSE 的”统计-归一化-建表-编码-解码”全流程。 需注意:比特流操作为简化版本;编码器中的状态查找使用了线性搜索(实际 zstd 用 O(1) 预计算表); 未包含 FSE 表头序列化和边界条件处理。

编译运行:

gcc -O2 -o fse_demo fse_demo.c -lm
./fse_demo

十、基准测试:zstd 对比其他压缩算法

以下基准测试使用 Silesia 语料库(211 MB),在 AMD Ryzen 9 7950X 上运行, 单线程,Ubuntu 24.04,所有工具使用最新稳定版本。

压缩率对比

算法             级别    压缩后大小    压缩率    压缩速度       解压速度
                         (MB)         (x:1)    (MB/s)         (MB/s)
---------------------------------------------------------------------------
lz4              1       100.6        2.10     780            4200
lz4              9        86.2        2.45     42             4100
lz4              12       83.8        2.52     10             4150
---------------------------------------------------------------------------
zstd             -3       106.3       1.99     1150           1550
zstd             1        73.4        2.88     530            1480
zstd             3        67.2        3.14     380            1420
zstd             6        62.1        3.40     140            1350
zstd             9        59.8        3.53     62             1300
zstd             12       58.1        3.63     28             1280
zstd             15       56.4        3.74     9.5            1250
zstd             19       54.2        3.89     2.8            1200
zstd             22       53.1        3.97     1.2            1180
---------------------------------------------------------------------------
gzip             1        76.8        2.75     120            410
gzip             6        68.3        3.09     42             400
gzip             9        67.5        3.13     15             395
---------------------------------------------------------------------------
brotli           1        73.8        2.86     320            480
brotli           6        60.5        3.49     28             520
brotli           9        56.1        3.76     4.2            510
brotli           11       51.8        4.07     0.8            490

关键观察

从上表可以清晰看出帕累托前沿的分布:

  1. zstd 在中间地带无人能敌:级别 1-6 的区间内,zstd 以显著优势覆盖了 gzip 的整个压缩率范围,同时速度快 3-10 倍。
  2. brotli 在极高压缩率时有优势:brotli-11 的压缩率超过 zstd-22,但速度慢 50%,且解压也更慢。
  3. lz4 在极速场景下仍是王者:当你需要 4 GB/s 的解压速度时,lz4 不可替代。
  4. 解压速度的一致性:zstd 的解压速度在所有级别下都很稳定(1180-1550 MB/s),而压缩速度的变化范围是 1000 倍。这意味着:写一次、读多次的场景应该使用高级别压缩

小文件场景(平均 1 KB,10 万个文件)

算法             级别    总压缩率    单文件延迟(us)
-------------------------------------------------------
zstd             3       1.82       3.2
zstd+dict        3       4.15       3.5
gzip             6       1.45       28.0
brotli           6       1.78       42.0
brotli+dict      6       3.90       15.0

字典压缩在小文件场景下的优势极为突出。

十一、真实世界应用案例

zstd 已经深入渗透到基础设施的各个层面。

Linux 内核

Linux 5.1 开始支持 zstd 压缩的内核镜像和 initramfs:

# 内核配置
CONFIG_KERNEL_ZSTD=y
CONFIG_RD_ZSTD=y

# Btrfs 文件系统压缩
mount -o compress=zstd:3 /dev/sda1 /mnt

# SquashFS(Ubuntu 22.04+ 默认使用 zstd)
mksquashfs /source /output.sqfs -comp zstd -Xcompression-level 15

Btrfs 实测中,zstd:3 在仅增加 22% CPU 使用的情况下节省 40% 存储空间, 是性能与空间的最佳平衡点。

MySQL / InnoDB

MySQL 8.0.18+ 支持 zstd 页面压缩:

CREATE TABLE logs (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    timestamp DATETIME NOT NULL,
    message TEXT
) ENGINE=InnoDB COMPRESSION='zstd';

RocksDB / LevelDB

RocksDB 是 zstd 最重要的使用者之一(同出 Facebook/Meta):

// RocksDB 配置 zstd 压缩
rocksdb::Options options;

// 分层压缩策略:L0-L1 用 lz4(快速),L2+ 用 zstd(省空间)
options.compression_per_level = {
    rocksdb::kLZ4Compression,      // L0
    rocksdb::kLZ4Compression,      // L1
    rocksdb::kZSTD,                // L2
    rocksdb::kZSTD,                // L3
    rocksdb::kZSTD,                // L4
    rocksdb::kZSTD,                // L5
    rocksdb::kZSTD                 // L6
};

// 最底层使用字典压缩
options.bottommost_compression = rocksdb::kZSTD;
options.bottommost_compression_opts.max_dict_bytes = 16384;
options.bottommost_compression_opts.zstd_max_train_bytes = 1048576;
options.bottommost_compression_opts.enabled = true;

RocksDB 的字典压缩是 per-SST-file 的:每个 SST 文件在刷写时, 用文件内的样本数据训练一个字典,并存储在 SST 文件的 meta block 中。

HTTP 内容编码

zstd 在 RFC 8878 中被标准化为 HTTP 内容编码(Content-Encoding: zstd)。 主要 CDN(Cloudflare、Fastly、Akamai)均已支持。 浏览器方面,Chrome 123+、Firefox 126+、Safari 18+ 均已支持。

其他主要应用

除以上之外,zstd 还被广泛用于:FreeBSD pkg(默认格式)、Arch Linux pacman、 systemd-journald、rsync 3.2.3+(--zstd)、PostgreSQL 15+(WAL 压缩)、 ClickHouse(默认编解码器)、Apache Kafka(compression.type=zstd)、 以及 Apache Parquet 列式文件格式。

十二、工程陷阱与最佳实践

在生产环境中使用 zstd 时,有不少容易踩到的坑。

常见陷阱对照表

陷阱 症状 原因 解决方案
级别选错 CPU 占用过高 对实时数据使用 level 19 实时场景用 1-3,归档用 15+
字典不匹配 解压失败或乱码 压缩和解压使用了不同版本的字典 字典 ID + 版本管理
窗口过大 内存溢出 解压端 windowLog=31 需要 2 GB 内存 ZSTD_dParam_windowLogMax 限制
忽略返回值 静默数据损坏 未检查 ZSTD_isError() 每次调用都检查错误码
上下文复用 内存泄漏 每次压缩都创建新上下文 复用 ZSTD_CCtx,调用 ZSTD_CCtx_reset()
流式接口误用 数据截断 未调用 ZSTD_endStream() 确保 flush 模式正确
字典过期 压缩率逐渐下降 数据模式漂移 定期重新训练字典
多线程竞争 崩溃或损坏 多线程共享同一个上下文 每线程独立上下文,或用 ZSTD_compressCCtx
预分配不足 缓冲区溢出 输出缓冲区太小 使用 ZSTD_compressBound() 预分配
校验和未启用 静默数据损坏 传输或存储错误未检测 生产环境始终启用 ZSTD_c_checksumFlag

正确的上下文复用模式

/* 错误:每次都创建和销毁上下文(频繁 malloc/free) */
void compress_bad(const void* src, size_t srcSize) {
    ZSTD_CCtx* ctx = ZSTD_createCCtx();
    ZSTD_compress(/* ... */);
    ZSTD_freeCCtx(ctx);
}

/* 正确:复用上下文,预分配输出缓冲区 */
void compress_good(ZSTD_CCtx* ctx, void* dst, size_t dstCap,
                    const void* src, size_t srcSize) {
    size_t rc = ZSTD_compressCCtx(ctx, dst, dstCap, src, srcSize, 3);
    if (ZSTD_isError(rc)) {
        fprintf(stderr, "zstd error: %s\n", ZSTD_getErrorName(rc));
    }
    /* 上下文自动重置,内部缓冲区被复用 */
}

压缩级别选择指南

场景                          推荐级别    理由
----------------------------------------------------------------
实时日志/消息队列               1-3       延迟敏感,压缩率够用
网络传输(HTTP/RPC)            3-6       兼顾带宽和 CPU
数据库页面压缩                  3-6       解压延迟影响查询
文件系统透明压缩                 3        操作系统路径,CPU 预算有限
归档/备份                       12-15    一次写多次读
冷数据存储                      19-22    极致压缩,不在乎压缩时间
小文件 + 字典                   3-6      字典已提供大量压缩率增益

个人思考

写完这篇文章,我想分享几个关于压缩技术的个人看法。

第一,zstd 的成功不仅是算法的成功,更是工程的成功。 FSE/tANS 的理论由 Jarek Duda 在 2009 年就提出了,但直到 Yann Collet 将其工程化, 这个理论才真正改变了世界。从 API 设计到内存管理,从多级别策略到字典训练, 每一个工程决策都体现了对实际使用场景的深刻理解。 好的算法需要好的工程师来落地,而好的工程不只是写出正确的代码。

第二,“可调”是 zstd 最被低估的特性。 很多人只知道 zstd 快且压缩率好,但真正强大的是它的可调性。 同一个库,同一套 API,从 lz4 级别的速度到 xz 级别的压缩率, 只需要改一个参数。这大大降低了技术选型的风险—— 你不需要在项目早期就做出”用哪个压缩库”的不可逆决策。

第三,字典压缩解决了一个真实且重要的问题。 在微服务时代,大量的数据交互发生在小消息(100B-10KB)上。 传统压缩对这些小消息几乎无能为力,而字典压缩可以将压缩率提升 2-3 倍。 Facebook 内部正是因为这个需求催生了 zstd 的字典功能。

第四,不要盲目追求最高压缩率。 我见过太多系统在已经 I/O 瓶颈的情况下使用 level 19, 白白浪费了 CPU 资源。正确的做法是:测量你的实际瓶颈是 I/O 还是 CPU, 然后选择帕累托最优的级别。对大多数在线系统来说,level 3 就是最佳选择。

第五,关于 ANS 的专利争议。 Google 曾试图为 ANS 的某些变体申请专利,这引发了学术界和开源社区的强烈反对。 Jarek Duda 本人明确反对任何 ANS 专利,认为这是对公共知识的侵占。 最终 Google 撤回了申请。这提醒我们:开放的标准和无专利的算法对整个生态系统的健康至关重要。 zstd 采用 BSD 许可证发布,这是它被广泛采用的重要原因之一。

压缩算法的演进远未结束。机器学习辅助的压缩、硬件加速的 ANS 解码、 针对特定数据类型的领域专用压缩器,都是值得关注的方向。 但无论技术如何演进,zstd 所展示的”理论突破 + 极致工程”的范式, 将长期作为数据压缩领域的标杆存在。

参考资料

  1. Yann Collet. Zstandard Compression and the application/zstd Media Type. RFC 8478, 2018.
  2. Jarek Duda. Asymmetric numeral systems. arXiv:1311.2540, 2013.
  3. Zstandard Format Specification. RFC 8878, 2021.
  4. Facebook Engineering. Smaller and faster data compression with Zstandard. 2016.
  5. RocksDB Wiki. Compression. https://github.com/facebook/rocksdb/wiki/Compression

上一篇: LZ 字典压缩 下一篇: 算术编码与 ANS 相关阅读: - Huffman 编码与 DEFLATE - 列式压缩


By .