【Python】実行速度が遅くなるアンチパターンと改善レシピ7選
前書き
こんにちは、フォルシアでエンジニアをしている太田と申します。
私は主にPythonを利用した開発を行っているのですが、開発内容の関係上、Pythonのコードを極力高速で実行したい瞬間があります。
そこで今回は、Pythonが遅くなってしまうようなアンチパターンと低速化の回避が可能なコードを紹介し、定量的に速度の比較結果も示したいと思います。
今回検証した環境と検証の際の条件を下記に示します。
検証した環境
Python: 3.14.0 (cpython)
OS: Linux 5.15.167.4-microsoft-standard-WSL2+
検証条件
- コーディングのみの工夫でアンチパターンの改善を行う
- Pythonから呼び出すライブラリは標準ライブラリのみとする
- 実行時間計測には
timeitを利用しています
- 実行時間計測には
- 実行時間は処理5回分の平均時間とする
- マルチスレッド化は今回検証対象外とする
- 当たり前過ぎるかと思い対象外としました
ここで今回紹介するアンチパターンを盛りこんだコードを先に提示しておきます。
興味のある方はどこをどう改善すべきか考えてみてください。
※ブログ中のコードに関しては可読性向上のため、型ヒントを省略しています。
import math
import time
data_source_1 = list(range(500000))
data_source_2 = list(range(500000, 1000000))
block_list_data = [x * 100 for x in range(10000)]
def slow_processing(src1, src2, block_list):
all_data = src1 + src2
valid_data = []
for item in all_data:
if item not in block_list:
valid_data.append(item)
queue = list(valid_data) # データのコピー作成
processed_results = []
while len(queue) > 0:
val = queue.pop(0)
root_val = math.sqrt(val)
processed_results.append(root_val)
total_val = 0
for x in processed_results:
total_val += x
report = ""
for i in range(1000): # 文字列結合のデモ用に先頭1000件のみ抽出
report += str(processed_results[i]) + ","
return total_val, report
res_slow, report_slow = slow_processing(data_source_1, data_source_2, block_list_data)
アンチパターン / 改善策
1.リストではなくセットを使う
特定の要素が集合に含まれているかを確認する(in演算子を使用する)場合、リスト(list)ではなくセット(set)を使用すべきです。
リストでの検索は、先頭から順番に要素を確認していく線形探索を行うため、データ量が増えるほど時間がかかります。
対してセットはハッシュテーブルという構造でデータを管理しており、データ量に関係なく計算位置を直接特定できるため、
リストで検索を行うより高速で検索が完了します。[1]
import timeit
def check_exists_in_list(target):
if target in data_list:
return True
return False
def check_exists_in_set(target):
if target in data_set:
return True
return False
loop = 5
data_list = [i for i in range(1000000)]
data_set = set(range(1000000))
target = 500000
slow_result = timeit.timeit("check_exists_in_list(data_list)", globals=globals(), number=loop)
print(f"Slow Version Time: {(slow_result / loop):.4f} sec")
fast_result = timeit.timeit("check_exists_in_set(data_set)", globals=globals(), number=loop)
print(f"Fast Version Time: {(fast_result / loop):.4f} sec")
Slow Version Time: 0.0077 sec
Fast Version Time: 0.0018 sec
2.文字列結合には+=ではなくjoinを使う
ループ処理の中で文字列を連結する際、+=演算子を使わず、リストに要素を追加してから最後に"".join(list)で結合すべきです。
Pythonの文字列は変更不可(イミュータブル)です。+=を実行するたびに、元の文字列と新しい文字列を合わせた新しいメモリ領域を確保し、中身をコピーします。
これがループ回数分繰り返されると、コピー量が雪だるま式に増え、処理速度が劇的に低下する一方、
joinは一度に必要なメモリを計算して確保するため高速です。[2]
import timeit
def combine_string_with_operator(word_list):
result = ""
for word in word_list:
result += word
return result
def combine_string_with_join(word_list):
temp_list = []
for word in word_list:
temp_list.append(word)
result = "".join(temp_list)
return result
loop = 5
words = ["Python", "Performance", "Tuning"] * 1000000
slow_result = timeit.timeit("combine_string_with_operator(words)", globals=globals(), number=loop)
print(f"Slow Version Time: {(slow_result / loop):.4f} sec")
fast_result = timeit.timeit("combine_string_with_join(words)", globals=globals(), number=loop)
print(f"Fast Version Time: {(fast_result / loop):.4f} sec")
Slow Version Time: 0.0691 sec
Fast Version Time: 0.0467 sec
3.リストの作成にはforループより内包表記を使う
既存のデータから新しいリストを作成する場合、空リストを作ってforループでappend()するのではなく、内包表記を使用すべきです。
通常のforループでは、毎回appendという属性を検索し、メソッドとして呼び出すオーバーヘッドがかかります。
リスト内包表記はLIST_APPENDという専用のバイトコードを使用し、Python仮想マシン内でC言語レベルの最適化されたループ処理が行われるため、インタプリタの介入が減り高速化します。[3]
import timeit
def make_list_with_for_loop(data):
result = []
for x in data:
result.append(x * 2)
return result
def make_list_with_connotation(data):
result = [x * 2 for x in data]
return result
loop = 5
data = range(1000000)
slow_result = timeit.timeit("make_list_with_for_loop(data)", globals=globals(), number=loop)
print(f"Slow Version Time: {(slow_result / loop):.4f} sec")
fast_result = timeit.timeit("make_list_with_connotation(data)", globals=globals(), number=loop)
print(f"Fast Version Time: {(fast_result / loop):.4f} sec")
Slow Version Time: 0.0335 sec
Fast Version Time: 0.0264 sec
4.先頭要素の操作にはpop(0)ではなくpopleft()を使う
かなり有名な内容だと思います。例として先頭要素の取得を行うシチュエーションで考えてみます。
リストをキューとして扱う際、先頭要素の取得する際にlist.pop(0)を使うのは避け、collections.dequeを使用してpopleft()を行うべきです。
これは、リストはメモリ上で連続した配列として実装されており、先頭を取得すると、空いた穴を埋めるために残りの全要素を1つずつ前にずらすシフト処理が発生し低速化を招きます。
これに対しdequeは双方向リスト構造のため、先頭要素の取得もシフト処理なしで行えます。[4]
もちろん、先頭要素の削除だけ行いたい場合であればlistをreverseして末尾を取得することでもdequeと同レベル感の実行速度を実現できます。
ですが、dequeにはappendとpopleftが両立できるというメリットもあり、このあたりの選定はデータの追加と削除が必要かどうか、
インデックスアクセスが必要かどうか、扱いたいシチュエーションが先入れ先出しか後入れ先出しかなどケースバイケースになってくるかと思います。
import timeit
from collections import deque
def get_first_content_with_pop(queue):
item = queue.pop(0)
return item
def get_first_content_with_popleft(queue):
item = queue.popleft()
return item
loop = 5
queue = [i for i in range(1000000)]
dequed_queue = deque(queue)
# このpointでは出力する桁数を変えています
slow_result = timeit.timeit("remove_first_content_with_pop(queue)", globals=globals(), number=loop)
print(f"Slow Version Time: {(slow_result / loop):.8f} sec")
fast_result = timeit.timeit("remove_first_content_with_popleft(dequed_queue)", globals=globals(), number=loop)
print(f"Fast Version Time: {(fast_result / loop):.8f} sec")
Slow Version Time: 0.00035998 sec
Fast Version Time: 0.00000078 sec
5.グローバル変数をローカル変数に入れる
ループ内で何度も使用するグローバル変数やモジュールの関数は、ループに入る前にローカル変数に代入(エイリアス化)して使用すべきです。
Pythonの変数解決は「ローカル」→「グローバル」→「組み込み」の順で行われます。関数内のローカル変数は配列のインデックスアクセス(LOAD_FAST)で済みますが、
グローバル変数は辞書検索(LOAD_GLOBAL)が必要です。特に回数が多いループでは、この検索コストにより低速化を招く場合があります。[5]
import timeit
import math
def calculate_roots_with_global(n):
res = []
for i in range(n):
res.append(math.sqrt(i))
return res
def calculate_roots_with_local(n):
res = []
sqrt = math.sqrt
append = res.append
for i in range(n):
append(sqrt(i))
return res
loop = 5
number = 1000000
slow_result = timeit.timeit("calculate_roots_with_global(number)", globals=globals(), number=loop)
print(f"Slow Version Time: {(slow_result / loop):.4f} sec")
fast_result = timeit.timeit("calculate_roots_with_local(number)", globals=globals(), number=loop)
print(f"Fast Version Time: {(fast_result / loop):.4f} sec")
Slow Version Time: 0.0458 sec
Fast Version Time: 0.0389 sec
6.組み込み関数を使う
ここにきてかなり当たり前に近い内容かもしれませんが、自分でforループなどで計算を行うのではなく、Pythonの組み込み関数を使用すべきです。
Pythonコードで書いたループは、インタプリタ上でバイトコードとして一つずつ実行されるためオーバーヘッドがあります。
一方、sumなどの組み込み関数はC言語で実装されており、ループ処理そのものがC言語レベルの速度で実行されるため高速です。[6]
import timeit
def sum_with_for_loop(data):
total = 0
for x in data:
total += x
return total
def sum_with_built_in_functions(data):
total = sum(data)
return total
loop = 5
data = range(1000000)
slow_result = timeit.timeit("sum_with_for_loop(data)", globals=globals(), number=loop)
print(f"Slow Version Time: {(slow_result / loop):.4f} sec")
fast_result = timeit.timeit("sum_with_built_in_functions(data)", globals=globals(), number=loop)
print(f"Fast Version Time: {(fast_result / loop):.4f} sec")
Slow Version Time: 0.0163 sec
Fast Version Time: 0.0072 sec
7.itertools.chainを使う
複数のリストを順番に処理したい場合、+演算子で新しいリストを作るのではなく、itertools.chainを使用すべきです。
例えばリストの結合にlist1 + list2を実行した際、両方のリストの要素をコピーして新しいリストを作成するため、メモリ消費とコピー時間がかかります。
itertools.chainはデータをコピーせず、リスト間をまたいで順番にアクセスするイテレータを作成するため、初期化コストがほぼゼロでメモリも消費しません。[7]
import timeit
import itertools
def combine_list_with_operator(list1, list2):
result_list = list1 + list2
return result_list
def combine_list_with_chain(list1, list2):
result_list = itertools.chain(list1, list2)
return result_list
loop = 5
list1 = [i for i in range(500000)]
list2 = [i for i in range(500000, 1000000)]
# このpointでは出力する桁数を変えています
slow_result = timeit.timeit("combine_list_with_operator(list1, list2)", globals=globals(), number=loop)
print(f"Slow Version Time: {(slow_result / loop):.8f} sec")
fast_result = timeit.timeit("combine_list_with_chain(list1, list2)", globals=globals(), number=loop)
print(f"Fast Version Time: {(fast_result / loop):.8f} sec")
Slow Version Time: 0.00701807 sec
Fast Version Time: 0.00000099 sec
実演
ということで冒頭紹介したコードと上記の改善を施したコードで実行速度を比較してみます。
import math
import timeit
from collections import deque
from itertools import chain
data_source_1 = list(range(500000))
data_source_2 = list(range(500000, 1000000))
block_list_data = [x * 100 for x in range(10000)]
def slow_processing(src1, src2, block_list):
# [Point 7] 巨大なリストをコピーして新規作成している
all_data = src1 + src2
valid_data = []
# [Point 3] forループでlist作成している
for item in all_data:
# [Point 1] itemが含まれるかlistを利用し確認している
if item not in block_list:
valid_data.append(item)
processed_results = []
while len(valid_data) > 0:
# [Point 4] リストをキューとして使い、先頭削除ごとに全要素をシフトしている
val = valid_data.pop(0)
# [Point 5] ループのたびにmathモジュールからsqrtを検索している
root_val = math.sqrt(val)
processed_results.append(root_val)
total_val = 0
# [Point 6] forループで合計の計算をしている
for x in processed_results:
total_val += x
# [Point 2] 文字列結合を += で行っている
report = ""
for i in range(1000): # 文字列結合のデモ用に先頭1000件のみ
report += str(processed_results[i]) + ","
return total_val, report
def fast_processing(src1, src2, block_list):
# [Point 1] リストをセットに変換し、ハッシュ検索を可能にする
block_set = set(block_list)
# [Point 7] itertools.chain でイテレータとして扱う
all_data_iter = chain(src1, src2)
# [Point 3] リスト内包表記を使用 ※Point1のblock_setを使用
valid_data = [item for item in all_data_iter if item not in block_set]
# [Point 4] dequeを使用し、popleft()で削除を行う
queue = deque(valid_data)
processed_results = []
# [Point 5] 何度も呼ぶメソッドをローカル変数にキャッシュ
local_sqrt = math.sqrt
local_append = processed_results.append
while queue:
val = queue.popleft()
local_append(local_sqrt(val))
# [Point 6] 組み込み関数sumを使用
total_val = sum(processed_results)
# [Point 2] リスト内包表記で文字列化し、joinで一括メモリ確保・結合、先頭1000件を取得
# 厳密に言えば遅い関数のreportと比較して末尾にカンマがつかないという違いはあります
report = ",".join([str(x) for x in processed_results[:1000]])
return total_val, report
loop = 5
slow_result = timeit.timeit("slow_processing(data_source_1, data_source_2, block_list_data)", globals=globals(), number=loop)
print(f"Slow Version Time: {(slow_result / loop):.4f} sec")
fast_result = timeit.timeit("fast_processing(data_source_1, data_source_2, block_list_data)", globals=globals(), number=loop)
print(f"Fast Version Time: {(fast_result / loop):.4f} sec")
実行した結果がこちらです。
Slow Version Time: 130.5239 sec
Fast Version Time: 0.0806 sec
元のコードはアンチパターンの寄せ集めですので、この実験での定量性にそれほど大きい意味はないと考えていますが、
上記の改善を実施するだけで1000倍程度は高速になっていることがわかります。
また、どの処理にどれだけ時間がかかっているかも確認してみます。
各処理の確認にはline_profilerを利用しました。
確認結果は下記です。これを見ると最初に提示したアンチパターンのコードでは
特にPoint 4とPoint 1の内容が処理に時間がかかっている原因であることがわかります。
Timer unit: 1e-06 s
Total time: 136.578 s
File: speed_check.py
Function: slow_processing at line 12
# 行数 実行回数 処理時間 平均処理時間 処理時間割合 実行コード
Line # Hits Time Per Hit % Time Line Contents
==============================================================
12 @profile
13 def slow_processing(src1, src2, block_list):
14 # [Point 7] 巨大なリストをコピーして新規作成している
15 1 5758.7 5758.7 0.0 all_data = src1 + src2
16
17 1 2.1 2.1 0.0 valid_data = []
18
19 # [Point 3] forループでlist作成している
20 1000001 271940.3 0.3 0.2 for item in all_data:
21
22 # [Point 1] itemが含まれるかlistを利用し確認している
23 1000000 39246986.3 39.2 28.7 if item not in block_list:
24 990000 296312.5 0.3 0.2 valid_data.append(item)
25
26 1 1.3 1.3 0.0 processed_results = []
27 990001 382809.9 0.4 0.3 while len(valid_data) > 0:
28 # [Point 4] リストをキューとして使い、先頭削除ごとに全要素をシフトしている
29 990000 95019638.7 96.0 69.6 val = valid_data.pop(0)
30
31 # [Point 5] ループのたびにmathモジュールからsqrtを検索している
32 990000 612237.4 0.6 0.4 root_val = math.sqrt(val)
33 990000 324658.6 0.3 0.2 processed_results.append(root_val)
34
35 1 1.0 1.0 0.0 total_val = 0
36
37 # [Point 6] forループで合計の計算をしている
38 990001 210188.6 0.2 0.2 for x in processed_results:
39 990000 206174.3 0.2 0.2 total_val += x
40
41 # [Point 2] 文字列結合を += で行っている
42 1 1.4 1.4 0.0 report = ""
43 1001 220.9 0.2 0.0 for i in range(1000): # 文字列結合のデモ用に先頭1000件のみ
44 1000 621.1 0.6 0.0 report += str(processed_results[i]) + ","
45 1 6.0 6.0 0.0 return total_val, report
Total time: 0.684865 s
File: speed_check.py
Function: fast_processing at line 48
# 行数 実行回数 処理時間 平均処理時間 処理時間割合 実行コード
Line # Hits Time Per Hit % Time Line Contents
==============================================================
48 @profile
49 def fast_processing(src1, src2, block_list):
50 # [Point 1] リストをセットに変換し、ハッシュ検索を可能にする
51 1 211.8 211.8 0.0 block_set = set(block_list)
52
53 # [Point 7] itertools.chain でイテレータとして扱う
54 1 3.4 3.4 0.0 all_data_iter = chain(src1, src2)
55
56 # [Point 3] リスト内包表記を使用 ※Point1のblock_setを使用
57 1 25897.0 25897.0 3.8 valid_data = [item for item in all_data_iter if item not in block_set]
58
59 # [Point 4] dequeを使用し、popleft()で削除を行う
60 1 6262.9 6262.9 0.9 queue = deque(valid_data)
61 1 2.5 2.5 0.0 processed_results = []
62
63 # [Point 5] 何度も呼ぶメソッドをローカル変数にキャッシュ
64 1 3.2 3.2 0.0 local_sqrt = math.sqrt
65 1 2.3 2.3 0.0 local_append = processed_results.append
66
67 990001 211779.0 0.2 30.9 while queue:
68 990000 200828.9 0.2 29.3 val = queue.popleft()
69 990000 234395.0 0.2 34.2 local_append(local_sqrt(val))
70
71 # [Point 6] 組み込み関数sumを使用
72 1 5136.8 5136.8 0.8 total_val = sum(processed_results)
73
74 # [Point 2] リスト内包表記で文字列化し、joinで一括メモリ確保・結合、先頭1000件を取得
75 # 厳密に言えば遅い関数のreportと比較して末尾にカンマがつかないという違いはあります
76 1 338.9 338.9 0.0 report = ",".join([str(x) for x in processed_results[:1000]])
77 1 3.4 3.4 0.0 return total_val, report
まとめ
もちろん、可読性の維持など様々な制約により改善策を自然に取り入れることが難しい場合も多々あるかとは思います。
また、今回ご紹介した方法は万能薬ではなく、場合によっては効果が見られない / 逆効果になりうるのも事実です。
例えば今回支配的だったPoint 4ですが、listをdequeする処理が入るため、listの要素数などによってはlistをdequeに変換する処理が支配的となり、逆効果になる場合もあります。
その他のPointについても他ブログで様々な議論がありますので、いくつかご紹介させていただきます。
とはいえ少しの工夫を行うことで、実行速度面でかなり改善することもあるかと思っております。本記事が何かのご参考になれば幸いです。
この記事を書いた人
太田 圭祐
2023年キャリア入社 エンジニア
こんにちは
Discussion