「Pythonって、たぶんそういう意味じゃない」シリーズ。
普段Pythonを書いていると、list や dict、関数呼び出しの速さを意識することはあまりない。
list[i] は何nsくらいなのか。
dict[key] はどれくらいなのか。
x in list と x in set は、実際にどのくらい違うのか。
計算量としては知っていても、絶対値を見る機会は意外と少ない。
そこで今回は、CPython 3.13.5で基本的な処理を28項目測ってみた。
結果だけ見ると数nsで終わる処理も多かった一方で、list と set のようにデータ構造の違いがそのまま大きな差になっているものもあった。
この記事の値は、今回使った環境での実測値です。
実行時間の絶対値はCPUやOS、Pythonのビルド方法などで変わります。
そのため、この記事では同一環境内で処理同士の傾向を見ることを目的としています。
測定条件
| 項目 | 内容 |
|---|---|
| Python | CPython 3.13.5 |
| OS | Linux x86_64 |
| 測定 | timeit.Timer |
| repeat | 5回 |
| CPU affinity | 1論理CPUに固定 |
| 掲載値 | 5回の中央値 |
| 単位 | ns/op |
各処理は数十万〜数百万回実行し、1回あたりの時間へ換算した。
\mathrm{ns/op}
=
\frac{\mathrm{elapsed\ time}}{\mathrm{number\ of\ operations}}
\times 10^9
測定前には20,000回までのウォームアップも入れている。
timeit のドキュメントでは、外部プロセスなどの影響を受けるため、複数回の結果のうち最小値を見る考え方も紹介されています。
今回は「この環境で普段どの程度の値が出るか」を見たかったため、本文では中央値を使っています。後半には min / max も載せています。
まず結果を眺める
まずは、今回測った中から代表的なものを抜き出してみる。
| 操作 | median |
|---|---|
o.x (__slots__) |
8.98 ns |
list[i] |
10.34 ns |
len(list) |
11.96 ns |
int + int |
14.72 ns |
x in set |
15.32 ns |
| 関数呼び出し | 16.69 ns |
dict[key] |
17.45 ns |
| メソッド呼び出し | 18.71 ns |
dict.get(key) |
19.78 ns |
"hello" + "world" |
23.82 ns |
| classインスタンス生成 | 40.96 ns |
| f-string(整数2個) | 85.88 ns |
raise → except
|
92.32 ns |
100要素のlistで中央付近を検索 |
274.63 ns |
ただ、10nsと11nsを比べて「前者の方が速い」と言うのは少し危ない。
今回の list[i] だけを見ても、
min 9.36 ns
median 10.34 ns
max 11.89 ns
くらいには揺れている。
数ns程度の差は、あまり細かく読みすぎない方がよさそうだ。
list と set の in
今回の結果で一番差が分かりやすかったのは、inによる存在確認だった。
lst = list(range(100))
s = set(range(100))
50 in lst
50 in s
結果は次のようになった。
| 操作 | median | min〜max |
|---|---|---|
50 in s |
15.32 ns | 15.13〜15.47 ns |
50 in lst |
274.63 ns | 251.72〜293.19 ns |
今回の条件では約17.9倍の差になる。
\frac{274.63}{15.32} \approx 17.93
list は先頭から値を探していくのに対して、set はハッシュテーブルを使う。
もちろん、だからといって list を set に置き換えればよいわけではない。
items = [1, 1, 2, 3]
と
items = {1, 2, 3}
では意味が違う。
順序や重複を残したいなら list が必要になる。
ただ、たとえば「ある値が含まれているか」を何度も調べる用途で、重複や順序が必要ないのであれば、set の方が素直なのではないだろうか。
少なくとも今回の条件では、数ns単位の書き方より、こうしたデータ構造の違いの方がずっと大きく効いている。
list[i] と tuple[i]
インデックスアクセスも測ってみた。
lst[50]
t[50]
| 操作 | median | min〜max |
|---|---|---|
list[i] |
10.34 ns | 9.36〜11.89 ns |
tuple[i] |
10.70 ns | 10.08〜11.08 ns |
中央値だけを見ると list の方が0.36ns小さい。
ただ、この差は測定の揺れに埋もれる程度なので、少なくとも今回の結果から「list の方が速い」とは言えないだろう。
tuple を選ぶ理由は、性能よりも、
- 値を変更しない
- 固定された値の組として扱う
- 条件を満たせばハッシュ可能になる
といった意味の方が大きそうだ。
dict[key] と dict.get()
100件の dict から既知のキーを取得した。
d = {str(i): i for i in range(100)}
k = "50"
d[k]
get() も同じ条件で測った。
| 操作 | median |
|---|---|
d[k] |
17.45 ns |
d.get(k) |
19.78 ns |
今回の条件では d[k] の方が少し小さい。
ただし、これも単純な速度比較だけでは決められない。
d["missing"]
なら KeyError が発生するが、
d.get("missing")
なら None が返る。
欲しい挙動が違うので、2ns程度の差を理由に書き換えるものではないだろう。
[] と list()
空の list を作る方法には、
[]
と、
list()
がある。
結果は次のようになった。
| 式 | median |
|---|---|
[] |
14.05 ns |
list() |
24.87 ns |
dict と tuple でも似た傾向が出た。
| 型 | リテラル | コンストラクタ |
|---|---|---|
| list | 14.05 ns | 24.87 ns |
| dict | 14.75 ns | 26.17 ns |
| tuple | 9.09 ns | 12.81 ns |
同じCPython 3.13.5でバイトコードも見てみた。
[] -> BUILD_LIST 0
list() -> LOAD_NAME list -> PUSH_NULL -> CALL 0
{} -> BUILD_MAP 0
dict() -> LOAD_NAME dict -> PUSH_NULL -> CALL 0
() -> RETURN_CONST ()
tuple() -> LOAD_NAME tuple -> PUSH_NULL -> CALL 0
[] や {} は専用命令で処理される一方、list() や dict() には呼び出しが入る。
少なくとも今回の結果とは辻褄が合っているように見える。
ただし () は少し特殊で、空tupleは定数として扱える。
そのため「毎回新しい空tupleを生成する速度」として見るのは違うだろう。
ここで言えるのは、空のコンテナを用意するだけなら、今回のCPythonではリテラル記法の方が軽かった、というところまでです。
list(iterable) のように、コンストラクタを使う意味がある場面まで置き換えられるわけではありません。
関数呼び出し
ほぼ何もしない関数を用意した。
def f():
return 1
f()
結果は 16.69 ns/op だった。
メソッド呼び出しも測った。
class C:
def f(self):
return 1
o = C()
o.f()
| 操作 | median |
|---|---|
| 関数呼び出し | 16.69 ns |
| メソッド呼び出し | 18.71 ns |
今回の単純なケースでは約2ns差だった。
実際の関数では引数処理や処理本体が入るので、この数字をそのまま一般化はできない。
「ほぼ何もしない関数を呼ぶだけでも、このくらいのコストはある」と見るくらいがよさそうだ。
__slots__
普通のclassと __slots__ を使ったclassも比べてみた。
属性参照は、
| 操作 | median |
|---|---|
| 通常class | 9.50 ns |
__slots__ |
8.98 ns |
差は0.52nsしかない。
この程度の差なら、今回の測定ではほぼ同じと見た方がよさそうだ。
一方、空インスタンスの生成は少し差が出た。
class Normal:
pass
class Slotted:
__slots__ = ()
| インスタンス生成 | median |
|---|---|
| 通常class | 40.96 ns |
__slots__ |
31.36 ns |
今回の条件では約23%短い。
ただし、この2つは完全に同じ機能を持つclassではない。
class C:
__slots__ = ()
c = C()
c.value = 1 # AttributeError
__slots__ = () なら任意の属性を追加できない。
速さだけ見て __slots__ を付けるというより、必要な制約と合っているなら使う、くらいが自然ではないだろうか。
メモリ使用量については今回測っていないので、そこは別で見てみたい。
例外処理
try があるだけのケースと、実際に例外を発生させるケースを測った。
まず例外が発生しないもの。
def f():
try:
return 1
except ValueError:
return 0
19.13 ns/op
だった。
次に、実際に ValueError を発生させる。
def f():
try:
raise ValueError
except ValueError:
pass
こちらは 92.32 ns/op だった。
| ケース | median |
|---|---|
| 通常の関数呼び出し | 16.69 ns |
try、例外なし |
19.13 ns |
raise ValueError → except
|
92.32 ns |
今回の条件では、例外なしの try と比べて約4.8倍になる。
\frac{92.32}{19.13} \approx 4.83
ただし、この92nsもかなり単純なケースだ。
- メッセージなしの
ValueError - 同じ関数内でcatch
- 深いスタックを伝播しない
という条件なので、例外処理全般を「92ns」と考えるのは違うだろう。
少なくとも、try があること自体と、実際に例外を起こすことは分けて考えた方がよさそうだ。
文字列
単純な文字列結合も測った。
a = "hello"
b = "world"
a + b
結果は 23.82 ns/op。
一方、整数2個をf-stringに入れる処理は、
a = 123
b = 456
f"{a}:{b}"
で 85.88 ns/op だった。
| 操作 | median |
|---|---|
"hello" + "world" |
23.82 ns |
f"{a}:{b}" |
85.88 ns |
数字だけを見るとかなり差がある。
ただ、これは同じ処理ではない。
後者では整数を文字列へ変換したうえで、: を挟んで新しい文字列を作っている。
そのため「+ の方がf-stringより速い」と結論づけるのは違うのではないだろうか。
microbenchmarkは、数字よりも「何を測っているか」の方が大事になる。
比較しにくかったもの
今回、
[x + 1 for x in data]
は10要素で 165.15 ns、
sum(x + 1 for x in data)
は 296.76 ns だった。
ただし前者はlistを作り、後者はgeneratorから値を取り出して合計している。
結果として得られるものが違うため、これを見て
list comprehensionの方がgeneratorより速い
とは言えない。
数字自体は残しておくが、直接比較する材料にはしない方がよさそうだ。
microbenchmarkでは、同じように見えるコードでも、実際にやっている仕事が違うことがあります。
数字だけを横に並べるより、比較対象が本当に同じ仕事なのかを確認した方がよさそうです。
全28項目
全測定結果(median / min / max)
| Benchmark | median | min | max |
|---|---|---|---|
slots attribute read |
8.98 | 8.84 | 9.34 |
tuple literal ()
|
9.09 | 9.02 | 10.82 |
| attribute read | 9.50 | 9.08 | 9.70 |
| list index | 10.34 | 9.36 | 11.89 |
| tuple index | 10.70 | 10.08 | 11.08 |
len(list) |
11.96 | 11.36 | 12.29 |
tuple() |
12.81 | 11.33 | 13.92 |
list literal []
|
14.05 | 13.44 | 16.38 |
| float add | 14.60 | 12.05 | 15.44 |
| int add | 14.72 | 14.11 | 15.54 |
dict literal {}
|
14.75 | 12.52 | 15.87 |
| set contains | 15.32 | 15.13 | 15.47 |
| function call | 16.69 | 16.04 | 17.84 |
| dict lookup | 17.45 | 15.56 | 18.68 |
| method call | 18.71 | 18.02 | 23.57 |
| exception / no raise | 19.13 | 17.62 | 20.57 |
dict.get() |
19.78 | 18.79 | 22.11 |
| string concat | 23.82 | 23.46 | 24.20 |
list() |
24.87 | 24.78 | 32.91 |
dict() |
26.17 | 25.40 | 28.97 |
set() |
29.31 | 28.23 | 32.60 |
| slots instance | 31.36 | 30.06 | 32.94 |
| class instance | 40.96 | 39.91 | 58.28 |
| f-string / 2 ints | 85.88 | 78.50 | 95.25 |
raise/catch ValueError
|
92.32 | 91.33 | 96.08 |
| list comprehension / 10 elements | 165.15 | 154.58 | 272.99 |
| list membership / 100 elements・50を検索 | 274.63 | 251.72 | 293.19 |
generator + sum() / 10 elements |
296.76 | 287.53 | 347.22 |
単位はすべて ns/op。
いくつかmaxが大きく跳ねている項目もある。
たとえばlist comprehensionは、
median 165.15 ns
max 272.99 ns
まで開いている。
こういう結果を見ると、中央値だけを見て細かい差を断定するのはやはり難しそうだ。
再現用コード
benchmark.py
import os
import statistics
import timeit
try:
os.sched_setaffinity(0, {0})
except (AttributeError, OSError):
pass
BENCHMARKS = [
("int add", "a+b", "a=123;b=456", 3_000_000),
("float add", "a+b", "a=1.25;b=2.5", 3_000_000),
("list index", "lst[i]", "lst=list(range(100));i=50", 3_000_000),
("tuple index", "t[i]", "t=tuple(range(100));i=50", 3_000_000),
(
"dict lookup",
"d[k]",
'd={str(i):i for i in range(100)};k="50"',
3_000_000,
),
(
"dict get",
"d.get(k)",
'd={str(i):i for i in range(100)};k="50"',
2_000_000,
),
("set contains", "x in s", "s=set(range(100));x=50", 3_000_000),
(
"list contains (100 elems, hit at 50)",
"x in lst",
"lst=list(range(100));x=50",
500_000,
),
(
"function call",
"f()",
"def f():\n return 1",
3_000_000,
),
(
"method call",
"o.f()",
"class C:\n def f(self):\n return 1\no=C()",
2_000_000,
),
(
"attribute read",
"o.x",
"class C: pass\no=C();o.x=1",
3_000_000,
),
(
"slots attr read",
"o.x",
'class C:\n __slots__=("x",)\no=C();o.x=1',
3_000_000,
),
("f-string (2 ints)", 'f"{a}:{b}"', "a=123;b=456", 700_000),
("str concat", "a+b", 'a="hello";b="world"', 2_000_000),
(
"list comprehension 10",
"[x+1 for x in data]",
"data=list(range(10))",
200_000,
),
(
"generator sum 10",
"sum(x+1 for x in data)",
"data=list(range(10))",
200_000,
),
(
"exception no raise",
"f()",
"def f():\n"
" try:\n"
" return 1\n"
" except ValueError:\n"
" return 0",
2_000_000,
),
(
"raise/catch ValueError",
"f()",
"def f():\n"
" try:\n"
" raise ValueError\n"
" except ValueError:\n"
" pass",
500_000,
),
("list() create", "list()", "", 2_000_000),
("tuple() create", "tuple()", "", 3_000_000),
("dict() create", "dict()", "", 2_000_000),
("set() create", "set()", "", 2_000_000),
("list literal []", "[]", "", 3_000_000),
("dict literal {}", "{}", "", 3_000_000),
("tuple literal ()", "()", "", 4_000_000),
("class instance", "C()", "class C: pass", 700_000),
(
"slots instance",
"C()",
"class C:\n __slots__=()",
700_000,
),
("len(list)", "len(x)", "x=list(range(100))", 3_000_000),
]
rows = []
for name, statement, setup, loops in BENCHMARKS:
timer = timeit.Timer(statement, setup)
timer.timeit(number=min(loops, 20_000))
results = [
elapsed / loops * 1e9
for elapsed in timer.repeat(
repeat=5,
number=loops,
)
]
rows.append(
(
name,
statistics.median(results),
min(results),
max(results),
loops,
)
)
for name, median, minimum, maximum, loops in rows:
print(
f"{name:<38} "
f"{median:9.2f} ns/op "
f"[{minimum:.2f}, {maximum:.2f}]"
)
数nsの差をどう見るか
ここまで数ns単位の結果を見てきた。
たとえば、
[]
は14.05nsで、
list()
は24.87nsだった。
差は約11ns。
仮に100万回積み重なれば、
11\ \mathrm{ns} \times 1,000,000
=
11\ \mathrm{ms}
くらいになる。
無視できない場面もあるだろうが、普通のアプリケーションではDBやネットワーク、ファイルI/Oの方がずっと大きいことも多い。
それより今回気になったのは、list と set の存在確認のように、データ構造を変えるだけで桁が変わるケースだった。
最適化を考えるなら、まず遅い場所を測り、I/Oなのか、アルゴリズムやデータ構造なのかを見た方がよいのではないだろうか。数ns単位の書き方を詰めるのは、その後でも遅くない。
測ってみて思ったこと
今回測ってみると、Pythonの基本操作には10〜20ns前後で終わるものがかなり多かった。
list[i] は約10ns、dict[key] は約17ns、関数呼び出しも約17nsだった。
その一方で、100要素の list から中央付近を探す処理は約275ns、set なら約15nsだった。
細かな構文差を追うより、何をどのデータ構造で持つかの方が大きく効く場面は多そうだ。
だから、今回の表を「速い書き方一覧」として覚えるより、
同じように見える処理でも、内部ではかなり違うことをしている
くらいに捉えるのがよい気がしている。
次は list、tuple、dict、set、deque、dataclass、NamedTuple、__slots__ などを、速度だけでなくメモリや用途も含めて比べてみたい。
「この処理も測ってほしい」などあれば、コメントで教えてもらえると嬉しいです。