联犀 Go SIMD 实战:视频转码从 16.5ms 到 5.46ms

同一组视频,转码一帧从 16.5ms 降到 5.46ms,CPU 耗时减少约 67%。

联犀把 Go 的 SIMD 用在反复处理像素的计算上,再配合代码和编码策略优化,让视频转码更省 CPU,同时保留 Go 服务的部署便利。

352×288 测试流,单核 CPU 耗时;约 67% 为本轮综合优化收益。

为什么用:H.265 进来,还要给浏览器一条 H.264 流

联犀用 ZLMediaKit(下文简称 ZLM)接入摄像头视频。源流是 H.265,但某些浏览器、终端与播放器的组合需要 H.264,视频接进来后,还得解决播放兼容问题。

这次工作就是在 Go 服务里补上 H.265→H.264 转码:从 ZLM 拉取源视频,解码、整理像素、重新编码,再把 H.264 兼容流推回 ZLM,交给需要它的播放路径。

机制示意:SIMD 加速发生在 Go 转码器内部,业务层继续通过同一个转码入口工作。

功能接通后,我们做了真实视频回环和 FFmpeg 对照,又跑了 10 路、100 路压测。接下来要回答的就是:CPU 时间花在哪里,哪些计算值得加速?

性能分析找到了反复处理像素的热点。H.264 编码侧要比较图像小块、计算像素;H.265 解码侧,也有一行行重复执行的像素乘加、缩放与范围限制。我们把 SIMD 用在这些重复计算上。

SIMD 是什么:让 CPU 一次处理一批数据

SIMD 的全称是 Single Instruction, Multiple Data,中文常说"单指令、多数据"。可以先记住一句话:同一条运算指令,同时处理多个数据。

放到视频里就很好理解:一行像素都要做相同的加权计算,普通实现逐个算,SIMD 把一批像素放在一起算。本文后面的实现每批处理 8 个像素,但这不等于整条转码链路就能快 8 倍------数据装入、结果写回,以及其他处理步骤仍有开销。

它和多线程也不是一回事:多线程可以把工作分给多个 CPU 核心,SIMD 则利用一个核心内的并行运算能力。两者可以配合,但都不能替代对耗时位置的分析。

这正是我们在 Go 转码器中尝试 SIMD 的原因:视频每一帧都有大量重复的像素计算,省下单次开销,就有机会降低持续转码的 CPU 成本;业务层仍走原来的 Go 转码入口,不必为了局部加速改写整个服务。

怎样用:看这条转码链路里的真实代码

先接通转码,再优化内部计算

业务层创建转码器的实际配置如下,输入 H.265 视频数据后,继续通过 Feed 获取 H.264 输出:

复制代码
tr := transcode.New(transcode.Options{
    GOP: 0,
    QP: 28,
    EncodeSpeed: 8,
})

这段节选来自转码 worker。SIMD 不需要业务层对每帧另调一个接口,它位于这个转码器内部的解码、编码热点中。

把解码中的一行像素,改为成批计算

下面这处改动来自实际 H.265 解码器的 weightuni_simd.go。它按视频中的权重、缩放和偏移参数计算像素,再限制到输出允许的范围;编解码中称为"加权预测"。

先看对应普通实现的核心循环。src 是一行中间像素,dst 是输出,round 是舍入项,w32 是权重,off 是偏移,mv 是上限:

复制代码
round := int32(1) << uint(log2Wd-1)
w32, off, mv := int32(weight), int32(offset), int32(maxV)
for x := range src {
    v := ((int32(src[x])*w32 + round) >> uint(log2Wd)) + off
    if v < 0 {
        v = 0
    } else if v > mv {
        v = mv
    }
    dst[x] = uint8(v)
}

SIMD 版本把一批像素装入向量,分成两组做较宽位数的乘加,再恢复输出顺序。下面直接节选项目实现,系数准备和尾部处理省略:

复制代码
for ; x+8 <= w; x += 8 {
    v := archsimd.LoadInt16x8Array((*[8]int16)(src[x : x+8]))
    // DotProductPairs 与隔位系数配对:偶/奇两组 dword 各含一半像素积
    e := v.DotProductPairs(cev)
    o := v.DotProductPairs(cov)
    e = e.Add(round).ShiftRight(shift).Add(offs).Max(zeros).Min(maxs)
    o = o.Add(round).ShiftRight(shift).Add(offs).Max(zeros).Min(maxs)
    ordered := e.SaturateToInt16().InterleaveLo(o.SaturateToInt16())
    var tmp [8]int16
    ordered.StoreArray(&tmp)
    for i := 0; i < 8; i++ {
        dst[x+i] = uint8(tmp[i])
    }
}

不用逐个记住方法名,抓住这段变化就够了:原来逐像素执行的"乘权重、舍入、缩放、加偏移、限制范围",现在成批执行,计算规则保持一致。

