pypy 真的能讓 python 比 c 還快?

最近 “pypy 爲什麼能讓 python 比 c 還快” 刷屏了,原文講的內容偏理論,乾貨比較少。我們可以再深入一點點,瞭解 pypy 的真相。

正式開始之前,多嘮叨兩句。我司發力多個賽道的遊戲,其中包括某魚類遊戲 Top2 項目,拿過阿拉丁神燈獎的 SLG 卡牌小遊戲項目和海外三消遊戲。這些不同類型的遊戲,後端大多是使用的是 pypy。對於如何使用 pypy,我有一點使用經驗可以聊聊。話不多說,正式開始,本文包括下面幾個部分:

語言分類

我們先從最基本的一些語言分類概念聊起,對這部分內容非常瞭解的朋友可以跳過。

靜態語言 vs 動態語言

如果在編譯時知道變量的類型,則該語言爲靜態類型。靜態類型語言的常見示例包括 Java,C,C ++,FORTRAN,Pascal 和 Scala。在靜態類型語言中,一旦使用類型聲明瞭變量,就無法將其分配給其他不同類型的變量,這樣做會在編譯時引發類型錯誤。

# java

int data;
data = 50;
data = “Hello Game_404!”; // causes an compilation error

如果在運行時檢查變量的類型,則語言是動態類型的。動態類型語言的常見示例包括 JavaScript,Objective-C,PHP,Python,Ruby,Lisp 和 Tcl。在動態類型語言中,變量在運行時通過賦值語句綁定到對象,並且可以在程序執行期間將相同的變量綁定到不同類型的對象。

# python

data = 10;
data = "Hello Game_404!"; // no error caused
data = data + str(10)

一般來說靜態語言編譯成字節碼執行,動態語言使用解釋器執行。編譯型語言性能更高,但是較難移植到不同的 CPU 架構體系和操作系統。解釋型語言易於移植,性能會比編譯語言要差得多。這是頻譜的兩個極端。

強類型語言 vs 弱類型語言

強類型語言是一種變量被綁定到特定數據類型的語言,如果類型與表達式中的預期不一致,將導致類型錯誤,比如下面這個:

# 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;

常見編程語言的象限分類如下圖:

language

這一部分內容主要翻譯自參考鏈接 1

python 的解釋器實現

python 是一門動態編程語言,由特定的解釋器解釋執行。下面是一些解釋器實現:

還有幾個相關概念:

這裏大家會有一個疑問,python 不是解釋型語言嘛?怎麼又有編譯後的 pyc。是這樣的: py 文件編譯成 pyc 後,解釋器默認 優先 執行 pyc 文件,這樣可以加快 python 程序的 啓動速度 (注意是啓動速度)。繼背叛弱類型語言後,python 這個鬼又在編譯語言和解釋語言之間橫跳。

還有一個事件是 Go 語言在 1.5 版本實現自舉。Go 語言在 1.5 版本之前使用 c 實現的編譯器,在 1.5 版本時候使用 Go 實現了自己的編譯器,這裏有一個雞生蛋和蛋生雞的過程,也挺有意思。

pypy 爲什麼快

pypy 使用 python 的子集 rpython 實現瞭解釋器,和前面介紹的 Go 的自舉有點類似。反常識的是 rpython 的解釋器會比 c 實現的解釋器快?主要是因爲 pypy 使用了 JIT 技術。

Just-In-Time (JIT) Compiler 試圖通過對機器碼進行一些實際的編譯和一些解釋來獲得兩全其美的方法。簡而言之,以下是 JIT 編譯爲提高性能而採取的步驟:

  1. 標識代碼中最常用的組件,例如循環中的函數。

  2. 在運行時將這些零件轉換爲機器碼。

  3. 優化生成的機器碼。

  4. 用優化的機器碼版本交換以前的實現。

這也是 “pypy 爲什麼能讓 python 比 c 還快” 一文中的示例展現出來的能力。pypy 除了速度快外,還有下面一些特點:

以上都是宣稱

pypy 這麼強,快和省都佔了,爲什麼沒有大規模流行起來呢? 我個人認爲,主要還是 python 的原因。

  1. python 生態中大量庫採用 c 實現,特別是科學計算 / AI 相關的庫,pypy 在這塊並沒有優勢。pypy 快的主要在 pure-python,也就是純粹的 python 實現部分。

  2. pypy 適合長駐內存的高併發應用(web 服務類)

  3. python 是一門膠水語言,並不追求性能極致,即使快 4 倍也不夠快:( 🐶。肯定比不上 c,原文中的 c 應該是 偷換了概念 ,指 c 實現的 cpython 解釋器。

需要注意的是,pypy 一樣也有 GIL 的存在, 所以高併發主要在 stackless。

這一部分內容參考自參考鏈接 2

性能比較

我們可以編寫性能測試用例,用代碼說話,對各個實現進行對比。本文的測試用例並不嚴謹,不過也足夠說明一些問題了。

開車和步行

原文中累加測試用例是 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")

測試結果如下表 (測試環境在本文附錄部分):

yI1ErP

結果顯示運行 1000 次循環的情況下 cpython 要比 pypy 快,這和循環 100000000 次 相反 。用下面的例子可以非常形象的解釋這一點。

假設您想去一家離您家很近的商店。您可以步行或開車。您的汽車顯然比腳快得多。但是,請考慮需要執行以下操作:

  1. 去你的車庫。

  2. 啓動你的車。

  3. 讓汽車暖一點。

  4. 開車去商店。

  5. 查找停車位。

  6. 在返回途中重複該過程。

開車要涉及很多開銷,如果您想去的地方在附近,這並不總是值得的!現在想想如果您想去五十英里外的鄰近城市會發生什麼。開車去那裏而不是步行肯定是值得的。

舉例來自參考鏈接 2

儘管速度的差異並不像上面類比那麼明顯,但是 PyPy 和 CPython 的情況也是如此。

橫向對比

我們橫向對比一下 c,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;
}

