轻量级H.264解码器edge264全面解析

edge264是一个采用现代编程技术开发的极简H.264软件解码器,支持最高8K分辨率、多线程解码和MVC 3D配置,具有卓越的性能表现和紧凑的代码结构设计。

edge264

极简软件解码器,为H.264/AVC视频格式提供最新性能表现。请注意这是一个正在进行中的项目,在制作GStreamer/VLC插件后将准备就绪。

功能特性

  • 支持渐进式High和MVC 3D配置,最高级别6.2
  • 最高8K UHD的任何分辨率
  • 8位4:2:0平面YUV输出
  • 切片和任意切片顺序
  • 切片和帧多线程
  • 每切片参考图片列表
  • 内存管理控制操作
  • 长期参考帧

支持平台

  • Windows: x86, x64
  • Linux: x86, x64, ARM64
  • Mac OS: x64

编译和测试

edge264完全使用C语言开发,采用128位向量扩展和向量内部函数,可以使用GNU GCC或LLVM Clang编译。SDL2运行时库可用于(可选)通过edge264_test启用显示。

以下是调整编译库文件的make选项:

  • CC - 用于将源文件转换为目标文件的C编译器(默认cc)
  • CFLAGS - 传递给CC和TARGETCC的额外编译标志
  • TARGETCC - 用于将目标文件链接到库文件的C编译器(默认CC)
  • LDFLAGS - 传递给TARGETCC的额外编译标志
  • TARGETOS - 结果文件的命名约定,在Windows|Linux|Darwin中(默认主机)
  • VARIANTS - 逗号分隔的附加变体列表,包含在库中并在运行时选择(默认logs)

变体包括:

  • x86-64-v2 - 为x86-64微架构级别2编译的变体(SSSE3、SSE4.1和POPCOUNT)
  • x86-64-v3 - 为x86-64微架构级别3编译的变体(AVX2、BMI、LZCNT、MOVBE)
  • logs - 以YAML格式编译日志支持的变体(头部和切片)
1
$ make CFLAGS="-march=x86-64" VARIANTS=x86-64-v2,x86-64-v3 BUILD_TEST=no # x86构建示例

自动化测试程序edge264_test可以浏览给定目录中的文件,解码每个<video>.264文件,并将其输出与每个同级文件<video>.yuv(如果找到)进行比较。在AVCv1、FRExt和MVC一致性比特流集合中,109/224个文件无错误解码,其余使用尚不支持的功能。

1
2
3
4
$ make
$ ./edge264_test --help # 打印所有可用选项
$ ffmpeg -i vid.mp4 -vcodec copy -bsf h264_mp4toannexb -an vid.264 # 可选,从MP4格式转换
$ ./edge264_test -d vid.264 # 将-d替换为-b以进行基准测试而不是显示

示例代码

这是一个完整的示例,从命令行打开Annex B格式的输入文件,并将其解码的帧以平面YUV顺序转储到标准输出。有关更完整的示例,请参阅edge264_test.c,它还可以显示帧。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>

#include "edge264.h"

int main(int argc, char *argv[]) {
	int fd = open(argv[1], O_RDONLY);
	struct stat st;
	fstat(fd, &st);
	uint8_t *buf = mmap(NULL, st.st_size, PROT_READ, MAP_SHARED, fd, 0);
	const uint8_t *nal = buf + 3 + (buf[2] == 0); // 跳过[0]001分隔符
	const uint8_t *end = buf + st.st_size;
	// 自动线程,无日志,自动分配
	Edge264Decoder *dec = edge264_alloc(-1, NULL, NULL, 0, NULL, NULL, NULL);
	Edge264Frame frm;
	int res;
	do {
		res = edge264_decode_NAL(dec, nal, end, 0, NULL, NULL, &nal);
		while (!edge264_get_frame(dec, &frm, 0)) {
			for (int y = 0; y < frm.height_Y; y++)
				write(1, frm.samples[0] + y * frm.stride_Y, frm.width_Y);
			for (int y = 0; y < frm.height_C; y++)
				write(1, frm.samples[1] + y * frm.stride_C, frm.width_C);
			for (int y = 0; y < frm.height_C; y++)
				write(1, frm.samples[2] + y * frm.stride_C, frm.width_C);
		}
	} while (res == 0 || res == ENOBUFS);
	edge264_free(&dec);
	munmap(buf, st.st_size);
	close(fd);
	return 0;
}

API参考

const uint8_t * edge264_find_start_code(buf, end, four_byte)

返回指向下一个三或四字节(0)001起始代码前缀的指针,如果未找到则返回end。

参数:

  • const uint8_t * buf - 要搜索的缓冲区的第一个字节
  • const uint8_t * end - 缓冲区后面的第一个无效字节,停止搜索
  • int four_byte - 如果为0则寻找001前缀,否则寻找0001

Edge264Decoder * edge264_alloc(n_threads, log_cb, log_arg, log_mbs, alloc_cb, free_cb, alloc_arg)

分配并初始化解码上下文。