这里没有直接把中间结果塞回较窄的类型。像素中间值和权重相乘,可能超出原来的数值范围,因此乘加阶段保留更宽的结果;到最后,才按规则限制范围、写成输出像素。顺序错了,可能把本应为 0 的像素算成 255。

完整函数还会处理不足一批的尾部。图像宽度不一定刚好是 8 的倍数,不能因为换成 SIMD 就漏算最后几个像素。

开启路径,同时保留普通实现

这段新增实现使用 simd/archsimd,构建条件如下;另一个文件使用相反条件,提供同名的普通实现:

复制代码
//go:build goexperiment.simd && go1.27 && amd64 && !noasm

//go:build !goexperiment.simd || !go1.27 || !amd64 || noasm

所以,Go 1.27 构建时设置 GOEXPERIMENT=simd,启用的是已经接入的代码路径,不会自动把整个转码器的循环都向量化。上面的条件还明确限定了 amd64;这段实现不直接套到 ARM,其他平台或构建方式使用对应回退路径。

在仓库 backend/share 目录,可分别验证普通构建、实验路径和禁用汇编的回退,再检查真实视频转码回环:

复制代码
go test ./h265/transcode -run TestTranscodePureGoRoundtrip
GOEXPERIMENT=simd go test ./h265/transcode -run TestTranscodePureGoRoundtrip
GOEXPERIMENT=simd go test -tags noasm ./h265/transcode -run TestTranscodePureGoRoundtrip

局部对照检查像素值;真实流回环则把 H.265 转为 H.264,再解码检查帧数与错误。两层都要过,不能只看一个小函数跑得快。

效果如何:拆开看 SIMD、流水线和整条服务

单帧 CPU 耗时:16.5→5.46ms 是整轮收益

下面的单帧与并发 CPU 记录来自 Xeon Gold 6240R 环境、352×288 测试流;单路记录将 Go 并行度设为 1。编码策略在优化过程中也有调整,因此不能把阶段间的全部变化当成 SIMD 单项收益。

这次优化有多个阶段:热点像素处理、普通代码和编码速度策略调整后,阶段记录约 7.3ms/帧 ;升级工具链并接入解码侧 Go SIMD 路径后,记录约 6.6ms/帧 ;后来检查到一处编码侧加速函数没有被真实调用,恢复调用后,两次记录均为 5.46ms/帧。

352×288 历史阶段记录,单核 CPU 耗时。各阶段包含不同改动,不把柱间差值直接当成 SIMD 的单项收益。

这也是一个容易忽略的检查点:代码已经写好了,还要确认真实视频流程确实调用到了它。

10 路、100 路:用并发记录核算 CPU 成本

后续同一阶段的满载记录中,10 路每帧 CPU 约 7.65ms ,100 路约 7.61ms 。按每路 5fps 折算,分别约需 0.38 核、3.80 核 ;100 路进程 RSS 约 794MB。

这里的核数是按实测 CPU 时间和帧数折算,不是线上容量承诺。也不能拿单路的 5.46ms,直接乘成所有并发条件下的结果。

与 FFmpeg:保留同阶段对照和真实差距

另一组同阶段 CPU 对照中,纯 Go 默认快速档约 7.3ms/帧 ;FFmpeg 的 libx264 ultrafast 约 2.7ms ,medium 约 9.2ms。

这说明我们的方案在那组条件下已经有了可用的性能位置,ultrafast 仍有明显速度优势。不同编码器即使同用 QP28,也不等于相同画质或码率,不能把这些数字包装成编码器的综合排名。

720p 实时:流水线解决的是另一件事

接入 ZLM 后,较大分辨率又暴露了新的吞吐问题。我们把解码与图像整理、编码两段重叠执行:上一帧在编码时,开始处理下一帧。

原始记录中,同一份 720p 测试视频、250 帧,关闭流水线约 13.8fps ,开启后约 25.5fps ,达到这组 25fps 视频源的实时要求。但同组 1080p 记录只有约 11.8fps,不能把 720p 达标写成所有分辨率都能实时。

SIMD 减少部分计算开销,流水线利用不同阶段的并行,两者一起服务转码目标,却不是同一种收益。CPU 时间和墙钟帧率要分开记录。

哪些场景适用,哪些不适用?

适合优先尝试:大量数据反复做相同计算

视频、图像中的像素计算。 比如成批计算图像块差异、像素加权、颜色转换。同类数据多、计算规则重复,往往容易找到值得优化的部分。本文选择的就是 H.265 解码中的一行像素处理。

音频中的批量采样处理。 例如对一段采样统一调整音量,或把多路采样按规则混合,也可以考虑成批计算。

规则的数值计算。 例如向量点积、批量距离计算:数据能连续取用、计算关系明确,又占了较多 CPU 时间,就值得做 SIMD 对照。音频和数值计算是适用方向,本文实测只覆盖视频转码。

这些场景的共同点是:同样的计算重复得多,数据容易成批取用,而且它确实是 CPU 热点。 仅仅"数据量大",还不足以判断是否适合。

不适合优先投入:慢在等待,或很难成批计算

