pypy 真的能讓 Python 比 c 還快么?

最近 “pypy為什么能讓python比c還快” 刷屏了,原文講的內(nèi)容偏理論,干貨比較少。我們可以再深入一點點,了解pypy的真相。
正式開始之前,多嘮叨兩句。我司發(fā)力多個賽道的游戲,其中包括某魚類游戲Top2項目,拿過阿拉丁神燈獎的SLG卡牌小游戲項目和海外三消游戲。這些不同類型的游戲,后端大多是使用的是pypy。對于如何使用pypy,我有一點使用經(jīng)驗可以聊聊。話不多說,正式開始,本文包括下面幾個部分:
語言分類 python的解釋器實現(xiàn) pypy為什么快 性能比較 性能優(yōu)化方法 pypy的特性 小結(jié)
語言分類
我們先從最基本的一些語言分類概念聊起,對這部分內(nèi)容非常了解的朋友可以跳過。
靜態(tài)語言 vs 動態(tài)語言
如果在編譯時知道變量的類型,則該語言為靜態(tài)類型。靜態(tài)類型語言的常見示例包括Java,C,C ++,F(xiàn)ORTRAN,Pascal和Scala。在靜態(tài)類型語言中,一旦使用類型聲明了變量,就無法將其分配給其他不同類型的變量,這樣做會在編譯時引發(fā)類型錯誤。
# java
int data;
data = 50;
data = “Hello Game_404!”; // causes an compilation error
如果在運行時檢查變量的類型,則語言是動態(tài)類型的。動態(tài)類型語言的常見示例包括JavaScript,Objective-C,PHP,Python,Ruby,Lisp和Tcl。在動態(tài)類型語言中,變量在運行時通過賦值語句綁定到對象,并且可以在程序執(zhí)行期間將相同的變量綁定到不同類型的對象。
# python
data = 10;
data = "Hello Game_404!"; // no error caused
data = data + str(10)
一般來說靜態(tài)語言編譯成字節(jié)碼執(zhí)行,動態(tài)語言使用解釋器執(zhí)行。編譯型語言性能更高,但是較難移植到不同的CPU架構(gòu)體系和操作系統(tǒng)。解釋型語言易于移植,性能會比編譯語言要差得多。這是頻譜的兩個極端。
強類型語言 vs 弱類型語言
強類型語言是一種變量被綁定到特定數(shù)據(jù)類型的語言,如果類型與表達式中的預期不一致,將導致類型錯誤,比如下面這個:
# python
temp = “Hello Game_404!”
temp = temp + 10; // program terminates with below stated error (TypeError: must be str, not int)
python和我們感覺不一致,背叛了弱類型語言,不像世界上最好的語言:(
# php
$temp = “Hello Game_404!”;
$temp = $temp + 10; // no error caused
echo $temp;
常見編程語言的象限分類如下圖:

這一部分內(nèi)容主要翻譯自參考鏈接1
python的解釋器實現(xiàn)
python是一門動態(tài)編程語言,由特定的解釋器解釋執(zhí)行。下面是一些解釋器實現(xiàn):
CPython 使用c語言實現(xiàn)的解釋器 PyPy 使用python語言的子集RPython實現(xiàn)的解釋器,一般情況下PyPy比CPython快4.2倍 Stackless Python 帶有協(xié)程實現(xiàn)的解釋器 Jython Java實現(xiàn)的解釋器 IronPython .net實現(xiàn)的解釋器 Pyston 一個較新的實現(xiàn),是CPython 3.8.8的一個分支,具有其他針對性能的優(yōu)化。它針對大型現(xiàn)實應用程序(例如Web服務),無需進行開發(fā)工作即可提供高達30%的加速。 ...
還有幾個相關(guān)概念:
IPython && Jupyter ipython是使用python構(gòu)建的交互式shell, Jupyter是其web化的包裝。 Anaconda 是一個python虛擬環(huán)境,Python數(shù)據(jù)科學常用。 mypyc 一個新的項目,將python編譯成c代碼庫,以期提高python的運行效率。 py文件和pyc文件 pyc文件是python編譯后的字節(jié)碼,也可以由python解釋器執(zhí)行。 wheel文件和egg文件 都是項目版本發(fā)布的打包文件,wheel是最新標準。 ...
這里大家會有一個疑問,python不是解釋型語言嘛?怎么又有編譯后的pyc。是這樣的: py文件編譯成pyc后,解釋器默認 優(yōu)先 執(zhí)行pyc文件,這樣可以加快python程序的 啟動速度 (注意是啟動速度)。繼背叛弱類型語言后,python這個鬼又在編譯語言和解釋語言之間橫跳。
還有一個事件是Go語言在1.5版本實現(xiàn)自舉。Go語言在1.5版本之前使用c實現(xiàn)的編譯器,在1.5版本時候使用Go實現(xiàn)了自己的編譯器,這里有一個雞生蛋和蛋生雞的過程,也挺有意思。
pypy為什么快
pypy使用python的子集rpython實現(xiàn)了解釋器,和前面介紹的Go的自舉有點類似。反常識的是rpython的解釋器會比c實現(xiàn)的解釋器快?主要是因為pypy使用了JIT技術(shù)。
Just-In-Time (JIT) Compiler 試圖通過對機器碼進行一些實際的編譯和一些解釋來獲得兩全其美的方法。簡而言之,以下是JIT編譯為提高性能而采取的步驟:
標識代碼中最常用的組件,例如循環(huán)中的函數(shù)。 在運行時將這些零件轉(zhuǎn)換為機器碼。 優(yōu)化生成的機器碼。 用優(yōu)化的機器碼版本交換以前的實現(xiàn)。
這也是 “pypy為什么能讓python比c還快” 一文中的示例展現(xiàn)出來的能力。pypy除了速度快外,還有下面一些特點:
內(nèi)存使用情況比cpython少 gc策略更優(yōu)化 Stackless 協(xié)程模式默認支持,支持高并發(fā) 兼容性好,高度兼容cpython實現(xiàn),基本可以無縫切換
以上都是宣稱
pypy這么強,快和省都占了,為什么沒有大規(guī)模流行起來呢? 我個人認為,主要還是python的原因。
python生態(tài)中大量庫采用c實現(xiàn),特別是科學計算/AI相關(guān)的庫,pypy在這塊并沒有優(yōu)勢。pypy快的主要在pure-python,也就是純粹的python實現(xiàn)部分。 pypy適合長駐內(nèi)存的高并發(fā)應用(web服務類) python是一門膠水語言,并不追求性能極致,即使快4倍也不夠快:( ??。肯定比不上c,原文中的c應該是 偷換了概念 ,指c實現(xiàn)的cpython解釋器。
需要注意的是,pypy一樣也有GIL的存在, 所以高并發(fā)主要在stackless。
這一部分內(nèi)容參考自參考鏈接2
性能比較
我們可以編寫性能測試用例,用代碼說話,對各個實現(xiàn)進行對比。本文的測試用例并不嚴謹,不過也足夠說明一些問題了。
開車和步行
原文中累加測試用例是100000000次,我們減少成1000次:
import time
start = time.time()
number = 0
for i in range(1000):
number += i
print(number)
print(f"Elapsed time: {time.time() - start} s")
測試結(jié)果如下表(測試環(huán)境在本文附錄部分):
| 解釋器 | 循環(huán)次數(shù) | 耗時(s) |
|---|---|---|
| python3 | 1000 | 0.00014281272888183594 |
| pypy3 | 1000 | 0.00036716461181640625 |
結(jié)果顯示運行1000次循環(huán)的情況下cpython要比pypy快,這和循環(huán)100000000次 相反 。用下面的例子可以非常形象的解釋這一點。
假設您想去一家離您家很近的商店。您可以步行或開車。您的汽車顯然比腳快得多。但是,請考慮需要執(zhí)行以下操作:
去你的車庫。 啟動你的車。 讓汽車暖一點。 開車去商店。 查找停車位。 在返回途中重復該過程。
開車要涉及很多開銷,如果您想去的地方在附近,這并不總是值得的!現(xiàn)在想想如果您想去五十英里外的鄰近城市會發(fā)生什么。開車去那里而不是步行肯定是值得的。
舉例來自參考鏈接2
盡管速度的差異并不像上面類比那么明顯,但是PyPy和CPython的情況也是如此。
橫向?qū)Ρ?span style="display: none;">
我們橫向?qū)Ρ纫幌耤,python3, pypy3, js 和lua的性能。
# js
const start = Date.now();
let number = 0
for (i=0;i<100000000;i++){
number += i
}
console.log(number)
const millis = Date.now() - start;
console.log(`milliseconds elapsed = `, millis);
# lua
local starttime = os.clock();
local number = 0
local total = 100000000-1
for i=total,1,-1 do
number = number+i
end
print(number)
local endtime = os.clock();
print(string.format("elapsed time : %.4f", endtime - starttime));
# c
#include <stdio.h>
#include <time.h>
const long long TOTAL = 100000000;
long long mySum()
{
long long number=0;
long long i;
for( i = 0; i < TOTAL; i++ )
{
number += i;
}
return number;
}
int main(void)
{
// Start measuring time
clock_t start = clock();
printf("%llu \n", mySum());
// Stop measuring time and calculate the elapsed time
clock_t end = clock();
double elapsed = (end - start)/CLOCKS_PER_SEC;
printf("Time measured: %.3f seconds.\n", elapsed);
return 0;
}
| 解釋器 | 循環(huán)次數(shù) | 耗時(s) |
|---|---|---|
| c | 100000000 | 0.000 |
| pypy3 | 100000000 | 0.15746307373046875 |
| js | 100000000 | 0.198 |
| lua | 100000000 | 0.8023 |
| python3 | 100000000 | 10.14592313766479 |
測試結(jié)果可見,c無疑是最快的,秒殺其它語言,這是編譯語言的特點。在解釋語言中,pypy3表現(xiàn)配得上優(yōu)秀二字。
內(nèi)存占用
測試用例中增加內(nèi)存占用的輸出:
p = psutil.Process()
mem = p.memory_info()
print(mem)
測試結(jié)果如下:
# python3
pmem(rss= 9027584, vms=4747534336, pfaults= 2914, pageins=1)
# pypy3
pmem(rss=39518208, vms=5127745536, pfaults=12188, pageins=58)
pypy3的內(nèi)存占用會比python3要高,這個才科學,用內(nèi)存空間換了運行時間。當然這個評測并不嚴謹,實際情況如何,pypy宣稱的內(nèi)存占用較少,我表示懷疑,但是沒有證據(jù)。
性能優(yōu)化方法
了解語言的性能比較后,我們再看看一些性能優(yōu)化的方法,這對在cpython和pypy之間選型有幫助。
使用c函數(shù)
python中使用c函數(shù),比如這里的累加可以使用reduce替換,可以提高效率:
def my_add(a, b):
return a + b
number = reduce(add, range(100000000))
| 解釋器 | 次數(shù) | 耗時(s) |
|---|---|---|
| pypy3 | reduce | 0.08371400833129883 |
| pypy3 | 100000000 | 0.15746307373046875 |
| python3 | reduce | 5.705173015594482 s |
| python3 | 100000000循環(huán) | 10.14592313766479 |
結(jié)果展示,reduce對cpython和pypy都有效。
優(yōu)化循環(huán)
優(yōu)化最關(guān)鍵的地方,提高算法效率,減少循環(huán)。更改一下累加的需求,假設我們是求100000000以內(nèi)的偶數(shù)的和,下面展示了使用range的步進減少循環(huán)次數(shù)來提高性能:
try:
xrange # python2注意使用xrange是迭代器,而range是返回一個list
except NameError: # python3
xrange = range
def test_0():
number = 0
for i in range(100000000):
if i % 2 == 0:
number += i
return number
def test_1():
number = 0
for i in xrange(0, 100000000, 2):
number += i
return number
| 解釋器 | 循環(huán)次數(shù) | 耗時(s) |
|---|---|---|
| python3 | 50000000 | 2.6723649501800537 s |
| python3 | 100000000 | 6.530670881271362 s |
循環(huán)次數(shù)減半后,有效率顯著提升。
靜態(tài)類型
python3可以使用類型注解,提高代碼可讀性。類型確定邏輯上對性能有幫助,每次處理數(shù)據(jù)的時候,不用再進行類型推斷。
number: int = 0
for i in range(100000000):
number += i
| 解釋器 | 循環(huán)次數(shù) | 類型 | 耗時(s) |
|---|---|---|---|
| python3 | 100000000 | int | 9.492593050003052 s |
| python3 | 100000000 | 不定義 | 10.14592313766479 s |
內(nèi)存相當于一個空間,我們要用不同的盒子去填充它。圖中左邊部分1,都使用長度為4(想像float類型)的盒子填充,一行一個,速度最快;圖中中間部分2,使用長度為3(想像long類型)和長度為1(想像int類型)的箱子,一行2個,也挺快;圖中右側(cè)3,雖然箱子長度仍然是3和1,但是由于沒有刻度,填充時候需要試裝,所以速度最慢。

