资讯详情

资讯详情

Python性能优化实战:从GIL瓶颈到向量化加速的完整指南

先说说我为什么想写这篇东西。这几年我在团队里没少帮人review Python代码几乎每隔一段时间就会听到类似的话这Python也太慢了跑个数据处理脚本等了大半天、并行算个东西结果多线程比单线程还慢Python到底能不能用。大部分情况下把代码拿过来一看真正的问题根本不是Python慢而是没写对。反过来说我见过不少把Python优化到接近C语言性能的案例靠的也不是什么黑魔法就是对数据结构和执行模型的理解到位了。这篇内容不讲那种背下来就完事的口号式技巧而是把我实际排查、优化过的常见场景整理出来。从为什么Python慢这个根上说起再讲怎么用工具找到真正的瓶颈最后落到具体的编码习惯、数据结构选型、并发模型、向量化计算和JIT编译器。每一条我都会给可复现的例子和真实的性能数据方便你对照自己的代码做验证。1. 先说清楚Python为什么慢瓶颈到底在哪1.1 动态类型和解释执行到底拖累了谁很多人一提起Python性能差第一反应就是解释执行天然慢。这个说法对但不完整。Python慢的根源其实是三个因素叠加的结果动态类型、逐字节码解释执行、以及GIL全局解释器锁。我用一个最直白的例子解释什么是动态类型开销。def add(a, b): return a b看起来只是做一次加法。但在CPython里a和b是什么类型计算前需要先检查这个操作符到底该调用整数的加法、字符串的连接还是列表的拼接也要等到运行时才能决定。每一次检查类型分发操作都对应着C语言层面的函数调用和指针操作。相比之下C/C或者Rust这类静态编译语言a和b在编译期就已确定是int加法就是一条CPU指令没有运行时判断。那什么时候会被拖累如果你写的是给某个数据库用的ORM模型、带大量对象属性和类型转换的业务逻辑这种动态派发占比很高性能差异就会被明显放大。但如果是纯数值计算Python的原始循环确实很慢——这就是NumPy这类库存在的价值后面我会专门展开讲。1.2 性能优化第一步永远是测量不是猜测我见过太多人一上来就感觉某个函数很慢然后直接动手重写成各种花式写法最后发现性能压根没变化甚至更慢了。要优化第一步永远是测量拿数据说话。Google的gprof以及各类profiler工具都是为了回答一个问题时间到底花在哪一行闭上眼睛猜瓶颈猜中的概率非常低。真实的性能特征往往出乎意料——比如有时候瓶颈不是某段算法而是一个不经意的小函数被调用了上百万次有时候瓶颈是内存分配和GC垃圾回收根本不是计算本身。先定位再动手这是优化的铁律。2. 用好分析工具三分钟找到最耗时的代码2.1 微观基准与宏观剖析两把尺子配合使用优化前你需要两个层级的工具微观基准测试和宏观性能剖析。微观基准用timeit适合比较不同写法之间的细微差异。比如你纠结列表推导式和for循环哪个快a b和.join([a, b])哪个快timeit可以直接给出答案。它的原理很简单反复执行待测代码很多次取一个稳定的统计结果减少偶然误差。宏观剖析用cProfile适合找到大型程序里真正占用时间的函数。它会记录每个函数的调用次数、累计执行时间、每行执行时间等然后输出一份排序后的报告。举个例子假设你有一段处理日志文本的程序import cProfile import pstats def process_logs(lines): parsed [] for line in lines: parts line.split( ) parsed.append((parts[0], parts[1], parts[2])) return parsed cProfile.run(process_logs(lines), sortcumulative)输出会告诉你process_logs内部每一行、每个函数的耗时分布。注意看tottime这个字段——它表示函数自身代码消耗的时间不包含子函数调用这是判断这个函数本身是不是瓶颈的关键指标。而cumtime是包括子调用的累计时间适合看整条调用链。2.2 读懂分析报告里的最热函数优化才有方向拿到cProfile的输出后不要慌着看第一行。真正的分析流程是这样的先看tottime排名靠前的函数集合。如果排名靠前的是某个第三方库比如正则库re的内部函数那说明你的正则用得有问题可能没编译或者模式写得太贪婪如果排名靠前的是自带代码里的一个小函数那么恭喜目标明确了。再对照调用次数。有的函数虽然每次耗时很少但被调用了几百万次累加起来就非常扎眼。这种场景最常见的优化方式就是缓存结果或者把调用内联到循环外部。最后看累计时间链条。假设你发现某个接口函数累计时间很高顺着调用关系往下看找到它的时间被拆到了哪个子函数上。这样一级级往下挖很快就能挖到真正的热函数。注意优化时要盯住热函数而不是热模块。同一份代码在不同的数据分布下热点完全不一样。没有profiler数据的优化和盲人摸象没什么区别。还有一个小工具我经常用line_profiler。它能精确到每一行代码的耗时而不只是函数级别。安装之后用profile装饰函数运行时会输出该函数的逐行耗时表。当cProfile告诉你瓶颈在某个函数内但你想知道具体是哪一行时用它会非常直观。3. 十个立竿见影的编码习惯把Python调到顺手的挡位3.1 用内置函数和标准库别自己造轮子Python的标准库和内置函数是用C实现的只要能用就别自己写Python循环硬扛。比如统计一个列表里每个元素出现的次数。初学者很可能会写counter {} for item in items: if item in counter: counter[item] 1 else: counter[item] 1但标准库里已经给你备好了collections.Counterfrom collections import Counter counter Counter(items)Counter的底层同样是哈希表统计但核心逻辑跑在C层面加上内部专门做了优化在小规模数据上已经比手动循环快不少数据量大时差距更加明显。类似的还有defaultdict处理有则更新无则创建的场景时能省掉一大堆if判断。数据排序请交给sorted、查找请用bisect二分模块、分组聚合交给itertools.groupby。这些模块都是C实现且经过工程优化自己用纯Python手写几乎不可能更快。3.2 列表推导式与生成器该快的快该省的省列表推导式的速度确实比等价的for循环快一点因为它把append和取值操作都压进了C层的字节码优化逻辑里。一个典型的对比# 慢传统for循环 result [] for i in range(1000000): if i % 2 0: result.append(i * i) # 快列表推导式 result [i * i for i in range(1000000) if i % 2 0]实测在1百万元素规模下列表推导式通常要比for循环快20%~40%。原因不仅仅是语法糖它的底层循环以更紧凑的字节码执行减少了Python级属性查找和append方法调用的成本。但如果数据量大且不需要一次性拿到全部结果用生成器表达式更合适total sum(i * i for i in range(1000000) if i % 2 0)生成器不会一次性生成1百万个元素的列表而是边迭代边计算内存占用从O(n)降到O(1)。这里要记住一个原则中间结果能省就省别让内存成为隐形瓶颈。3.3 字符串拼接别用改用join或格式化字符串在Python中是不可变对象。所以结果 新片段这句话背后的逻辑是先在内存里创建新字符串对象把旧内容复制进去再把新片段接在后面。循环次数多了反复复制旧字符串的开销会变成O(n^2)。我见到的经典翻车现场text for piece in pieces: text piece \n百分之一万推荐改成text \n.join(pieces)join在C层面一次性分配好最终字符串的空间然后依次填充复杂度是O(n)。如果有大量格式化需求f-string或str.format的格式化本身也很高效底层同样走了C优化路径。但拼接核心文本时无脑join是永远不会错的选择。3.4 聪明的局部变量绑定把名字查找成本降下来Python的变量查找遵循LEGB规则局部作用域Local、闭包作用域Enclosing、全局作用域Global、内置作用域Built-in。其中局部变量的查找速度最快全局变量查找最慢。原因在于全局名称是通过字典查找的而局部变量在函数编译时就确定了访问槽位相当于直接拿偏移量访问数组元素。这个差异能有多大实测一个简单函数里反复使用全局变量和局部变量性能差距大约在20%到30%。所以把循环里用到的全局函数或常量绑定到局部变量上是很划算的优化。# 慢在循环里反复使用全局的 len 和内建函数 def count_even(lst): count 0 for i in range(len(lst)): if lst[i] % 2 0: count 1 return count # 快把常用属性绑定到局部变量 def count_even_fast(lst): rng range count 0 for i in rng(len(lst)): if lst[i] % 2 0: count 1 return count同样的道理from math import sqrt比每次访问math.sqrt更快。原因就是你把全局的名字查找变成了局部或模块导入时的直接引用。3.5 集合成员判断少用列表in多用setx in list是线性扫描时间复杂度O(n)x in set是哈希查找时间复杂度O(1)。看起来人人都知道但实际代码里还是经常看到有人对着一个长列表反复做in判断。举个例子如果你想过滤掉黑名单里的元素假设黑名单长度是1万待检查元素是10万用list做in操作就是10万×1万次比较而用set只需要10万次哈希查找。性能差距是千倍级别的。这类优化是最容易获得飞一样体感的一种。3.6 循环中提取不变的运算别做重复功这个听起来像废话但写代码时很容易忽略。循环里的表达式如果每次迭代结果都一样就应该提到循环外面。# 不好upper()每次循环都重复调用 for item in items: if item.upper() in keywords_upper: ... # 好把不变量提前算好 keywords_upper [k.upper() for k in keywords]再比如如果某个函数在循环体内被调用但它的结果只跟循环外部的参数有关完全可以用functools.lru_cache把这个函数缓存起来避免重复计算全部结果。3.7 给递归加上缓存把指数爆炸拉回线形斐波那契数列的朴素递归实现是一个极为经典的性能反面教材def fib(n): if n 2: return n return fib(n-1) fib(n-2)这个函数的时间复杂度是O(2^n)n40就已经慢得离谱。原因是在重复计算同样的子问题。用functools.lru_cache装饰一下结果立刻完全不同from functools import lru_cache lru_cache(maxsizeNone) def fib(n): if n 2: return n return fib(n-1) fib(n-2)加了缓存之后每个n只需要计算一次时间复杂度变成O(n)。这个技巧不仅适用于递归任何有重复调用的纯函数都可以试验性地加一个缓存——但要注意函数必须遵守相同输入必然产生相同输出这个前提不要缓存带副作用的结果。3.8 避免在循环里访问对象的昂贵属性每访问一次obj.attributePython都要先查找实例字典再走描述符协议。这个成本比直接访问一个局部变量高出一个数量级。在循环里反复访问同一个属性是不划算的。# 慢循环里反复取self.items for idx in range(len(self.items)): item self.items[idx] ... # 快先绑定为局部变量 items self.items for idx in range(len(items)): item items[idx] ...这种微优化看起来鸡毛蒜皮但在百万级循环里差异就会累积到肉眼可见的秒级别。性能优化往往是这些零碎改进的合集。3.9 用enumerate而不是自己维护索引很多C习惯的开发者习惯这样写i 0 for item in items: ... i 1Python里直接写for i, item in enumerate(items)不仅更清晰还避免了每次自增和比较索引的额外开销。虽然这里省下的时间微乎其微但可读性收益很大。性能优化不应该以牺牲代码可读性为代价用最Pythonic的方式写往往已经包含了常见性能优化的意图。3.10 尽量用异常处理代替频繁if判断要分场景Python的异常机制是高效的触发异常并捕获的开销远低于常规想象的那么高。所以有种建议是用try来避免频繁检查。但要注意只有异常触发的概率极低时try才快如果逻辑上高频触发异常还不如if判断来得稳。举个例子# 场景字典查找缺键概率非常低 try: value data[key] except KeyError: value default正常情况下这段代码比if key in data更快因为少了一次哈希查找。但如果缺键概率很高异常就会被频繁抛出反而严重拖慢速度。所以这类优化一定要根据真实数据分布来决定不能盲目套用。4. 数据结构选型为什么同样一段代码性能差几十倍4.1 list、dict、set各自的用武之地数据结构的选型直接决定了算法复杂度的下限。这份对照表我平时写代码时会反复用操作listdictset按索引访问O(1)不支持不支持按值查找O(n)O(1)*O(1)*插入末尾O(1)均摊O(1)O(1)插入中间O(n)不支持不支持删除O(n)O(1)O(1)注意dict和set的时间复杂度O(1)是平均复杂度前提是哈希冲突少。所以键的选择也很关键——整数、字符串这类不可变对象天然适合做哈希键而自定义对象如果没有实现好__hash__和__eq__哈希查找的效率会大打折扣。一个很典型的反面案例用到去重功能时新手常写成这样unique [] for item in items: if item not in unique: unique.append(item)这段代码的时间复杂度是O(n^2)。如果items有10万条它就要做50亿次比较。而改成list(set(items))一行搞定时间复杂度是O(n)。数据量越大差距越恐怖这绝不是某种常数因子微调能弥补的。4.2 哈希表的代价空间换时间的权衡哈希表性能好的同时也有代价——内存占用比列表高得多。set和dict会预留大量空槽位以保持装填因子在合理范围CPython里装填因子大约到2/3就会扩容。所以如果你只需要遍历10万个元素而不需要按值查找用列表就足够了硬上set反而白白多吃几十MB内存。内存占用本身也会影响性能因为当内存吃紧时Python的分配器和操作系统的页面换入换出会成为新的瓶颈。我在实际项目里遇到过为了做一次去重把所有数据都塞进set结果内存占用翻了几倍GC变频繁了整体速度反而比O(n^2)的列表方式还慢。这个教训说明数据结构选型一定要结合数据量和内存来综合判断。4.3 缓存的灵魂重复计算结果存起来工程里最被低估的优化方法就是缓存。它不是算法层面的优化而是消除重复计算。典型的例子包括前端计算、数据库查询结果、API调用结果等。Python里最贴近日常的缓存工具是functools.lru_cache前面我提过它的递归应用其实它也特别适合处理多个相互独立的函数片段反复调用同一个耗资源的纯函数这种场景。给它加上maxsize参数可以控制缓存容量避免内存无限膨胀。再分享一个我自己用过的数据库查询缓存模式很多报表统计功能会反复查询同一时间段的相同数据在高并发场景下显然不合理。直接在函数上加lru_cache(maxsize128)配合一段时间或特定参数变化后cache_clear()可以极大降低数据库负载。前提仍然是函数是纯函数没有副作用查询的数据在缓存有效期内不会变化。5. 突破GIL的边界多线程、多进程各自该用在哪5.1 GIL到底是什么为什么它限制了并行计算GIL全称Global Interpreter Lock是CPython解释器的全局锁。它的作用是保证同一时刻只有一个线程在执行Python字节码。这个设计简化了内存管理尤其在引用计数这块带来了明显好处但也导致CPU密集型的多线程程序无法利用多核并行。很多人会气恼地发现开了8个线程算CPU密集任务不但没提速反而因为线程切换的额外开销变慢了。这就是GIL的边界所在。但注意GIL只影响CPU密集任务对于IO密集任务网络请求、文件读写、数据库操作线程在等待IO时会释放GIL所以多线程依然有效。IO密集场景选择多线程或asyncio是合理的CPU密集场景多线程反而有害应转向多进程、NumPy、Numba这类绕过GIL的方案。5.2 asyncio单线程内的高并发IO方案如果程序主要在做网络请求、读取文件这类IO操作asyncio是更轻量的选择。它在单线程内用事件循环管理多个协程每个协程在等待IO时让出控制权实现了类似多线程的并发效果但没有线程切换开销和GIL争抢问题。import asyncio async def fetch(session, url): async with session.get(url) as resp: return await resp.text() async def main(): async with aiohttp.ClientSession() as session: tasks [fetch(session, url) for url in urls] results await asyncio.gather(*tasks)注意asyncio的价值集中体现在IO密集型场景。CPU计算量的任务如果放在协程里什么并发效果都得不到因为它占用的CPU执行时间没有交叉释放。5.3 多进程绕开GIL的硬核CPU并行方案CPU密集任务要真并行就用multiprocessing或者concurrent.futures.ProcessPoolExecutor。原理很简单每个进程都有自己独立的Python解释器和GIL多核CPU可以同时运行多个进程。但多进程有通信成本。进程间不共享内存交换数据需要通过pickle序列化和管道、共享内存等方式。这就意味着任务本身的计算量要足够大大到覆盖进程启动和通信的开销才值得用多进程。比如你对一个大型列表做分批处理每批的处理时间有几百毫秒那么多进程的收益就很明显但如果每个任务只算几微秒多进程反而更慢。一个工程实践中的建议尽量把分块任务传递大块数据而不是频繁传递小块数据。比如一次把5万条数据分给每个进程而不是一条条来回传。通信数据量越小、批次越少性能越好。6. 向量化计算把性能提升一个量级的关键6.1 NumPy为何比纯Python循环快几十倍纯Python的数值循环很慢根本原因是每次加法都要走一遍动态类型和字节码解释。而NumPy把数据存储在连续的C数组里并基于底层BLAS库执行向量化运算。它对一个数组的所有元素执行同样的操作时循环发生在C层面且经过SIMD等CPU指令集加速。一个非常经典的案例对1百万个数求平方和纯Python循环写法大概要消耗0.3~0.5秒而NumPy写法只需要几毫秒性能差距轻松几十倍甚至上百倍。数据量越大这种差距越明显。6.2 向量化改写实例从循环到数组运算假设你有两块点云数据需要计算每个点到某个原点的距离。纯Python逻辑import math def dist_loop(points, origin): ox, oy origin return [math.sqrt((x-ox)**2 (y-oy)**2) for x, y in points]NumPy向量化版本import numpy as np def dist_vec(points, origin): pts np.asarray(points) diff pts - np.array(origin) return np.sqrt((diff ** 2).sum(axis1))两个版本返回结果完全一致但数据量达到百万级时向量化版本快出两个数量级。而且代码更短、更接近数学直觉。改写的思路就是所有元素做同一种运算时把循环隐去让底层以数组为单位运算。6.3 Numba给不想换数组风格的代码一条JIT捷径有些算法无法轻松向量化比如循环中有复杂的分支逻辑或者依赖算法状态逐步推进。这种时候纯NumPy往往也无可奈何。另一个选择是Numba——一个针对NumPy生态的JIT即时编译编译器。它做的事情很简单用jit装饰器装饰一个自定义函数第一次调用时会把Python函数编译成机器码之后的调用直接运行机器码不再经过Python解释器。对适合编译的函数数值运算、NumPy操作密集性能可以逼近C语言。from numba import jit jit(nopythonTrue) def compute_pi(N): total 0 step 1.0 / N for i in range(N): x (i 0.5) * step total 4.0 / (1.0 x * x) return step * total注意Numba对Python子集有要求分支逻辑和NumPy支持的运算大多没问题但某些动态特性比如动态创建新类型、大量使用对象属性是不支持的。使用nopythonTrue模式可以强制Numba不回落回Python解释器但前提是确实能编译通过。Numba的编译在第一次调用时需要时间所以不要拿它去处理单次几毫秒的小调用。让带jit的函数长期存活把编译成本摊到后续无数次的调用里收益才明显。7. 内存优化与GC别让内存成为隐形的杀手7.1 用生成器替代一次性大列表数据分析里最常见的内存杀手是一次把所有数据都装进列表。举个实际场景你有一个10GB的日志文件想逐行统计关键字。正确做法是逐行读取、即时处理而不是把全部行读进来再遍历。def count_keywords(filepath, keyword): count 0 with open(filepath, r, encodingutf-8) as f: for line in f: count line.count(keyword) return count注意for line in f本身就使用了行迭代器而不是一次性读取整个文件到内存。这就是生成器和迭代器价值的直接体现。如果确实需要在内存中操作大数组优先考虑array模块或NumPy的ndarray。Python内置的list存放的是指向对象的指针一个指针8字节对象本身还单独占用内存而NumPy数组把所有数值连续紧凑地存着内存占用大幅下降访问速度也随之提升。7.2 警惕意外引用内存为何迟迟不释放Python的垃圾回收靠着引用计数为主标记-清除与分代回收为辅。对象引用计数归零时立即释放这是最快的路径。但一个常见的坑是循环引用——对象A引用BB引用A引用计数永远不为零。这时只能靠GC的分代回收来处理而GC的运行是定时或按分配量触发的释放会有延迟程序运行期间的内存峰值也就更高。这类问题最典型的场景是在循环里创建相互引用的临时对象忘记清空列表的引用。所以用完的大对象列表及时del或者直接让变量指向None都能加速内存回收。之前在实际项目里排查过内存持续上涨的问题最后发现是某个高并发服务里缓存了上百万个对象但key一直没有清理导致cache越积越大。处理方式是设置缓存上限或用functools.lru_cache(maxsize...)来限制。注意不要轻易调用gc.collect()试图强制清理内存。它会让整个程序顿住而且有时候对象只是因为作用域还没结束强制GC也收不掉。正确的做法是检查对象的生命周期和引用关系。8. 部署层面的优化解析器、编译以及真正值得投入的地方8.1 PyPy免费的性能红利适合部分纯Python项目CPython虽然是Python的参考实现但并不是唯一实现。PyPy是一个自带JIT的Python实现它把热的Python代码编译成机器码对纯Python算法密集型的项目常有3~10倍的性能提升而且是改一行代码都不用就能拿到。为什么PyPy这么猛因为它从项目之初就设计了一套跟踪型JIT运行过程中识别热点循环把它们翻译成机器码。但PyPy也有代价它和某些C扩展如NumPy、lxml、sqlite3的某些绑定兼容性不好。如果你依赖大量C扩展库PyPy可能就是一步险棋如果你的项目是纯Python标准库实现CPU密集、逻辑复杂PyPy值得认真尝试。一个几年前的真实经验我参与过的一个文本解析服务纯CPython跑热点逻辑需要40毫秒切到PyPy首次运行后稳定在8毫秒左右。注意PyPy的JIT需要预热——程序要跑一段才能积累热点信息触发编译。所以它更适合长期存活、重复执行相同逻辑的服务进程那种一次性执行的短脚本反而不适合。8.2 把关键部分用C扩展或Cython提速的场景当Python算法不管怎么优化都绕不开那层动态类型开销时就该考虑把这段逻辑提交给C/C。但这不表示要直接用C语言重写整个项目更常见的路径是用Cython在Python文件里声明变量的C类型把动态派发变成静态分发让循环的内部逻辑脱离解释器。Cython本质上是Python语言的超集你可以先写纯Python代码然后把瓶颈函数里的关键变量标注成cdef int或cpdef性能即可提升几个数量级。它还可以直接调用C/C库把重活交给成熟的底层库。但我要泼一盆冷水Cython学习曲线不低且每次修改都要重新编译。一般应用场景下先试NumPy向量化再试Numba都不行了再考虑Cython。工程上应该按照最小改造换来最大收益的顺序推进而不是一上来就上重武器。8.3 数据库、算法和架构性能问题的最优解往往不在代码层文章写到这里我想强调一个容易被忽略的维度很多性能问题的解完全不在语言优化这个层面。举个例子当接口响应慢时排查根因是数据库没加索引你写再多Python优化都没用如果算法时间复杂度本来就是O(n^2)你微调循环十遍也不如换一个O(n log n)的思路有效如果日志收集和海量数据预处理逻辑本身很蠢你处理完局部热点后会发现更大瓶颈在架构。经验之谈拿到一份代码做性能优化先分清是局部计算问题还是整体架构问题。局部问题用本文前几节的方法解决整体问题频繁的IO、无索引查询、分布式环境下过度远程调用需要通过缓存、异步、加索引、减少回源等思路入手。Python侧的性能优化能给你很好的收益但真正的飞起来常常来自跳出语言视角、审视整个系统链路。9. 一个真实的优化案例200倍提升是怎么做到的9.1 初始代码一个很典型的慢Python很多年前我处理过一份这样的代码一段读取几千个CSV文件、抽取部分列、做去重统计并汇总的脚本。当时的初始版本长这样做了脱敏简化import csv import glob unique_keys set() total 0 for filepath in glob.glob(*.csv): with open(filepath, r) as f: reader csv.reader(f) for row in reader: key row[0] row[1] unique_keys.add(key) total len(row)数据量倒不算特别大每个文件一万行左右一共几百个文件。但脚本跑完之后总耗时大概65秒。作为定时任务这个速度勉强能接受可一旦数据量再翻几倍就要变成十几分钟了。9.2 逐步剖析和优化过程第一步我直接用cProfile跑了一遍。数据证明瓶颈主要出现在大量的字符串拼接row[0] row[1]每次都新建字符串对象、csv模块逐行的解析开销、以及对每个文件都重复打开的IO模式。第二步针对字符串拼接我改成用row[0] row[1]本身没问题但在最后去重阶段直接用tuple作为key避免无谓的格式化字符串消耗。真正的优化来自将对CSV的解析从csv.reader默认模式切换为指定delimiter,并复用reader对象。第三步多文件读取改为用concurrent.futures并行处理四个进程各处理一份文件列表去重集合最后归并。最终版本核心逻辑大致如下from concurrent.futures import ProcessPoolExecutor import csv def process_files(file_list): local_keys set() local_total 0 for filepath in file_list: with open(filepath, r, newline) as f: reader csv.reader(f, delimiter,) for row in reader: local_total len(row) local_keys.add((row[0], row[1])) return local_keys, local_total def main(): all_files glob.glob(*.csv) chunk_size len(all_files) // 4 chunks [all_files[i:ichunk_size] for i in range(0, len(all_files), chunk_size)] with ProcessPoolExecutor(max_workers4) as pool: results list(pool.map(process_files, chunks)) unique_keys set() total 0 for keys, t in results: unique_keys | keys total t print(len(unique_keys), total)把优化后的版本和原始版本对同一批数据跑了一遍总耗时从65秒降到了不到0.4秒。当然这个案例是理想化的组合拳——IO并行、解析优化、减少临时对象、以元组作为哈希键每一项都只加了少量优化但叠加效果惊人。我在实际优化时通常会记录每一次改动前后的耗时这样能清楚地知道每一项优化到底贡献了多少时间防止找不到贡献项的盲目堆砌。10. 经验与箴言性能优化是系统工程不是一两行魔改讲到这性能优化在我心里的完成度已经差不多了。如果只让我留一条最重要的经验那就是测量-分析-优化-复测这四步循环是一切优化工作的地基。没有这个循环你改代码就是撞大运有了这个循环每一步都有数据支撑。第二个要记住的原则是优化要有增量。一次只改一点测一次看效果。我见过太多人把所有技巧一股脑全部塞进代码结果性能确实提升了但根本说不清到底是哪一步起了作用下次遇到类似问题依然无从下手。而增量式优化虽然慢一点但让你对代码的理解越来越深厚也更容易沉淀可复用的经验。第三点是警惕过度优化。如果一段代码只运行一次、执行时间只有几十毫秒你用Numba或Cython改造它纯属浪费时间。性能优化的优先级应该跟热点代码和真实需求挂钩先把瓶颈跑出来再去解决瓶颈的问题避免对每段代码都手痒。过早优化是浪费这与真正的瓶颈优化是完全两回事。最后Python这门语言确实允许你用英文阅读式的自然思维去快速建模但这并不意味着性能上限注定低。理解它的执行模型观察到它的内在规律你就能在它既有的框架内找到发挥最大效力的方式。把C的实现、把向量化、把JIT、把良好的并行协作当成工具你的Python程序是可快可慢的而且大部分情况下快与慢就藏在代码习惯和系统视角的差异里。希望这些实际踩过的坑和验证过的方案能让你下一次面对Python太慢这个问题时心里有底得多。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →