為什麼 Python 程式碼在函式中執行得更快?

鹹魚Linux運維發表於2023-09-19

哈嘍大家好,我是鹹魚

當談到程式設計效率和效能最佳化時,Python 常常被調侃為“慢如蝸牛”

有趣的是,Python 程式碼在函式中執行往往比在全域性範圍內執行要快得多

小夥伴們可能會有這個疑問:為什麼在函式中執行的 Python 程式碼速度更快?

今天這篇文章將會解答大家心中的疑惑

原文連結:https://stackabuse.com/why-does-python-code-run-faster-in-a-function/

譯文

要理解為什麼 Python 程式碼在函式中執行得更快,我們需要首先了解 Python 是如何執行程式碼的

我們知道,python 是一種解釋型語言,它會逐行讀取並執行程式碼

當執行一個 python 程式的時候,首先將程式碼編譯成位元組碼(一種更接近機器碼的中間語言)然後 python 直譯器執行位元組碼

def hello_world():
    print("Hello, World!")

import dis
dis.dis(hello_world)
#結果
  2           0 LOAD_GLOBAL              0 (print)
              2 LOAD_CONST               1 ('Hello, World!')
              4 CALL_FUNCTION            1
              6 POP_TOP
              8 LOAD_CONST               0 (None)
             10 RETURN_VALUE

由上所示,python 中的 dis 模組將函式 hello_world 分解為位元組碼

需要注意的是,python 直譯器是一個執行位元組碼的虛擬機器,預設的 python 直譯器是用 C 編寫的,即 CPython

還有其他的 python 直譯器如 Jython(用 Java 編寫),IronPython(用於 .net)和PyPy(用 Python 和 C 編寫)

為什麼 Python 程式碼在函式中執行得更快

我們來編寫一個簡單的例子:定義一個函式 my_function,函式內部包含一個 for 迴圈

def my_function():
    for i in range(100000000):
        pass

編譯該函式的時候,位元組碼可能如下所示

  SETUP_LOOP              20 (to 23)
  LOAD_GLOBAL             0 (range)
  LOAD_CONST              3 (100000000)
  CALL_FUNCTION           1
  GET_ITER            
  FOR_ITER                6 (to 22)
  STORE_FAST              0 (i)
  JUMP_ABSOLUTE           13
  POP_BLOCK           
  LOAD_CONST              0 (None)
  RETURN_VALUE

這裡的關鍵指令是 STORE_FAST ,用於儲存迴圈變數 i

現在我們把這個 for 迴圈放在 python 指令碼的頂層(全域性範圍內),然後再來看一下位元組碼

for i in range(100000000):
	pass
  SETUP_LOOP              20 (to 23)
  LOAD_NAME               0 (range)
  LOAD_CONST              3 (100000000)
  CALL_FUNCTION           1
  GET_ITER            
  FOR_ITER                6 (to 22)
  STORE_NAME              1 (i)
  JUMP_ABSOLUTE           13
  POP_BLOCK           
  LOAD_CONST              2 (None)
  RETURN_VALUE

可以看到關鍵指令變成了 STORE_NAME,而不是 STORE_FAST

位元組碼 STORE_FASTSTORE_NAME 快,因為在函式中,區域性變數儲存在固定長度的陣列中,而不是儲存在字典中。這個陣列可以透過索引直接訪問,使得變數檢索非常快

基本上,它只是一個指向列表的指標,並增加了 PyObject 的引用計數,這兩個都是高效的操作

另一方面,全域性變數儲存在一個字典。當訪問全域性變數時,Python 必須執行雜湊表查詢,這涉及計算雜湊值,然後檢索與之關聯的值

雖然經過最佳化,但仍然比基於索引的查詢慢

基準測試驗證

我們知道在 Python 中,程式碼執行的速度取決於程式碼執行的位置——在函式中還是在全域性作用域中

讓我們用一個簡單的基準測試的例子來比較一下

首先定義一個求階乘的函式

def factorial(n):
    result = 1
    for i in range(1, n + 1):
        result *= i
    return result

然後在全域性範圍內執行相同的程式碼

n = 20
result = 1
for i in range(1, n + 1):
    result *= i

為了對這兩段程式碼進行基準測試,我們可以在 Python 中使用 timeit 模組,它提供了一種簡單的方法來對少量 Python 程式碼進行計時

import timeit

# 函式
def benchmark():
    start = timeit.default_timer()

    factorial(20)

    end = timeit.default_timer()
    print(end - start)

benchmark()
# Prints: 3.541994374245405e-06

# 全域性範圍
start = timeit.default_timer()

n = 20
result = 1
for i in range(1, n + 1):
    result *= i

end = timeit.default_timer()
print(end - start) 
# Pirnts: 5.375011824071407e-06

可以看到,函式程式碼的執行速度比全域性作用域程式碼要快

需要注意的是,這兩段程式碼最好不要放在同一指令碼中,要分開單獨執行

這是因為 benchmark() 函式在執行時間上增加了一些開銷,並且全域性程式碼在內部進行了最佳化

cProfile 分析

python 提供了一個 cProfile 內建模組

讓我們用它來分析一個新例子:在區域性和全域性範圍內計算平方和

import cProfile

def sum_of_squares():
    total = 0
    for i in range(1, 10000000):
        total += i * i

i = None
total = 0
def sum_of_squares_g():
    global i
    global total
    for i in range(1, 10000000):
        total += i * i
    
def profile(func):
    pr = cProfile.Profile()
    pr.enable()

    func()

    pr.disable()
    pr.print_stats()
#
# Profile function code
#
print("Function scope:")
profile(sum_of_squares)

#
# Profile global scope code
#
print("Global scope:")
profile(sum_of_squares_g)

上面的例子中,可以認為sum_of_squares_g() 函式是全域性的,因為它使用了兩個全域性變數, itotal

從效能分析結果中,可以看到函式程式碼在執行時間方面比全域性更有效

Function scope:
         2 function calls in 0.903 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
   1       0.903    0.903    0.903    0.903 profiler.py:3(sum_of_squares)
   1       0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}


Global scope:
         2 function calls in 1.358 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
   1       1.358    1.358    1.358    1.358 profiler.py:10(sum_of_squares_g)
   1       0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

如何最佳化 python 函式的效能

前面我們知道,Python 程式碼在函式中執行往往比在全域性範圍內執行要快得多

如果想要進一步提高 python 函式程式碼效率,不妨考慮一下使用區域性變數而不是全域性變數

另一種方法是儘可能使用內建函式和庫。Python 的內建函式是用 C 實現的,比 Python 快得多

比如 NumPy 和 Pandas,也是用 C 或 C++ 實現的,它們比實現同樣功能的 Python 程式碼速度更快

又比如同樣是實現數字求和的功能,python 內建的 sum 函式要比你自己編寫函式速度更快

相關文章