ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

远程桌面分块差分的numpy陷阱:一次归约写法换来7.7倍提速

远程桌面分块差分的numpy陷阱:一次归约写法换来7.7倍提速 02-分块差分的numpy陷阱一次归约写法换来7.7倍提速作者黒漂技术佬上篇我们搞定了怎么把屏幕拍下来。现在画面到了手里是一张 RGB 的 numpy 数组。接下来要回答一个核心问题这一帧和上一帧比到底哪些地方变了远程桌面的带宽就靠这个问题省出来。如果整屏传2560×1440 的图怎么压都肉疼但如果只传变了的那几块办公场景里变化可能只占屏幕的 0.3%带宽直接砍到脚踝。这比一比的动作每秒要做 20 次。它慢一点整条链路就卡一点。项目在 Phase 0b 实测里把这个环节从一个 118 ms 的慢写法优化到了 15.4 ms——快了7.7 倍而且检出结果完全一样(都是 209 块)。本篇就拆开讲直觉写法为什么慢以及那次归约(reduce)改法到底妙在哪。一、为什么要分块而不是直接比全图先说直觉两张图逐像素相减取绝对值大于阈值的地方就是变了。这思路没错。但变了的地方是零散的像素点没法直接编码——你得告诉对端第几块图、贴到哪。所以业界(也包括本项目)的做法是把屏幕切成固定大小的方块(本项目用 64×64)以块为最小单位判断变化。一块里只要有一个像素变化超过阈值整块就算脏块要传。好处有两个编码、传输、还原都以块为单位逻辑干净。一块 64×64 才 12 KB 出头传起来零碎但可控。所以差分的第一步是把(H, W, 3)的图reshape 成(块行, 64, 块列, 64, 3)的形状然后逐块算这块的最大像素差。二、直觉写法(慢的那个)新手看到逐块算最大像素差很容易写出下面这种# 直觉写法:先把两张图转 int16 避免溢出,然后逐像素 abs 差,# 再沿通道轴(第 2 轴,也就是颜色轴)取 max 得到每块差异图,# 最后再归约得到 (块行, 块列)aa.astype(np.int16)bb.astype(np.int16)dnp.abs(a-b)# (th, 64, tw, 64, 3)per_pixeld.max(axis2)# 先沿颜色轴压一遍 - (th, 64, tw, 64)per_tileper_pixel.max(axis(1,3))# 再沿两个空间轴压 - (th, tw)这个写法语义完全正确但项目实测在原生 2560×1408 上要118.2 ms。注意这还只是算哪些块变了这一步后面编码还没开始呢。118 ms 意味着每秒最多处理 8 帧——直接把 20 fps 的目标按死在地板上了。三、优化写法(快 7.7 倍的那个)项目源码里最终的写法是这样的(关键几行):# 先裁到分块整数倍,否则 reshape 会报错(后面第六节细说)ph,pwth*tile_size,tw*tile_size anp.ascontiguousarray(cur[:ph,:pw]).reshape(th,tile_size,tw,tile_size,3)bnp.ascontiguousarray(prev[:ph,:pw]).reshape(th,tile_size,tw,tile_size,3)# uint8 相减不需要转 int16:maximum - minimum 恒为非负且不溢出dnp.maximum(a,b)-np.minimum(a,b)# 关键优化:颜色通道、两个空间轴一次性一起归约per_tiled.max(axis(1,3,4))# (th, tw)对照实验的结果算法原生 2560×1408 耗时np.abs(a.astype(int16) - b.astype(int16)).max(axis2)再归约118.2 ms(maximum - minimum).max(axis(1,3,4))✅15.4 ms快 7.7 倍而且检出结果完全一致——都是 209 块。也就是说这不是用精度换速度的妥协是纯粹的写法更对。四、为什么快根子在 numpy 的小尾轴归约要理解这个 7.7 倍得先接受一个有点反直觉的事实numpy 对排在后面的轴(axis 序号大)做归约开销远高于对前面的轴做归约。原因在内存布局。numpy 数组默认是 C 顺序(row-major)最后一个轴(axis4也就是颜色通道)在内存里是连续存放的。归约意味着跨轴聚合当被归约的轴正好是最内层、最密的那个轴时numpy 要反复跳着访问内存缓存命中率暴跌CPU 干大量无用功。直觉写法里这几步d.max(axis2)# 沿颜色轴(小尾轴)先压一遍....max(axis(1,3))# 再压空间轴等于专门挑了那个最慢的轴先归约了一次把颜色通道三个值逐个算绝对值差、取 max——而这一步的差对最终结果毫无额外信息贡献纯粹是 3 倍的无用计算。优化写法换了个思路d np.maximum(a, b) - np.minimum(a, b)先把整个(th, 64, tw, 64, 3)的块差异一次性算出来(这一步是逐元素运算numPy 非常擅长而且内存连续访问)然后最后一步才d.max(axis(1, 3, 4))把颜色轴(4)、两个空间轴(1、3)一起归约掉直接得到(th, tw)。一句话别急着归约小尾轴先把所有轴的活儿用逐元素运算一次性干完最后再一次性归约。归约次数从多次小归约变成一次大归约且避开了最慢的小尾轴优先归约陷阱。五、uint8 相减为什么不用转 int16直觉写法里a.astype(np.int16)是个潜意识的防溢出动作大家怕uint8相减得到负数会溢出成奇怪的大正数。但本项目用np.maximum(a, b) - np.minimum(a, b)巧妙地绕开了这个问题。讲清楚为什么安全对同一个像素位置的两张图maximum(a, b)取较大的那个minimum(a, b)取较小的那个。大减小结果恒为非负而且最大就是 255(一方 255、一方 0)最小是 0。所以差值落在[0, 255]闭区间里根本不会溢出,uint8完全够用。既省了一次astype(int16)的类型转换开销(整张大图转一遍不便宜)又避免了 int16 数组体积翻倍带来的内存带宽浪费。一举两得。顺带一提为什么不是abs(a - b)因为a - b在 uint8 下若 a b 会下溢成巨大的正数abs 也救不回来。所以必须用max - min这种先对齐大小再减的写法这才是正确且快的关键。六、一个不起眼的坑必须裁到分块的整数倍代码里这两行不是装饰ph,pwth*tile_size,tw*tile_size anp.ascontiguousarray(cur[:ph,:pw]).reshape(th,tile_size,tw,tile_size,3)为什么要先cur[:ph, :pw]裁一刀因为reshape(th, 64, tw, 64, 3)要求总像素数正好能被64×64×3整除。而真实分辨率经常不凑巧:本项目主屏 2560×1440,1440 不是 64 的倍数(64×221408,64×231472)。不裁剪直接 reshape,numpy 会直接报错cannot reshape array of size ... into shape ...。所以必须先裁到分块的整数倍ph 22×64 1408,pw 40×64 2560。也就是把 2560×1440 对齐成2560×1408裁掉底部那 32 行没人看的区域(屏幕最底下通常是任务栏无关紧要)。这不是性能问题是不裁就跑不起来的正确性问题。七、对齐之后880 块是怎么算出来的对齐到 2560×1408 后分块数一眼可算块列 tw 2560 / 64 40 块行 th 1408 / 64 22 总块数 40 × 22 880 块也就是说这张屏被切成 880 个 64×64 的小格子。每秒 20 次每次拿当前帧和上一帧做上面那套差分产出一份哪些块脏了的坐标列表交给下一环(拼 atlas、做 JPEG)去编码。为什么是 64×64这是块大小的经验值太小则块数爆炸、坐标表膨胀太大则一变传一大片浪费带宽。64 在定位精度和坐标开销之间取得了不错的平衡项目实测也验证了这个尺寸下差分 编码的总开销可控。八、把收益放大 20 倍来看单看一帧省了 102.8 ms(118.2 - 15.4)可能没什么感觉。但这步差分每秒跑 20 次所以真实收益是每帧省时 118.2 - 15.4 102.8 ms 每秒省时 102.8 × 20 ≈ 2.06 秒(CPU 时间)也就是说这个写法改动等于每秒给采集编码线程白捡了 2 秒的 CPU 预算。原本直觉写法每秒要吃掉约 2.36 秒的 CPU(118.2×20)光差分就把单核榨干还超支改完之后只要 0.3 秒剩下的 CPU 全留给 atlas、JPEG 和发送。难怪项目能把 20 fps 稳稳跑在单核上——很多卡不是算法错了是这种不起眼的归约顺序在悄悄吞算力。顺带说一句这个对照实验是可信的两组写法喂的是同一对输入帧(当前帧 vs 上一帧)产出的是同一份脏块集合(都是 209 块)。也就是说7.7 倍纯粹来自写法更对不是用精度换速度、也不是换了数据蒙混。做性能优化时这种同输入、同输出、只换实现的对照才是能拍胸脯的结论。还有一处细节值得提np.ascontiguousarray(cur[:ph, :pw])这一步看起来多余——cur[:ph, :pw]切片后本来就是连续内存。但ascontiguousarray是道保险后续若有人把 crop 改成非连续操作(比如转置、高级索引),reshape 仍不会崩。它对连续输入几乎零成本却堵住了未来改崩的隐患。优化归优化正确性护栏不能拆。九、小结分块差分这一环项目踩过的最值钱的一个坑就是 numpy 的小尾轴归约陷阱直觉写法先max(axis2)压颜色轴等于专门挑最慢的轴先做无用功118 ms每秒要吞 2.36 秒 CPU。优化写法先max - min逐元素算全差异最后max(axis(1,3,4))一次性归约15.4 ms且结果完全一致(209 块)每秒只花 0.3 秒白捡 2 秒 CPU。uint8用max - min而非abs(a-b)既防溢出又省类型转换。真实分辨率不整除分块大小时必须先裁到整数倍否则 reshape 直接崩。2560×1408 对齐后是 22×40 880 块。对照实验同输入同输出7.7 倍是纯写法收益不是精度妥协。
返回列表