在这个系列里,打算总结一下作为一个程序员,在我日常遇到的许多典型场景下的基本思路,可以先按照这个手册来进行debug,避免走一些歪路
场景一:这个程序比较速度慢
这应该是一个现实中常见的场景。一般而言,程序比较慢的原因包括(以下可能是废话):
- GPU瓶颈
- CPU瓶颈
- IO瓶颈
但是无论哪种,首先要利用profiling工具看一下是否能找到瓶颈,参见:Python Profiling
但是这一篇里,我们来说一些通用的可能检查项
GPU瓶颈
判断GPU瓶颈的方法很简单:watch nvidia-smi
看GPU利用率,如果一直很高大概率就是GPU瓶颈,这里要注意波动情况,波动太大说明还是有cpu瓶颈或者io瓶颈在的
GPU利用率高基本上没啥好方法,能立刻想到的方向包括:
- 半精度:相当有效,直接速度提升一倍,需要注意的是最好做一下回归测试,拿一些例子跑一下,看一下不一致的比例是多少
- 算法优化:这个要具体问题具体分析,就不展开讲了,典型的比如
- 减少重复计算,大部分需要根据业务场景来设计,要评估某项举措是否只影响了极小的情况,如果是,那么才能继续推动
- 或者控制一些参数,比如beam-search的大小等等
- 分batch前先排序
CPU瓶颈
CPU的瓶颈也相对简单,直接htop
看CPU利用率一直很高就行。
CPU的瓶颈原因就太多了,但是如果是预期内的慢操作,我们也不会太过惊讶,所以,我们这边列举一些常见的【将低复杂度操作写成高复杂度操作】的例子:
- 在list中执行in操作判断:in操作在list中是O(n)复杂度,在set中是O(1)复杂度(对于平衡树的是O(log n))
IO瓶颈
有的时候我没看GPU和CPU利用率其实不高,这时候就要考虑IO瓶颈。
IO瓶颈我认为是三种瓶颈里最不容易察觉的,坑太多,一不小心就掉进去了
这里给一些典型的场景:
- 反复申请内存:比如有一次我做一个jaccard similarity的举措,结果反复申请了一个矩阵,导致某个相似性矩阵反复创建
- 使用Triton的时候没有使用grpc方式而是使用了http方式:50%的性能损失
- 反复垃圾回收:比如在python里超大for循环,如果有tqdm,有的时候可能会发现【进度一卡一卡的】,这时候有可能是反复在进行垃圾回收
- GPU和CPU的反复数据交换:典型的比如在一个GPU向量上进行循环
- 赋值操作(可以用默认值代替):这个不太常见,比如,我要给一个矩阵的某些位置赋值,那么,如果要赋的值已经等于矩阵的默认值(典型的是0),那么跳过这个赋值,就可以起到一定的优化作用
- 多进程时反复创建大对象:比如用ProcessPoolExecutor.map的时候,传入的function里会创建一个大的对象
- 数据库一条条请求:能用batch就用batch吧(或者叫bulk、chunk,各位看官搜索的时候都可以试试)
场景三:docker build太慢了
三个思路:
- 直接先搞一个基础镜像,把比较慢的包或者其他步骤搞一下,然后基于这个基础镜像构建新的镜像
- 在上一个version的基础上进行构建,比如这一次我们执行
docker build -t tmp .
,那么下一次Dockerfile里可以写:FROM tmp:latest
- 如果只是代码修改,不是环境的修改,可以直接通过挂载目录的形式进行代码更新,不需要重新打镜像
场景二:镜像build失败了(卡住了)
一些思考如下:
- 最重要的是——看错误提示
- 基础镜像能不能访问:有的时候为了加速我们会使用之前创建好的本地镜像,看一下这次是否是本地镜像然而却没有创建
- apt-get install等是否需要互动:这个会造成卡住,比如需要输入y才能继续,这时候可以查一下怎么跳过,比如apt-get的话可以加
-y
- 避免直接对着docker build进行debug:可以进入基础镜像内进行操作——先通过docker run进入基础镜像,对照着Dockerfile一步一步执行,看一下是哪一步出了错,解决后,继续进行下一步,直到整个Dockerfile的步骤直行通过,再进行整体的docker build。这样做可以防止浪费时间在已经搞定的步骤上(知道docker build有缓存,但是有的时候不靠谱啊)
场景四:单步调试
一些思考如下:
-
如果想要进入for循环里比较特殊的一个部分,可以通过加if然后输出的形式来做,具体而言,如果我想在
for idx in range(100)
这个循环里在idx=50时暂停,我需要的是1 2 3 4
for idx in range(100): if idx == 100: print("stop here") other_codes
然后在第三行打断点
-
如果程序启动太慢,而又不确定问题在哪儿,要反复单步调试,这时候其实可以先初始化好(例如加载机器学习模型或者读取大文件),然后写一个死循环比如
while 1
来反复调用我们的功能性函数,过了断点位置只需要等待下一次循环重新进入即可,不需要重新启动程序