参数:

  • int n_threads - 后台工作线程数,0禁用多线程,-1在运行时检测逻辑核心数
  • void (* log_cb)(const char * str, void * log_arg) - 如果不为NULL,则是一个fputs兼容的函数指针,edge264_decode_NAL将调用它以记录每个头部、SEI或宏块(需要logs变体,否则在运行时失败,从同一线程调用,多线程解码中的宏块除外)
  • void * log_arg - 传递给log_cb的自定义值
  • int log_mbs - 设置为1以启用宏块日志记录
  • void (* alloc_cb)(void ** samples, unsigned samples_size, void ** mbs, unsigned mbs_size, int errno_on_fail, void * alloc_arg) - 如果不为NULL,则是一个函数指针,edge264_decode_NAL将调用(在同一线程上)而不是malloc来请求为帧分配样本和宏块缓冲区(errno_on_fail对于强制分配是ENOMEM,或者对于可能跳过以节省内存但减少播放平滑度的分配是ENOBUFS)
  • void (* free_cb)(void * samples, void * mbs, void * alloc_arg) - 如果不为NULL,则是一个函数指针,edge264_decode_NAL和edge264_free将调用(在同一线程上)以释放通过alloc_cb分配的缓冲区
  • void * alloc_arg - 传递给alloc_cb和free_cb的自定义值

int edge264_decode_NAL(dec, buf, end, non_blocking, free_cb, free_arg, next_NAL)

解码包含任何参数集或切片的单个NAL单元。

参数:

  • Edge264Decoder * dec - 初始化的解码上下文
  • const uint8_t * buf - NAL单元的第一个字节(包含nal_unit_type)
  • const uint8_t * end - 缓冲区后面的第一个字节(最大缓冲区大小在32位上是2^31-1,在64位上是2^63-1)
  • int non_blocking - 如果当前线程有其他处理因此不能在此阻塞,则设置为1
  • void (* free_cb)(void * free_arg, int ret) - 当多线程时可能从另一个线程调用的回调,以发出解析结束信号并释放NAL缓冲区
  • void * free_arg - 将传递给free_cb的自定义值
  • const uint8_t ** next_NAL - 如果不为NULL且返回代码是0|ENOTSUP|EBADMSG,将接收指向Annex B流中下一个起始代码之后的下一个NAL单元的指针

返回代码:

  • 0 成功
  • ENOTSUP 不支持的流(解码可能继续但可能返回零帧)
  • EBADMSG 无效流(解码可能继续但可能显示视觉伪影,如果您可以用另一个解码器检查流实际上是无瑕疵的,请考虑填写错误报告🙏)
  • EINVAL 如果函数使用dec == NULL或dec->buf == NULL调用
  • ENODATA 如果函数在dec->buf >= dec->end时调用
  • ENOMEM 如果malloc未能分配内存
  • ENOBUFS 如果应使用edge264_get_frame消耗更多帧以释放图片槽
  • EWOULDBLOCK 如果非阻塞函数在图片槽可用之前必须等待

int edge264_get_frame(dec, out, borrow)

获取下一个准备输出的帧。

参数:

  • Edge264Decoder * dec - 初始化的解码上下文
  • Edge264Frame *out - 将填充返回帧数据的结构
  • int borrow - 如果为0,则可以访问帧直到下一次调用edge264_decode_NAL,否则应使用edge264_return_frame显式返回帧。注意访问不是独占的,它可以同时用作其他帧的参考。

返回代码:

  • 0 成功(返回一帧)
  • EINVAL 如果函数使用dec == NULL或out == NULL调用
  • ENOMSG 如果此时没有要输出的帧

虽然参考帧可能在实际显示之前被解码(例如B-Pyramid技术),但所有帧在显示之前都被缓冲以进行重新排序:

  • 解码非参考帧会释放它和所有设置在其之前显示的所有帧。
  • 解码关键帧会释放所有存储的帧(但不是关键帧本身,它可能稍后被重新排序)。
  • 超过为重新排序保留的最大帧数会释放显示顺序中的下一帧。
  • 缺少可用的帧缓冲区会释放显示顺序中的下一个非参考帧(以挽救其缓冲区)和在其之前显示的所有参考帧。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
typedef struct Edge264Frame {
	const uint8_t *samples[3]; // Y/Cb/Cr平面
	const uint8_t *samples_mvc[3]; // 第二视图
	const uint8_t *mb_errors; // 每个宏块错误的概率(0..100),如果没有错误则为NULL,值在内存中按stride_mb间隔
	int8_t pixel_depth_Y; // 0表示8位,1表示16位
	int8_t pixel_depth_C;
	int16_t width_Y;
	int16_t width_C;
	int16_t height_Y;
	int16_t height_C;
	int16_t stride_Y;
	int16_t stride_C;
	int16_t stride_mb;
	uint32_t FrameId;
	uint32_t FrameId_mvc; // 第二视图
	int16_t frame_crop_offsets[4]; // {top,right,bottom,left},用于导出具有16x16宏块的原始帧
	void *return_arg;
} Edge264Frame;

void edge264_return_frame(dec, return_arg)

如果帧是从先前对edge264_get_frame的调用中借用的,则归还其所有权。