算法的魅力
優(yōu)化到最后,最重量級的內(nèi)容登場:高斯求和算法。高斯的故事,想必大家都不陌生,下面是算法實現(xiàn):
def gaussian_sum(total: int) -> int:
if total & 1 == 0:
return (1 + total) * int(total / 2)
else:
return total * int((total - 1) / 2) + total
# 4999999950000000
number = gaussian_sum(100000000 - 1)
| 解釋器 | 循環(huán)次數(shù) | 耗時(s) |
|---|---|---|
| python3 | 高斯求和 | 4.100799560546875e-05 s |
| python3 | 100000000循環(huán) | 10.14592313766479 |
使用高斯求和后,程序秒開。這大概就是業(yè)內(nèi)面試,要考算法的真相,也是算法的魅力所在。
優(yōu)化的原則
簡單介紹一下優(yōu)化的原則,主要是下面2點:
使用測試而不是推測。
python3 -m timeit 'x=3' 'x%2'
10000000 loops, best of 5: 25.3 nsec per loop
python3 -m timeit 'x=3' 'x&1'
5000000 loops, best of 5: 41.3 nsec per loop
python2 -m timeit 'x=3' 'x&1'
10000000 loops, best of 3: 0.0262 usec per loop
python2 -m timeit 'x=3' 'x%2'
10000000 loops, best of 3: 0.0371 usec per loop
上面示例展示了,求奇偶的情況下,python3中位運算比取模慢,這是個反直覺推測的地方。在我的python冷兵器合集一文中也有介紹。而且需要注意的是,python2和python3表現(xiàn)相反,所以性能優(yōu)化要實測,注意環(huán)境和實效性。
遵循2/8法則, 不要過度優(yōu)化,不用贅述。
pypy的特性
pypy還有下面一些特性:
cffi pypy推薦使用cffi的方式加載c cProfile pypy下使用cProfile檢測性能無效 sys.getsizeof pypy的gc方式差異,sys.getsizeof無法使用 __slots__ cpython使用的slots,在pypy下失效
使用slots在python對象中,可以減少對象內(nèi)存占用,提高效率,下面是測試用例:
def test_0():
class Player(object):
def __init__(self, name, age):
self.name = name
self.age = age
players = []
for i in range(10000):
p = Player(name="p" + str(i), age=i)
players.append(p)
return players
def test_1():
class Player(object):
__slots__ = "name", "age"
def __init__(self, name, age):
self.name = name
self.age = age
players = []
for i in range(10000):
p = Player(name="p" + str(i), age=i)
players.append(p)
return players
測試日志如下:
# python3 slots
pmem(rss=10776576, vms=5178499072, pfaults=3351, pageins=58)
Elapsed time: 0.010818958282470703 s
# python3 默認
pmem(rss=11792384, vms=5033795584, pfaults=3587, pageins=0)
Elapsed time: 0.01322031021118164 s
# pypy3 slots
pmem(rss=40042496, vms=5263011840, pfaults=12341, pageins=4071)
Elapsed time: 0.005321025848388672 s
# pypy3 默認
pmem(rss=39862272, vms=4974653440, pfaults=12280, pageins=0)
Elapsed time: 0.004619121551513672 s
詳細信息可以看參考鏈接4和5
pypy最重要的特性還是stackless,支持高并發(fā)。這里有IO密集型任務(I/O-bound)和CPU密集型任務(compute-bound)的區(qū)分,CPU密集型任務的代碼,速度很慢,是因為執(zhí)行大量CPU指令,比如上文的for循環(huán);I / O密集型,速度因磁盤或網(wǎng)絡延遲而變慢,這兩者之間是有區(qū)別的。這部分內(nèi)容,要介紹清楚也不容易,容我們下章見。
小結(jié)
python是一門解釋型編程語言,具有多種解釋器實現(xiàn),常見的是cpython的實現(xiàn)。pypy使用了JIT技術(shù),在一些常見的場景下可以顯著提高python的執(zhí)行效率,對cpython的兼容性也很高。如果項目純python部分較多,推薦嘗試使用pypy運行程序。
順便打個廣告,我司常年招聘各類技術(shù)研發(fā),游戲策劃,測試,產(chǎn)品經(jīng)理等等,python方向感興趣的朋友可以微信公眾號里加博主微信參加內(nèi)推。
注:由于個人能力有限,文中示例如有謬誤,還望海涵。
附錄
測試環(huán)境
MacBook Pro (16-inch, 2019)(2.6 GHz 六核Intel Core i7) Python 2.7.16 Python 3.8.5 Python 3.6.9 [PyPy 7.3.1 with GCC 4.2.1 Compatible Apple LLVM 11.0.3 (clang-1103.0.32.59)] Python 2.7.13 [PyPy 7.3.1 with GCC 4.2.1 Compatible Apple LLVM 11.0.3 (clang-1103.0.32.59)] lua#Lua 5.2.3 Copyright (C) 1994-2013 Lua.org, PUC-Rio node#v10.16.3
參考鏈接
https://medium.com/android-news/magic-lies-here-statically-typed-vs-dynamically-typed-languages-d151c7f95e2b https://realpython.com/pypy-faster-python/ https://www.pypy.org/index.html https://stackoverflow.com/questions/23068076/using-slots-under-pypy https://morepypy.blogspot.com/2010/11/efficiently-implementing-python-objects.html

還不過癮?試試它們