主要在等网络、磁盘或数据库。 比如拉流慢在网络,接口慢在数据库等待,SIMD 不能缩短这些等待。先查 I/O、查询和数据传输,比改像素算式更直接。

数据很少,或者这段代码本来就不耗时。 少量配置字段、偶尔执行的小计算,省下的时间可能抵不过装入、转换和处理尾部的开销。加上 SIMD,未必更快,还增加了维护负担。

分支很多,或者下一步强依赖上一步。 例如解码中一些按位解析和状态推进,不能直接把相邻步骤放在一起算。这类代码通常要先分析算法;也不是所有解析任务都不能用 SIMD,而是不能照搬像素循环的写法。

数据分散,准备和搬运比计算更贵。 如果先要在大量对象之间跳转、收集数据、转换类型,计算阶段省下的时间可能又花回去了。已经主要受内存取数速度限制时,也不能只靠增加计算并行度期待成倍收益。

这次开发也有反例:另一段图像计算的实验 Go API 版本记录为 70--185ns,现有汇编为 6.4--7.3ns,装载、转换等成本都算在内。早期还有一处实现反复构建系数向量,后来才把固定系数移出每段计算。适合 SIMD 的任务,也可能被不合适的写法拖慢。

落到这次转码,还要守住实现边界

本文新增的解码快路径只针对 8bit 输出的一行像素;其他位深继续由已有路径处理。构建条件限定为 amd64,还须确认目标 CPU 支持所需能力。工具链、处理器或实现条件不满足时,应使用已验证的普通路径,不能把一个构建开关当成任意机器上的加速保证。

同一条视频链路,也不是每个环节都交给 SIMD。拉流等待要查网络,持续高压要管资源;这次 ZLM 接入仍采用按需启动、无人观看停止、CPU 高压时逐路降级。省 CPU 和管好服务负载,要一起做。

数据与代码来源

文中代码节选自联犀 H.265→H.264 转码与 ZLM 接入任务的实际实现,省略的上下文不作为可独立运行的程序。性能数字为该轮开发的历史测试记录,不代表所有视频、机器和部署条件下的表现。

Go 官方资料,可复制访问:

Go 1.27 版本说明:https://go.dev/doc/go1.27

处理器相关 SIMD 接口:https://go.dev/blog/archsimd

场景判断参考 Intel 向量化编程指南(计算依赖与数据布局原则):https://www.intel.com/content/www/us/en/docs/dpcpp-cpp-compiler/developer-guide-reference/2024-1/vectorization-programming-guidelines.html

最后:从一处热点开始,把真实视频跑稳

对联犀来说,这轮优化的出发点很简单:让摄像头的视频在需要 H.264 的终端上顺利播放,同时让 Go 转码服务少占一些 CPU。SIMD 在重复的像素计算上发挥了作用,但最终要交付的,是正确、跟得上播放节奏的视频,而不是一份 SIMD 调用更多的代码。

如果你的 Go 项目也卡在视频处理上,不妨先拿一段常用分辨率的视频做性能分析:热点集中在重复像素计算,就选一处做小范围对照;主要时间花在拉流和等待上,就先解决那个问题。先把一处热点做对、把真实视频跑稳,再扩大优化范围。

欢迎关注我们的开源项目

联犀 things · Go 物联网开源模块

提供设备接入、物模型、设备数据与 OTA 等基础能力。本文介绍的完整 AI 平台能力,不等于该仓库的开源范围。

https://gitee.com/unitedrhino/things

欢迎点亮 Star,也欢迎关注联犀,持续分享 AI+物联网的开发进展、开源实践与应用案例!

历史阅读

相关推荐
octopus_c21 分钟前
STL 之 string:auto 与范围 for、常用接口、模拟实现与深浅拷贝
开发语言·c++·算法
Nerd(BUG版)30 分钟前
KMP学习day5--Kotlin Flow异步流基础
开发语言·学习·kotlin
J.YA1 小时前
String字符串[面试题]
java·开发语言
9527华安12 小时前
FPGA纯verilog实现DP视频接口,支持2K60帧分辨率,提供3套工程源码和技术支持
fpga开发·音视频·dp
xiaoye-duck12 小时前
《C++ 项目:手写大模型接入 SDK》Gemini接口设计(上):初始化、全量响应实现和测试
开发语言·网络·c++·语言模型
nice_lcj52014 小时前
Java Lambda 表达式入门:怎么写、怎么用、常用方法有哪些
java·开发语言·后端·面试
码农小河6614 小时前
基于SSM+JSP+MySQL的在线医疗服务系统的设计与实现
java·开发语言·mysql
nice_lcj52019 小时前
ThreadLocal 入门:它是怎么隔离数据的、为什么会导致内存泄漏
java·开发语言·后端·面试·并发编程·内存泄漏·threadlocal
紧跟先前的步伐19 小时前
Go AI Agent 实战 28|Agent 的预算、超时与停止条件
开发语言·人工智能·后端·ai·golang