参数:

  • Edge264Decoder * dec - 初始化的解码上下文
  • void * return_arg - 存储在要返回的帧中的值

void edge264_flush(dec)

用于寻址时,停止所有后台处理,刷新所有延迟帧同时保持它们分配,并清除内部解码器状态。

参数:

  • Edge264Decoder * dec - 初始化的解码上下文

void edge264_free(pdec)

释放整个解码上下文,并取消设置指针。

参数:

  • Edge264Decoder ** pdec - 指向解码上下文的指针,无论是否初始化

路线图

  • 压力测试(进行中)
  • 多线程(进行中)
  • 错误恢复(进行中)
  • 集成到VLC/ffmpeg/GStreamer
  • ARM32
  • PAFF和MBAFF
  • 4:0:0、4:2:2和4:4:4
  • 9-14位深度,可能具有不同的亮度/色度深度
  • 宏块QP==0的变换旁路
  • SEI消息
  • AVX-2优化

编程技术

我使用edge264来试验新的编程技术,以在现有解码器的基础上提高性能和代码大小,并在FOSDEM'24和FOSDEM'25上介绍了其中一些技术。

  • 单头文件 - 包含所有结构定义、常见常量和枚举、SIMD别名、内联函数和宏,以及每个源文件的导出函数。要理解代码库,您应该首先查看此文件。
  • 代码块而不是函数 - 主解码循环是一个前向流水线,设计为DAG,松散地类似于硬件解码器,节点是非内联函数,边是尾调用。它有助于在任何可能的地方互化代码分支,从而减少代码大小以帮助适应L1缓存。
  • 树分支 - 方向性内部模式使用跳转表实现到树的叶子,然后无条件跳转到主干。它允许在方向模式之间共享底部代码,以减少代码大小。
  • 全局上下文寄存器 - 当编译器(GCC)支持时,指向保存上下文数据的主结构的指针被分配给寄存器。随着Clang最终达到同等性能,放弃了这种技术,因此维护此黑客的动机很小。
  • 默认邻居值 - 邻居可用性测试被替换为围绕每个帧的假邻居宏块。它减少了主解码循环中的条件测试数量,从而减少了代码大小和分支预测器压力。
  • 相对邻居偏移 - 对左/上宏块值的访问是通过内存中的直接偏移完成的,而不是事先将其值复制到缓冲区。它有助于减少主解码循环中的读写。
  • 解析不均匀块形状 - 每个使用mb_type和sub_mb_type指定的Inter宏块铺砌首先转换为位掩码,然后在设置位上迭代以获取正确数量的参考索引和运动向量。这有助于减少代码大小和条件块数量。
  • 使用向量扩展 - GCC的向量扩展与向量内部函数一起使用,以编写更紧凑的代码。所有来自Intel的内部函数都使用较短的名称别名,这也提供了解码器中使用的所有SIMD指令的枚举。
  • 寄存器饱和SIMD - 一些关键的SIMD算法使用比可用寄存器更多的同时向量,有效地饱和寄存器组并有目的地生成堆栈溢出。在某些情况下,这比将算法拆分为较小的位更有效,并且具有随着后续CPU良好扩展的额外好处。
  • 活塞缓存比特流读取器 - 比特流位在size_t[2]中间缓存中读取,带有尾随设置位以跟踪缓存位数,允许从缓存中每读访问32/64位,并允许从内存中进行宽刷新。
  • 即时SIMD转义移除 - 输入比特流使用向量代码即时取消转义,避免了完整的预处理过程以移除转义序列,从而减少了内存读写。
  • 多架构SIMD编程 - 使用向量扩展以及别名内部函数允许支持Intel SSE和ARM NEON,具有约80%的公共代码和少量#if #else块,同时为两种架构保持最新性能。
  • 数组结构模式 - 帧缓冲区存储为每个不同字段的数组,而不是结构数组,以使用位和向量运算符表达对帧的操作。多线程的任务缓冲区也部分依赖它。
  • 延迟错误检查 - 错误检测在每个类型的NAL单元中执行一次,通过将所有输入值钳制到其预期范围,然后期望rbsp_trailing_bit之后(如果流损坏,捕获错误的概率非常高)。此设计选择在关于解析错误的案例中讨论。

其他尚未介绍的位:

  • 具有FFI友好设计的极简API(7个函数和1个结构)。
  • CAVLC和CABAC的比特流缓存存储在两个size_t变量中,将来可能映射到全局寄存器变量。
  • 输入符号的解码与其解析交错(而不是解析到结构然后解码数据)。它去重了在解析和解码中都存在的分支和循环,甚至消除了存储某些符号的需要(例如mb_type、sub_mb_type、mb_qp_delta)。

测试(进行中)

借助使用edge264输出的相同YAML格式的自定义比特流写入器的帮助,正在tools/raw_tests中创建一组广泛的测试,以压力测试此解码器的最暗角落。下表列出了它们所有,以及实现它们的文件。

(测试表格内容由于篇幅限制,在此不完整翻译,但保持了原文的结构和分类)

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计