5IJDHK

測試結果可見,c 無疑是最快的,秒殺其它語言,這是編譯語言的特點。在解釋語言中,pypy3 表現配得上優秀二字。

內存佔用

測試用例中增加內存佔用的輸出:

p = psutil.Process()
mem = p.memory_info()
print(mem)

測試結果如下:

# python3
pmem(rss= 9027584, vms=4747534336, pfaults= 2914, pageins=1)

# pypy3
pmem(rss=39518208, vms=5127745536, pfaults=12188, pageins=58)

pypy3 的內存佔用會比 python3 要高,這個才科學,用內存空間換了運行時間。當然這個評測並不嚴謹,實際情況如何,pypy 宣稱的內存佔用較少,我表示懷疑,但是沒有證據。

性能優化方法

瞭解語言的性能比較後,我們再看看一些性能優化的方法,這對在 cpython 和 pypy 之間選型有幫助。

使用 c 函數

python 中使用 c 函數,比如這裏的累加可以使用 reduce 替換,可以提高效率:

def my_add(a, b):
    return a + b

number = reduce(add, range(100000000))

AoWxWN

結果展示,reduce 對 cpython 和 pypy 都有效。

優化循環

優化最關鍵的地方,提高算法效率,減少循環。更改一下累加的需求,假設我們是求 100000000 以內的偶數的和,下面展示了使用 range 的步進減少循環次數來提高性能:

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

ctfbpV

循環次數減半後,有效率顯著提升。

靜態類型

python3 可以使用類型註解,提高代碼可讀性。類型確定邏輯上對性能有幫助,每次處理數據的時候,不用再進行類型推斷。

number: int = 0
for i in range(100000000):
    number += i

MypoIl

內存相當於一個空間,我們要用不同的盒子去填充它。圖中左邊部分 1,都使用長度爲 4(想像 float 類型) 的盒子填充,一行一個,速度最快;圖中中間部分 2,使用長度爲 3(想像 long 類型) 和長度爲 1(想像 int 類型) 的箱子,一行 2 個,也挺快;圖中右側 3,雖然箱子長度仍然是 3 和 1,但是由於沒有刻度,填充時候需要試裝,所以速度最慢。

數據類型

算法的魅力

優化到最後,最重量級的內容登場:高斯求和算法。高斯的故事,想必大家都不陌生,下面是算法實現:

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)

ooMoL7

使用高斯求和後,程序秒開。這大概就是業內面試,要考算法的真相,也是算法的魅力所在。

優化的原則

簡單介紹一下優化的原則,主要是下面 2 點:

  1. 使用測試而不是推測。
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 表現相反,所以性能優化要實測,注意環境和實效性。

  1. 遵循 2/8 法則, 不要過度優化,不用贅述。

pypy 的特性

pypy 還有下面一些特性:

使用 slots 在 python 對象中,可以減少對象內存佔用,提高效率,下面是測試用例:

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( + 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( + 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,支持高併發。這裏有 IO 密集型任務(I/O-bound)和 CPU 密集型任務(compute-bound)的區分,CPU 密集型任務的代碼,速度很慢,是因爲執行大量 CPU 指令,比如上文的 for 循環;I / O 密集型,速度因磁盤或網絡延遲而變慢,這兩者之間是有區別的。這部分內容,要介紹清楚也不容易,容我們下章見。

小結

python 是一門解釋型編程語言,具有多種解釋器實現,常見的是 cpython 的實現。pypy 使用了 JIT 技術,在一些常見的場景下可以顯著提高 python 的執行效率,對 cpython 的兼容性也很高。如果項目純 python 部分較多,推薦嘗試使用 pypy 運行程序。

附錄

測試環境

參考鏈接

  1. https://medium.com/android-news/magic-lies-here-statically-typed-vs-dynamically-typed-languages-d151c7f95e2b

  2. https://realpython.com/pypy-faster-python/

  3. https://www.pypy.org/index.html

  4. https://stackoverflow.com/questions/23068076/using-slots-under-pypy

  5. https://morepypy.blogspot.com/2010/11/efficiently-implementing-python-objects.html

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/5agi3P2V8aG3vGfvX01ZnQ