從環(huán)境設(shè)置到內(nèi)存分析:Python代碼優(yōu)化指南
代碼地址:https://github.com/apatrascu/hunting-python-performance
目錄
一、環(huán)境設(shè)置
二、內(nèi)存分析
三、CPU 分析——Python 腳本
四、CPU 分析——Python 解釋器
本文是該教程的***部分,主要從環(huán)境設(shè)置和內(nèi)存分析兩個方面探討Python代碼優(yōu)化的路徑。
一、環(huán)境設(shè)置
設(shè)置
在深入到基準(zhǔn)測試和性能分析之前,首先我們需要一個合適的環(huán)境。這意味著我們需要為這項任務(wù)配置我們的機(jī)器和操作系統(tǒng)。
我的機(jī)器的規(guī)格如下:
- 處理器:Intel(R) Xeon(R) CPU E5-2699 v3 @ 2.30GHz
- 內(nèi)存:32GB
- 操作系統(tǒng):Ubuntu 16.04 LTS
- Kernel:4.4.0-75-generic
我們的目標(biāo)是得到可復(fù)現(xiàn)的結(jié)果,因此要確保我們的數(shù)據(jù)不會受到其它后臺進(jìn)程、操作系統(tǒng)配置或任何其它硬件性能提升技術(shù)的影響。
讓我們首先從配置用于性能分析的機(jī)器開始。
硬件功能
首先,禁用所有硬件性能功能,也就是說要禁用 Intel Turbo Boost 和 Hyper Threading from BIOS/UEFI。
正如其官方網(wǎng)頁上說的那樣,Turbo Boost 是「一種在處理器內(nèi)核運(yùn)行,并可以在低于功耗、電流和溫度規(guī)格限制的情況下允許它們以高于額定頻率的速度運(yùn)行的技術(shù)。」此外,Hyper Threading 是「一種可以更高效地利用處理器資源的技術(shù),能使每個內(nèi)核都能多線程運(yùn)行?!?/p>
這都是值得我們花錢購買的好東西。那為什么要在性能分析/基準(zhǔn)測試中禁用它們呢?因為使用這些技術(shù)會讓我們無法得到可靠的和可復(fù)現(xiàn)的結(jié)果。這會讓運(yùn)行過程發(fā)生變化。讓我們看個小例子 primes.py,代碼故意寫得很糟糕。
import time
import statistics
def primes(n):
if n==2:
return [2]
elif n<2:
return []
s=range(3,n+1,2)
mroot = n ** 0.5
half=(n+1)/2-1
i=0
m=3
while m <= mroot:
if s[i]:
j=(m*m-3)/2
s[j]=0
while j<half:
s[j]=0
j+=m
i=i+1
m=2*i+3
return [2]+[x for x in s if x]
def benchmark():
results = []
gstart = time.time()
for _ in xrange(5):
start = time.time()
count = len(primes(1000000))
end = time.time()
results.append(end-start)
gend = time.time()
mean = statistics.mean(results)
stdev = statistics.stdev(results)
perc = (stdev * 100)/ mean
print "Benchmark duration: %r seconds" % (gend-gstart)
print "Mean duration: %r seconds" % mean
print "Standard deviation: %r (%r %%)" % (stdev, perc)
benchmark()
這段代碼可在 GitHub 上查看:https://github.com/apatrascu/hunting-python-performance/blob/master/01.primes.py。你需要運(yùn)行以下命令安裝一個依賴包:
pip install statistics
讓我們在一個啟用了 Turbo Boost 和 Hyper Threading 的系統(tǒng)中運(yùn)行它:
python primes.py
Benchmark duration: 1.0644240379333496 seconds
Mean duration: 0.2128755569458008 seconds
Standard deviation: 0.032928838418120374 (15.468585914964498 %)
現(xiàn)在禁用該系統(tǒng)的睿頻加速(Turbo Boost)和超線程(Hyper Threading),然后再次運(yùn)行這段代碼:
python primes.py
Benchmark duration: 1.2374498844146729 seconds
Mean duration: 0.12374367713928222 seconds
Standard deviation: 0.000684464852339824 (0.553131172568 %)
看看***個案例的標(biāo)準(zhǔn)差為 15%。這是一個很大的值!假設(shè)我們的優(yōu)化只能帶來 6% 的加速,那我們怎么能將運(yùn)行過程中的變化(run to run variation)和你的實現(xiàn)的差異區(qū)分開?相對而言,在第二個例子中,標(biāo)準(zhǔn)差減少到了大約 0.6%,我們的新優(yōu)化方案效果清晰可見。
CPU 節(jié)能
禁用所有的 CPU 節(jié)能設(shè)置,并使用固定的 CPU 頻率。這可以通過在 Linux 功率調(diào)節(jié)器(power governor)中將 intel_pstate 改成 acpi_cpufreq 而實現(xiàn)。
intel_pstate 驅(qū)動使用英特爾內(nèi)核(Sandy Bridge 或更新)處理器的內(nèi)部調(diào)節(jié)器實現(xiàn)了一個縮放驅(qū)動。acpi_cpufreq 使用了 ACPI Processor Performance States。
下面讓我們先來檢查一下:
$ cpupower frequency-info
analyzing CPU 0:
driver: intel_pstate
CPUs which run at the same hardware frequency: 0
CPUs which need to have their frequency coordinated by software: 0
maximum transition latency: 0.97 ms.
hardware limits: 1.20 GHz - 3.60 GHz
available cpufreq governors: performance, powersave
current policy: frequency should be within 1.20 GHz and 3.60 GHz.
The governor "powersave" may decide which speed to use
within this range.
current CPU frequency is 1.20 GHz.
boost state support:
Supported: yes
Active: yes
可以看到這里所使用的調(diào)節(jié)器被設(shè)置成了節(jié)能模式,而 CPU 的頻率范圍在 1.20 GHz 到 3.60 GHz 之間。這個設(shè)置對日常應(yīng)用來說是很不錯的,但卻會影響到基準(zhǔn)測試的結(jié)果。
那么應(yīng)該給調(diào)節(jié)器設(shè)置什么值呢?如果我們?yōu)g覽一下文檔,我們可以看到我們可以使用以下設(shè)置:
- 高性能(performance):以***頻率運(yùn)行 CPU
- 節(jié)能(powersave):以最小頻率運(yùn)行 CPU
- 自定義(userspace):按用戶指定的頻率運(yùn)行 CPU
- 按需(ondemand):根據(jù)當(dāng)前負(fù)載動態(tài)調(diào)節(jié)頻率??赡芴?**頻率,空閑時又會降低
- 保守(conservative):根據(jù)當(dāng)前負(fù)載動態(tài)調(diào)節(jié)頻率。相比于按需模式,其頻率調(diào)節(jié)更加漸進(jìn)
我們要使用性能調(diào)節(jié)器(performance governor),并將頻率設(shè)置成 CPU 支持的***頻率。如下所示:
$ cpupower frequency-info
analyzing CPU 0:
driver: acpi-cpufreq
CPUs which run at the same hardware frequency: 0
CPUs which need to have their frequency coordinated by software: 0
maximum transition latency: 10.0 us.
hardware limits: 1.20 GHz - 2.30 GHz
available frequency steps: 2.30 GHz, 2.20 GHz, 2.10 GHz, 2.00 GHz, 1.90 GHz, 1.80 GHz, 1.70 GHz, 1.60 GHz, 1.50 GHz, 1.40 GHz, 1.30 GHz, 1.20 GHz
available cpufreq governors: conservative, ondemand, userspace, powersave, performance
current policy: frequency should be within 2.30 GHz and 2.30 GHz.
The governor "performance" may decide which speed to use
within this range.
current CPU frequency is 2.30 GHz.
cpufreq stats: 2.30 GHz:100.00%, 2.20 GHz:0.00%, 2.10 GHz:0.00%, 2.00 GHz:0.00%, 1.90 GHz:0.00%, 1.80 GHz:0.00%, 1.70 GHz:0.00%, 1.60 GHz:0.00%, 1.50 GHz:0.00%, 1.40 GHz:0.00%, 1.30 GHz:0.00%, 1.20 GHz:0.00% (174)
boost state support:
Supported: no
Active: no
現(xiàn)在你已經(jīng)使用性能調(diào)節(jié)器將頻率設(shè)置成了固定的 2.3 GHz。這是***的可設(shè)置的值,沒有睿頻加速(Turbo Boost),它可以被用在 Xeon E5-2699 v3 上。
要完成設(shè)置,請使用管理員權(quán)限運(yùn)行以下命令:
cpupower frequency-set -g performance
cpupower frequency-set --min 2300000 --max 2300000
如果你沒有 cpupower,可使用以下命令安裝:
sudo apt-get install linux-tools-common linux-header-`uname -r` -y
功率調(diào)節(jié)器對 CPU 的工作方式有很大的影響。該調(diào)節(jié)器的默認(rèn)設(shè)置是自動調(diào)節(jié)頻率以減少功耗。我們不想要這樣的設(shè)置,所以從 GRUB 中禁用它。只需要編輯 /boot/grub/grub.cfg(但是如果你在 kernel 升級上很小心,那么這將會消失)或在 /etc/grub.d/40_custom 中創(chuàng)建一個新的 kernel 入口。我們的 boot 行中必須包含這個 flag: intel_pstate=disable,如下所示:
linux /boot/vmlinuz-4.4.0-78-generic.efi.signed root=UUID=86097ec1-3fa4-4d00-97c7-3bf91787be83 ro intel_pstate=disable quiet splash $vt_handoff
ASLR(地址空間配置隨機(jī)發(fā)生器)
這個設(shè)置是有爭議的,參見 Victor Stinner 的博客:https://haypo.github.io/journey-to-stable-benchmark-average.html。當(dāng)我***建議在基準(zhǔn)測試時禁用 ASLR 時,那是為了進(jìn)一步提升對那時在 CPython 中存在的 Profile Guided Optimizations 的支持。
我為什么要說這個呢?因為在上面給出的特定硬件上,禁用 ASLR 可以將運(yùn)行之間的標(biāo)準(zhǔn)差降低至 0.4%。另一方面,根據(jù)在我的個人計算機(jī)(Intel Core i7 4710MQ)上的測試,禁用 ASLR 會導(dǎo)致 Victor 所提到的同樣的問題。在更小的 CPU(比如 Intel Atom)上的測試會帶來甚至更大的運(yùn)行間標(biāo)準(zhǔn)差。
因為這似乎并不是普遍適用的真理,而且很大程度上依賴于硬件/軟件配置,所以對于這個設(shè)置,我在啟用后測量一次,再禁用后測量一次,之后再進(jìn)行比較。在我的機(jī)器上,我通過在 /etc/sysctl.conf. 中加入以下命令禁用了 ASLR。使用 sudo sysctl -p 進(jìn)行應(yīng)用。
kernel.randomize_va_space = 0
如果你想在運(yùn)行時禁用它:
sudo bash -c 'echo 0 >| /proc/sys/kernel/randomize_va_space'
如果你想重新啟用:
sudo bash -c 'echo 2 >| /proc/sys/kernel/randomize_va_space'
二、內(nèi)存分析
在這一節(jié),我將介紹一些有助于我們解決 Python 中(尤其是使用 PyPy 時)的內(nèi)存消耗難題的工具。我們?yōu)槭裁匆P(guān)心這個問題?為什么我們不僅僅就關(guān)心性能?這些問題的答案相當(dāng)復(fù)雜,但我會總結(jié)出來。
PyPy 是一個可選的 Python 解釋器,其相對于 CPython 有一些巨大的優(yōu)勢:速度(通過其 Just in Time 編譯器)、兼容性(幾乎可以替代 CPython)和并發(fā)性(使用 stackless 和 greenlets)。
PyPy 的一個缺點(diǎn)是因為其 JIT 和垃圾一樣的回收站實現(xiàn),它通常會使用比 CPython 更多的內(nèi)存。但是在某些案例中,其的內(nèi)存消耗會比 CPython 少。下面我們來看看你可以如何測量你的應(yīng)用使用了多少內(nèi)存。
診斷內(nèi)存使用
memory_profiler 是一個可用來測量解釋器運(yùn)行一個負(fù)載時的內(nèi)存用量的庫。你可以通過 pip 安裝它:
pip install memory_profiler
另外還要安裝 psutil 依賴包:
pip install psutil
這個工具的優(yōu)點(diǎn)是它會在一個 Python 腳本中一行行地顯示內(nèi)存消耗。這可以讓我們找到腳本中可以被我們重寫的位置。但這種分析有一個缺點(diǎn)。你的代碼的運(yùn)行速度比一般腳本慢 10 到 20 倍。
怎么使用它?你只需要在你需要測量的函數(shù)上直接加上 @profile() 即可。讓我們看看實際怎么操作!我們將使用之前用過的素材腳本作為模型,但做了一點(diǎn)修改,移除了統(tǒng)計部分。代碼也可在 GitHub 查看:https://github.com/apatrascu/hunting-python-performance/blob/master/02.primes-v1.py
from memory_profiler import profile
@profile(precision=6)
def primes(n):
if n == 2:
return [2]
elif n < 2:
return []
s = range(3, n + 1, 2)
mroot = n ** 0.5
half = (n + 1) / 2 - 1
i = 0
m = 3
while m <= mroot:
if s[i]:
j = (m * m - 3) / 2
s[j] = 0
while j < half:
s[j] = 0
j += m
i = i + 1
m = 2 * i + 3
return [2] + [x for x in s if x]
len(primes(100000))
開始測量時,使用以下 PyPy 命令:
pypy -m memory_profiler 02.primes-v3.py
或者直接在腳本中導(dǎo)入 memory_profiler:
pypy -m memory_profiler 02.primes-v3.py
在執(zhí)行完這行代碼之后,我們可以看到 PyPy 得到這樣的結(jié)果:
Line # Mem usage Increment Line Contents
================================================
54 35.312500 MiB 0.000000 MiB @profile(precision=6)
55 def primes(n):
56 35.351562 MiB 0.039062 MiB if n == 2:
57 return [2]
58 35.355469 MiB 0.003906 MiB elif n < 2:
59 return []
60 35.355469 MiB 0.000000 MiB s = []
61 59.515625 MiB 24.160156 MiB for i in range(3, n+1):
62 59.515625 MiB 0.000000 MiB if i % 2 != 0:
63 59.515625 MiB 0.000000 MiB s.append(i)
64 59.546875 MiB 0.031250 MiB mroot = n ** 0.5
65 59.550781 MiB 0.003906 MiB half = (n + 1) / 2 - 1
66 59.550781 MiB 0.000000 MiB i = 0
67 59.550781 MiB 0.000000 MiB m = 3
68 59.554688 MiB 0.003906 MiB while m <= mroot:
69 59.554688 MiB 0.000000 MiB if s[i]:
70 59.554688 MiB 0.000000 MiB j = (m * m - 3) / 2
71 59.554688 MiB 0.000000 MiB s[j] = 0
72 59.554688 MiB 0.000000 MiB while j < half:
73 59.554688 MiB 0.000000 MiB s[j] = 0
74 59.554688 MiB 0.000000 MiB j += m
75 59.554688 MiB 0.000000 MiB i = i + 1
76 59.554688 MiB 0.000000 MiB m = 2 * i + 3
77 59.554688 MiB 0.000000 MiB l = [2]
78 59.679688 MiB 0.125000 MiB for x in s:
79 59.679688 MiB 0.000000 MiB if x:
80 59.679688 MiB 0.000000 MiB l.append(x)
81 59.683594 MiB 0.003906 MiB return l
我們可以看到這個腳本使用了 24.371094 MiB 的 RAM。讓我們簡單分析一下。我們看到其中大多數(shù)都用在了數(shù)值數(shù)組的構(gòu)建中。它排除了偶數(shù)數(shù)值,保留了所有其它數(shù)值。
我們可以通過調(diào)用 range 函數(shù)而對其進(jìn)行一點(diǎn)改進(jìn),其使用一個增量參數(shù)。在這個案例中,該腳本看起來像是這樣:
from memory_profiler import profile
@profile(precision=6)
def primes(n):
if n == 2:
return [2]
elif n < 2:
return []
s = range(3, n + 1, 2)
mroot = n ** 0.5
half = (n + 1) / 2 - 1
i = 0
m = 3
while m <= mroot:
if s[i]:
j = (m * m - 3) / 2
s[j] = 0
while j < half:
s[j] = 0
j += m
i = i + 1
m = 2 * i + 3
l = [2]
for x in s:
if x:
l.append(x)
return l
len(primes(100000))
如果我們再次測量,我們可以得到以下結(jié)果:
Line # Mem usage Increment Line Contents
================================================
27 35.343750 MiB 0.000000 MiB @profile(precision=6)
28 def primes(n):
29 35.382812 MiB 0.039062 MiB if n == 2:
30 return [2]
31 35.382812 MiB 0.000000 MiB elif n < 2:
32 return []
33 35.386719 MiB 0.003906 MiB s = range(3, n + 1, 2)
34 35.417969 MiB 0.031250 MiB mroot = n ** 0.5
35 35.417969 MiB 0.000000 MiB half = (n + 1) / 2 - 1
36 35.417969 MiB 0.000000 MiB i = 0
37 35.421875 MiB 0.003906 MiB m = 3
38 58.019531 MiB 22.597656 MiB while m <= mroot:
39 58.019531 MiB 0.000000 MiB if s[i]:
40 58.019531 MiB 0.000000 MiB j = (m * m - 3) / 2
41 58.019531 MiB 0.000000 MiB s[j] = 0
42 58.019531 MiB 0.000000 MiB while j < half:
43 58.019531 MiB 0.000000 MiB s[j] = 0
44 58.019531 MiB 0.000000 MiB j += m
45 58.019531 MiB 0.000000 MiB i = i + 1
46 58.019531 MiB 0.000000 MiB m = 2 * i + 3
47 58.019531 MiB 0.000000 MiB l = [2]
48 58.089844 MiB 0.070312 MiB for x in s:
49 58.089844 MiB 0.000000 MiB if x:
50 58.089844 MiB 0.000000 MiB l.append(x)
51 58.093750 MiB 0.003906 MiB return l
很好,現(xiàn)在我們的內(nèi)存消耗下降到了 22.75 MiB。使用列表解析(list comprehension),我們還可以將消耗再降低一點(diǎn)。
from memory_profiler import profile
@profile(precision=6)
def primes(n):
if n == 2:
return [2]
elif n < 2:
return []
s = range(3, n + 1, 2)
mroot = n ** 0.5
half = (n + 1) / 2 - 1
i = 0
m = 3
while m <= mroot:
if s[i]:
j = (m * m - 3) / 2
s[j] = 0
while j < half:
s[j] = 0
j += m
i = i + 1
m = 2 * i + 3
return [2] + [x for x in s if x]
len(primes(100000))
再次測量:
Line # Mem usage Increment Line Contents
================================================
4 35.425781 MiB 0.000000 MiB @profile(precision=6)
5 def primes(n):
6 35.464844 MiB 0.039062 MiB if n == 2:
7 return [2]
8 35.464844 MiB 0.000000 MiB elif n < 2:
9 return []
10 35.464844 MiB 0.000000 MiB s = range(3, n + 1, 2)
11 35.500000 MiB 0.035156 MiB mroot = n ** 0.5
12 35.500000 MiB 0.000000 MiB half = (n + 1) / 2 - 1
13 35.500000 MiB 0.000000 MiB i = 0
14 35.500000 MiB 0.000000 MiB m = 3
15 57.683594 MiB 22.183594 MiB while m <= mroot:
16 57.683594 MiB 0.000000 MiB if s[i]:
17 57.683594 MiB 0.000000 MiB j = (m * m - 3) / 2
18 57.683594 MiB 0.000000 MiB s[j] = 0
19 57.683594 MiB 0.000000 MiB while j < half:
20 57.683594 MiB 0.000000 MiB s[j] = 0
21 57.683594 MiB 0.000000 MiB j += m
22 57.683594 MiB 0.000000 MiB i = i + 1
23 57.683594 MiB 0.000000 MiB m = 2 * i + 3
24 57.847656 MiB 0.164062 MiB return [2] + [x for x in s if x]
我們***的腳本僅消耗 22.421875 MiB。相比于***個版本,差不多下降了 10%。
原文地址:
- https://pythonfiles.wordpress.com/2017/05/15/hunting-python-performance-setup/
- https://pythonfiles.wordpress.com/2017/05/18/hunting-python-performance-part-2/