薄いブログ

技術の雑多なことを書く場所

Pythonのbytesはsubscriptでコピーが発生する

TL;DR

Pythonのbytesはsubscriptでmemmoveする。

背景

Pythonで簡単なバイナリのパーサーを書いていたとき、そんなに入力が大きくない(1.6MB程度)のに処理に時間がかかることに気づいた。

調査

まず当該プログラムをcProfileを使ってプロファイリングしてみた。しかし、原因の特定につながるような情報は得られなかった。 次にInstrumentsを用いてプロファイリングを行った。

Instrumentsの結果
Instrumentsの結果

bytes_subscript から呼ばれている memmove がボトルネックになっていることがわかった。

考察

思い当たる節はバイナリのパーサーで特定のオフセットから指定したバイト数取り出す際に普段書かない方法で書いていたことだった。

普段は以下のように書く。

b[offset:offset+size]

しかし今回は試作のプログラムで以下のように書いていた。

b[offset:][:size]

両者は基本的に同じ結果を返す。 ではなぜ後者の書き方をしていたかと言うと例でもそうだが下のコードのほうが短い。 特に今回のプログラムはoffsetの部分に変数同士の計算した式が書いてありコピーするのが億劫だったため後者の書き方をしていた。

そして部分バイト列を取り出す際に内部的にviewのようなものが作られると思い込んでいたため問題ないと判断したが誤りだった。

bytes は subscript でコピーが発生するので前者はsizeバイトのmemmove, 後者はlen(b)-offset+sizeバイトのmemmoveが実行される。 この部分バイト列を取り出す処理が頻繁に呼び出されていたため問題になった。

結論

b[offset:offset+size]

の形に書き直したところ10倍ほど速くなった。

Pythonのbytesのsubscriptはmemmoveが発生することを把握しておくと良い。

再現するスクリプトと結果

b = ("b"*1000000).encode()

def before():
  for i in range(len(b)):
    b[i:][:2]

def after():
  for i in range(len(b)):
    b[i:i+2]

if __name__ == "__main__":
  import timeit
  print("before:", timeit.timeit(before, number=1))
  print("after:", timeit.timeit(after, number=1))

出力:

before: 20.346244589949492
after: 0.10111576301278546