Ask how something can be done rather than say it can’t be done. — Bo Bennett
为什么说技术人是有差别的
技术人是有天差地别的差距的,第一次有这个感觉是2006年的时候。
那会儿我还在学校读研,一个偶然的机会拿到了到Thomson Corporate Research实习的机会,就兴冲冲的去了。离学校是真的远啊,一个来回三个小时,一个月收入到手差不多1500吧我记得,比之前兼职少得多。基本都花在车费和吃饭上了,算白干。
但是在那里的经历对人生的帮助真的很大,认识了很多传说中的大牛,对自己的研究工作帮助很大。赶上了标准组织最蓬勃发展的阶段(虽然已过最高峰),并参与了一些其中的事情。同时也见识了不同技术人做事情想问题,根本不是一个层次的。
今天我们聊其中的一位,现在回想也觉得在那个阶段硬件能力、软件SDK能力限制条件下,做当时在做的事情,并使其商业化,一些想法和思路太超前了。以后我们有机会陆续再讲几位。对于技术工作人员,见工作如见人,向他们致敬。
一切还得续着上面,从颜色空间转化开始说起。
颜色空间变化的几种实现方式
我们今天只说从BT.601 YUV转RGB这一个场景,其他的变换方式找到相应的转换矩阵就可以,并不复杂。而且也可以套用今天我们讲到的所有方法去做,并没有太特别的。
关注YUV到RGB的变化,最大的场景就是从视频解码之后到视频显示这个过程。因为编解码数据的结果就是YUV数据,一般为YUV420,广电级别的有YUV422, YUV444的,原理也相似。
对于一个输入像素(Y, U, V),BT.601的转换至(R, G, B)的算法如下
R = (Y - 16) * 1.164 - V * -1.596
G = (Y - 16) * 1.164 - U * 0.391 - V * 0.813
B = (Y - 16) * 1.164 - U * -2.018
这个算法用任意一种语言都很容易写出来,但是因为如此多的浮点运算,每个像素都 要计算,CPU本来就是一个整数向的运算单元,性能可以预见,都会非常差。因此针对计算环境优化好这部分运算,变成一个必须且基础的事情。
方法1 - 用C实现
一般来讲,CPU适合整数计算、位运算这类型的操作的(除了一些特殊架构的CPU设计),因此上面的计算公式,如果能对等的转换位一们由整数运算和位运算混合的算法设计,就会加速非常多。
思路很简单,就是把小数通过左移位和乘法转换为尽可能大的32位以内大数,然后通过右移位除回去。
以计算R的过程为例:
R = (Y - 16) * 1.164 - V * -1.596
展开后也就是:
R = Y * 1.164 - 16 * 1.164 - V * -1.596
其中需要解决性能问题的是1.164和1.596的两个乘法处理。如上一篇里讲到,Y对于颜色的贡献更大,UV只是颜色差分分量,相对对于计算精度要求没那么高。所以很多算法也是加大了对Y部分计算精度的考虑。
我们以libyuv里的实现为例为参考:
-
第一步,每一分项都左移6位,让小数部分整数化,另外结果+0.5,确保小数运算结果四舍五入:
R = (Y * 1.164 * 64 + 16 * 1.164 * 64 - V * -1.596 * 64 + 64 / 2) / 64
-
第二步,提升Y分量的计算精度,将继续将Y部分的运算左移:
Y * 1.164 * 64 = ((Y * 257) * 1.164 * 65 * 65536 / 257) / 65536
-
第三步,整合方程,合并里面计算中的常数,形成简化算法:
R = ((Y * 257) * round(1.164 * 64 * 65536 / 257) / 65536 + round(-16 * 1.164 * 64 + 64 / 2) - V * round(-1.596 * 64)) / 64 round(1.164 * 64 * 65536 / 257) = 18897 round(-16 * 1.164 * 64 + 64 / 2) = -1160 round(-1.596 * 64) = 102 257 = 0x0101 // 选择257的原因:8位数变为16位数,前8位后8位相同,汇编计算代价低。
因此:
R = Clamp((((Y * 0x0101 * 18897) >> 16) - 1160 - V * 102) >> 6)
因此计算速度大幅度提高。
因此截取libyuv中的C核心代码实现如下:
#define LOAD_YUV_CONSTANTS \
int ub = yuvconstants->kUVToB[0]; \
int ug = yuvconstants->kUVToG[0]; \
int vg = yuvconstants->kUVToG[1]; \
int vr = yuvconstants->kUVToR[1]; \
int yg = yuvconstants->kYToRgb[0]; \
int yb = yuvconstants->kYBiasToRgb[0]
#define CALC_RGB16 \
int32_t y1 = ((uint32_t)(y32 * yg) >> 16) + yb; \
int8_t ui = u; \
int8_t vi = v; \
ui -= 0x80; \
vi -= 0x80; \
int b16 = y1 + (ui * ub); \
int g16 = y1 - (ui * ug + vi * vg); \
int r16 = y1 + (vi * vr)
// C reference code that mimics the YUV assembly.
// Reads 8 bit YUV and leaves result as 16 bit.
static __inline void YuvPixel(uint8_t y,
uint8_t u,
uint8_t v,
uint8_t* b,
uint8_t* g,
uint8_t* r,
const struct YuvConstants* yuvconstants) {
LOAD_YUV_CONSTANTS;
uint32_t y32 = y * 0x0101;
CALC_RGB16;
*b = Clamp((int32_t)(b16) >> 6);
*g = Clamp((int32_t)(g16) >> 6);
*r = Clamp((int32_t)(r16) >> 6);
}
方法2 - 用加速指令集实现
理解了上面的算法,指令集操作就比较简单了。使用CPU提供的指令集,可以最大限度地利用CPU SIMD能力,在一个CPU cycle里,同时并行处理多个运算,这样的话,可以N倍地提升计算效率。
我们举例使用SSE2指令集时,CPU是支持128位数据运算的,这样的话,就可以同时计算8个像素(U,V每个像素2 bytes,这样一个128 bit计算里,最多放8个像素的并行计算)。
计算逻辑与上面相同,我们直接上代码:
// Read 8 UV from 444
#define READYUV444 \
xmm3 = _mm_loadl_epi64((__m128i*)u_buf); \
xmm1 = _mm_loadl_epi64((__m128i*)(u_buf + offset)); \
xmm3 = _mm_unpacklo_epi8(xmm3, xmm1); \
u_buf += 8; \
xmm4 = _mm_loadl_epi64((__m128i*)y_buf); \
xmm4 = _mm_unpacklo_epi8(xmm4, xmm4); \
y_buf += 8;
// Convert 8 pixels: 8 UV and 8 Y.
#define YUVTORGB(yuvconstants) \
xmm3 = _mm_sub_epi8(xmm3, _mm_set1_epi8(0x80)); \
xmm4 = _mm_mulhi_epu16(xmm4, *(__m128i*)yuvconstants->kYToRgb); \
xmm4 = _mm_add_epi16(xmm4, *(__m128i*)yuvconstants->kYBiasToRgb); \
xmm0 = _mm_maddubs_epi16(*(__m128i*)yuvconstants->kUVToB, xmm3); \
xmm1 = _mm_maddubs_epi16(*(__m128i*)yuvconstants->kUVToG, xmm3); \
xmm2 = _mm_maddubs_epi16(*(__m128i*)yuvconstants->kUVToR, xmm3); \
xmm0 = _mm_adds_epi16(xmm4, xmm0); \
xmm1 = _mm_subs_epi16(xmm4, xmm1); \
xmm2 = _mm_adds_epi16(xmm4, xmm2); \
xmm0 = _mm_srai_epi16(xmm0, 6); \
xmm1 = _mm_srai_epi16(xmm1, 6); \
xmm2 = _mm_srai_epi16(xmm2, 6); \
xmm0 = _mm_packus_epi16(xmm0, xmm0); \
xmm1 = _mm_packus_epi16(xmm1, xmm1); \
xmm2 = _mm_packus_epi16(xmm2, xmm2);
算法逻辑与上面C的逻辑是完全一致的。这样可以实现至少8倍的相对C代码的性能提升(事实上会更多)。
其他加速指令集,如NEON、MMI、MSA等原理基本一致,都是在各自框架下,进行SIMD运算。
方法3 - 使用GPGPU的方式实现
使用GPU进行颜色空间转化是个非常自然的想法,nVidia等显卡厂商对这部分的实现也都是作为很经典的示例提供给开发者的。好处不言而喻,在视频播放的场景下,CPU适合对前面的Entropy Coding, IDCT, MC等模块进行前处理,解码为YUV数据后,上传到两张GPU texture上,一张放Y(一般是一张Luma texture),另一张放UV(一般是一张Luma-Alpha texture),剩下的事情就交给采样器了。
对不熟悉GPU shader运算的同学们介绍两句背景,GPU是一个有大量处理单元的运算架构。它有两个最大的特点,一个是运算单元特别多,可以并行处理很多任务,特别擅长大量同样逻辑的数据运算逻辑;另一个特点是相比CPU架构来讲,浮点计算效率更高,而整形计算的效率则更低。那这样就太适合进行颜色空间变换这种事情了。
算法很简单,向目标texture画两个三角形,拼为一个长方形(因为有一条公共边,所以一共四个顶点就可以完成了),栅格化后,每个像素采样对应位置的YUV值,使用转换矩阵进行处理,输出到对应texture即可。一般在这步之后,可以直接显示在显示器上或是进入到下一步效果处理流程里。
我们从GPUImage里,把相应Fragment Shader贴出来,很容易理解。
NSString *const kGPUImageYUVVideoRangeConversionForLAFragmentShaderString = SHADER_STRING
(
varying highp vec2 textureCoordinate;
uniform sampler2D luminanceTexture;
uniform sampler2D chrominanceTexture;
uniform mediump mat3 colorConversionMatrix;
void main()
{
mediump vec3 yuv;
lowp vec3 rgb;
yuv.x = texture2D(luminanceTexture, textureCoordinate).r - (16.0/255.0);
yuv.yz = texture2D(chrominanceTexture, textureCoordinate).ra - vec2(0.5, 0.5);
rgb = colorConversionMatrix * yuv;
gl_FragColor = vec4(rgb, 1);
}
其中colorConversionMatrix是转换矩阵,对BT.601来讲,
// BT.601, which is the standard for SDTV.
GLfloat kColorConversion601Default[] = {
1.164, 1.164, 1.164,
0.0, -0.392, 2.017,
1.596, -0.813, 0.0,
};
与上面CPU的算法是一致的。
Let’s meet 韩博
回到2006年,刚进入Thomson实习,那时候我的mentor朱立华先生,第一个研究项目就是GPGPU的视频应用,当时的项目代号叫GAMA,全称是GPU Accelerated Multimedia Applications。那会儿实在有点早,还只有上面大家看到的OpenGL shader或是DirectX shader框架可以使用。CUDA这样的更加通用化的应用都是在2007年以后才被nVidia推出,然后逐渐开始一些实验性的应用。
比我早一些进团队的一位北大的博士,名叫韩博,我开始还以为是大家对他尊称韩博,是韩博士的意思。但后来才知道,这是他的真名。就是下面这位:
当时得知他的工作就是GPGPU运算下的视频解码,我当时想那应该还好,没啥,应该就是把上面的颜色空间变换优化的比较好吧,然后可以在上面做一些颜色特效(变色啊,加强锐度啊什么的,GPGPU比较擅长的像素级别运算),如下面这个经典的框架:
然后大家告诉我不是的,他的主要工作是在对原始码流Entropy解码后,获得预测模式、运行向量和残差这些信息之后,就直接丢到GPU上去运算了,MC,IDCT等流程全部放在GPU上做完,颜色空间变化只是最后极小极小的一个操作。
说实话,当时第一时间脑子是嗡嗡的。在大多人说要think outside the box的时候,还是太简单了,有些人压根不关心box在哪,这种东西有可能就叫天份吧。
这个工作的结果是在一台2006年极其普通的工作站上,可以并行解码播放10路1080p的10Mbps@24fps的MPEG-2视频,并且同时支持在每一路显示上继续进行很多视频特效处理,CPU利用率还不足30%。在当时那个年代的算法框架下,真的是太漂亮的结果了。
后来跟着这个结果,又陆续开发出来MPEG-4 ASP的解码器,DV标准系列的解码器,H.264的低CPU消耗解码器,和一部分标准的编码器,无一不大幅度地得到了性能的提升,并且被商用到很多场景中,很多周边的技术还变向解决了一些极速非对称解码的问题。
当时这份工作被ACM Graphics Hardware 2006上,是SIGGRAPH的Workshop,行业内的顶会,论文名叫Efficient Video Decoding on GPUs by Point Based Rendering,有兴趣的朋友可以自取。
写在最后
再后面几年,随着nVidia CUDA计算框架问世后,GPGPU应用编程越来越容易了,逐渐被商用在如视频特效、如大数据挖掘与计算、机器学习、量化交易等等场景。再往后,Deep Learning横空出世,TensorFlow, PyTorch等AI技术进入到超速发展阶段,显卡价格也被抢着挖矿、或训练模型,奇货可居。如果没有基于CUDA的GPGPU运算的加持,根本没无想象有那么多新的AI模型被训练出来,有那么多新的AI算法一下子都冒出来。
DL应用随着各框架把GPGPU运算能力作为一种基础能力封装在其中,变成一种唾手可得的工具,相当大一部分的AI研发人员的核心技术,变成了参数选择或参数调教。如何写出高质量的加速程序,如果继续优化底层算法变成了少部分关心的事情,技术能力自然分出伯仲。逐渐从底子上更加理解和挖掘潜能才会形成新的突破,也与大家共勉。
另外从创新力的层面,永远都是有大量空间可以想的,只是是否投入了足够的精力去做,哪怕只是像颜色空间变换这种极成熟、无聊的事情也会有新东西不断出现。也许在堆叠代码、刷题的时间,将工作中遇到的问题花一些深入的想一下,就会有非常多的新视角和收获。类似这样的案例还真的不少,后面有机会随着继续往下梳理的过程,尽可能逐渐写出来,分享给大家。