既然不想买高价显卡,我就在代码里优化每一个算子(年终版)

既然不想买高价显卡,我就在代码里优化每一个算子。这话说出来,自己都觉得有点悲壮。2022年了,疫情第三年,我重新回到一个人单干的状态,但接的活却比带团队时更硬核。客户要的是批量抠图服务,一天几十万张的量,用云GPU?那点利润还不够交电费的。只能死磕CPU,把手里这块老旧的消费级显卡和CPU榨出最后一滴油水。

抠图用的是Rembg,一个基于U2-Net的模型,开源,效果好,但原生实现根本没考虑生产环境。第一次压测,单进程处理1000张图,耗时直接奔着半小时去了,内存占用像坐火箭。这不行,客户等不了,服务器也扛不住。

瓶颈太明显了。首先是IO,读图、写图占了大头。然后是模型推理本身,PyTorch的默认设置对批量推理并不友好。最后是Python的GIL锁,多线程在这里就是个摆设。我开始拆解这个黑盒。预处理部分,PIL库的Image.open和Image.save是同步阻塞的,换成OpenCV的cv2.imread和cv2.imwrite,并且用线程池提前把一批图片读进内存,形成生产-消费队列。光这一步,吞吐量就提了30%。

真正的硬骨头在模型推理。PyTorch的torch.no_grad()是基础,但更重要的是利用TorchScript把模型序列化成ScriptModule。这一步折腾了我两天,模型里有动态控制流,trace模式不行,得用script模式,手动改了几处源码,把一些基于输入尺寸的判断逻辑给静态化。ScriptModule的好处是脱离了Python运行时,推理过程更底层,还能享受一些图优化。

然后是多进程。对,必须用多进程绕过GIL。但进程间传递图片数据(numpy数组)开销巨大,不能每次推理都序列化反序列化。我用上了multiprocessing的共享内存(shared_memory),在父进程开辟一块RawArray,子进程直接通过memoryview读写。这里有个巨坑,Linux上没问题,Windows上共享内存的句柄传递能让人debug到天亮。最后写了两套初始化逻辑,用platform.system()判断。

算子级别的优化是最磨人的。看Rembg的源码,它的后处理有个对概率图做阈值化和形态学操作的过程,用的是skimage的函数。我把它全部换成了用OpenCV和NumPy手写的向量化操作。比如,skimage.morphology.remove_small_holes,我换成先用cv2.findContours找出轮廓,再根据面积过滤,最后用cv2.drawContours填充。一行一行地profile,用cProfile和line_profiler盯着,把那些隐藏在库函数里的、不必要的类型转换和内存拷贝全部揪出来。

内存碎片是另一个隐形杀手。Python的垃圾回收并不及时,在处理几十万张图片的长时运行后,内存会缓慢增长。我强制在每个批次处理完后,调用gc.collect(),并且把大的中间变量(比如一批图片的Tensor)放在一个循环外预分配好的内存块里复用,而不是每次新建。

现在这套Flovico Rembg Pro,在16核的服务器上,开8个进程,每个进程内用线程池处理IO,能把CPU利用率打到90%以上。处理单张图片的平均时间从3秒压到了0.8秒,而且内存占用是一条平稳的直线,不再飙升。客户那边一天百万张的任务,也能稳稳当当地跑下来,不再需要求爷爷告奶奶地去租天价GPU实例。

省下的钱是实实在在的,但更重要的是这种掌控感。当团队散了,当大环境不确定,能握在手里的,就是这些对底层代码的、近乎偏执的优化能力。它让我觉得安全。什么管理,什么商业模式,都是虚的,代码跑出来的效率和成本,才是硬通货。

身体还是有点跟不上了。长时间盯着profiler的输出和闪烁的终端,颈椎和眼睛都在抗议。最近开始强迫自己每优化一小时,就去做一组引体向上。搞技术是脑力活,到最后,拼的还真是体力。得撑住,这套优化方案,明年说不定又能包装成一个新的服务产品。

© 版权声明
THE END
喜欢就支持一下吧
点赞57 分享