[Python] バイナリファイルを少しずつ読む


import numpy as np

まとめ

  • np.fromfileのオプションcountoffsetを使うと,バイナリファイルの一部だけを読み込むことが出来る.
  • 小さなファイルではこれらを使わずに一括して読む方が速い
  • 大きなファイルでチャンクごとに何らかの処理を挟む場合は,分割して読む方が速くなる場合がある

問題

(特に大きなサイズの)配列をnp.fromfile()で一括して読み込むと遅い.
少しずつ読み込むためには?

解決策

np.fromfilecountオプション,offsetオプションを使う.
これらは単位が異なるので注意[1]

  • count: 読み込むデータの大きさ(バイトサイズではなく,取得したい配列の大きさと一致)
  • offset: ファイルを読み始めるバイト位置
n0, n1 = 5, 24 # テスト用の配列の大きさ
#dtype  = 'float' # 8バイト
dtype  = 'float32' # 4バイト,どちらでもOK.
path   = './test.bin' # ファイルを書き出す/読み込むパス

np.random.seed(0)
orgarr = np.random.rand(n0, n1).astype(dtype)
orgarr.tofile(path)

# 一括して読む
arr0 = np.fromfile(path, dtype=dtype).reshape((n0, n1))

# n1サイズの配列をn0回読む+一括版と比較
bytesize = np.dtype(dtype).itemsize # datatypeごとのバイトサイズを取得
for i in range(n0):
    # 1回ごとにn1サイズの配列を読むのでcount=n1
    # offsetにはスキップしたいバイトサイズ
    _arr1 = np.fromfile(path, dtype=dtype, count=n1, offset=n1*i*bytesize)
    print((arr0[i] == _arr1).all(), _arr1.mean())
'''
結果は
True 0.6104534
True 0.48200977
True 0.40383717
True 0.4136633
True 0.5744956
'''

例外(?)処理

countoffsetが実際のファイルと整合していなくても,np.fromfileはエラーを出さない.

  • countが実際のファイルサイズをオーバーすると,実際にデータが存在したところまでの大きさの配列を返す
  • offsetが既に実際のファイルサイズをオーバーしていた場合,配列の形状は(0,)

従ってファイル末尾の処理(StopIteration等)には,実際のファイルを取得しておく必要がある.
(e.g., os.path.getsize(path)でファイルのバイトサイズが返る)

_arr1 = np.fromfile(path, dtype=dtype, count=n1+3, offset=n1*(n0-1)*bytesize)
print(_arr1.shape) # (24,)
print((arr0[-1] == _arr1).all()) # True
_arr1 = np.fromfile(path, dtype=dtype, count=n1, offset=(n1*(n0-1)+n1//2)*bytesize)
print(_arr1.shape) # (12,)
_arr1 = np.fromfile(path, dtype=dtype, count=n1, offset=n1*n0*bytesize)
print(_arr1.shape) # (0,)
_arr1 = np.fromfile(path, dtype=dtype, count=n1, offset=n1*(n0+1)*bytesize)
print(_arr1.shape) # (0,)

実行速度: ファイルを分割して読むだけでは遅い!

しかし,ファイルを分割して読む→結合する(以下「分割」),という使い方は,一括して読む(以下「一括」)よりも遅くなる.
以下を比較する.

# パターン1
arr0 = np.fromfile(path, dtype=dtype).reshape((n0, n1))

# パターン2
bytesize = np.dtype(dtype).itemsize
arr1 = []
for i in range(n0):
    _arr1 = np.fromfile(path, dtype=dtype, count=n1, offset=n1*i*bytesize)
    arr1 += [_arr1]
arr1 = np.stack(arr1)

JupyterLabの%%timeitによる結果:

# 上記で出力した(5, 24) float32のファイル
一括: 270 µs ± 45.7 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
分割: 1.25 ms ± 146 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

# 別の(365, 1036800=720*1440) float32のファイル
一括: 3.76 s ± 1.28 s per loop (mean ± std. dev. of 7 runs, 1 loop each)
分割: 6.33 s ± 1.95 s per loop (mean ± std. dev. of 7 runs, 1 loop each)

この分割は(1) 大きなファイルについて (2) 各チャンクごとに何か処理を行うときに効果的.
例えばチャンクごとの最大値を取得するような以下を考えると,

# パターン1
arr0 = arr0.max(axis=1)
# パターン2
arr1 += [_arr1.max()]

小さなファイルではやはり一括して読む方が効率的だが,
大きなファイルについては大小関係が逆転する.

# 上記で出力した(5, 24) float32のファイル
一括: 329 µs ± 92.4 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
分割: 1.71 ms ± 136 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

# 別の(365, 720*1440) float32のファイル
一括: 5.32 s ± 3.03 s per loop (mean ± std. dev. of 7 runs, 1 loop each)
分割: 949 ms ± 196 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

他手法との比較

open('rb')struct.unpackの組み合わせもよく挙げられるが,実行速度上で不利らしい[2]

参考文献

[1] Numpy: numpy.fromfile
[2] stackoverflow: Fastest way to read in and slice binary data files in Python