(フラットバイナリファイルの代わりに)大規模アレイストレージにHDF5を使用することには、分析速度やメモリ使用の利点がありますか?


96

大きな3Dアレイを処理しています。さまざまなデータ分析を行うために、さまざまな方法でスライスする必要があることがよくあります。典型的な「キューブ」は最大100GBになる可能性があります(将来的にはさらに大きくなる可能性があります)

Pythonの大規模なデータセットの一般的な推奨ファイル形式は、HDF5(h5pyまたはpytables)を使用することです。私の質問は、HDF5を使用してこれらのキューブを単純なフラットバイナリファイルに保存するよりも保存および分析することで、速度やメモリ使用のメリットはありますか?HDF5は、操作しているような大きな配列とは対照的に、表形式のデータに適していますか?HDF5は適切な圧縮を提供できることがわかりますが、処理速度とメモリオーバーフローの処理により関心があります。

キューブの1つの大きなサブセットのみを分析したいことがよくあります。pytablesとh5pyの両方の欠点の1つは、配列のスライスを取得すると、常にメモリを使い切ってnumpy配列が返されることです。ただし、フラットバイナリファイルの面倒なmemmapをスライスすると、ディスクにデータを保持するビューを取得できます。したがって、メモリを使い果たすことなく、データの特定のセクターをより簡単に分析できるようです。

私はpytablesとh5pyの両方を調査しましたが、これまでのところ、どちらの目的でも自分の目的に役立つことはありません。


1
HDFは「チャンク」ファイル形式です。平均すると、データセットの任意のスライスの読み取りがはるかに高速になります。memmapは高速の最良のケースになりますが、非常に非常に遅い最悪のケースになります。h5pyのようなデータセットに適していpytablesます。また、メモリ内のnumpy配列を返しh5pyませ。代わりに、1のように動作するものを返しますが、メモリに読み込まれません(memmapped配列と同様)。私はより完全な答えを書いています(それを終えないかもしれません)が、うまくいけば、このコメントはその間に少し役立つでしょう。
Joe Kington、2014

ありがとう。h5pyがmemmapに類似したデータセットを返すことに同意します。しかし、h5pyデータセットのスライスを実行すると、numpy配列が返されます。これは、データが不必要にメモリに入れられたことを意味します(?)memmampは、可能であればビューを元のmemmapに戻します。つまり、type(cube)を与えh5py._hl.dataset.Datasetます。ながらtype(cube[0:1,:,:])与えるnumpy.ndarray
カレブ2014

ただし、平均読み取り時間についてのあなたのポイントは興味深いです。
カレブ2014

4
I / Oボトルネックがある場合、多くの場合、圧縮によって実際の読み取り/書き込みパフォーマンスが向上します(特にBLOSCやLZOなどの高速圧縮ライブラリを使用)。これにより、必要なI / O帯域幅が減少し、CPUサイクルが余分に増加します。 。あなたは見たいかもしれません、このページ PyTables HDF5ファイルを使用して読み書きのパフォーマンスの最適化に関する多くの情報を持っています、。
ali_m 2014

2
「私はフラットバイナリファイルのnumpyのmemmapをスライスならば、私は、ディスク上のデータを保持するビュー、取得することができます」 -本当かもしれないが、あなたが実際にしたい場合は行う遅かれ早かれ、その配列内の値で何かをそれらをRAMにロードする必要があります。メモリマッピングされた配列はカプセル化を提供するだけなので、データが読み取られるタイミングや、システムのメモリ容量を超えるかどうかを正確に考慮する必要はありません。状況によっては、memmapされた配列のネイティブキャッシュ動作は、非常に最適ではない可能性があります。
ali_m

回答:


158

HDF5の利点:編成、柔軟性、相互運用性

HDF5の主な利点のいくつかは、階層構造(フォルダー/ファイルと同様)、各アイテムと共に保存されるオプションの任意のメタデータ、およびその柔軟性(圧縮など)です。この組織構造とメタデータストレージは簡単に聞こえるかもしれませんが、実際には非常に役立ちます。

HDFのもう1つの利点は、データセットを固定サイズまたは柔軟なサイズにできることです。したがって、新しいコピー全体を作成しなくても、大きなデータセットにデータを簡単に追加できます。

さらに、HDF5はほぼすべての言語で利用可能なライブラリを備えた標準化された形式であるため、たとえば、Matlab、Fortran、R、C、Pythonの間でディスク上のデータを共有することは、HDFを使用すると非常に簡単です。(公平にするために、CとFの順序付けを理解し、格納されている配列の形状、dtypeなどを知っている限り、大きなバイナリ配列でもそれほど難しくありません。)

大規模アレイのHDFの利点:任意のスライスの高速I / O

TL / DRと同様に:〜8GB 3Dアレイの場合、任意の軸に沿った「完全な」スライスの読み取りには、チャンクHDF5データセットで〜20秒かかり、0.3秒(最良の場合)から3時間以上(最悪の場合)同じデータのマップされた配列。

上記のもの以外に、HDF5などの「チャンク」*オンディスクデータ形式には、別の大きな利点があります。オンディスクデータがより連続しているため、任意のスライス(任意を強調)の読み取りは通常、はるかに高速になります。平均。

*(HDF5はチャンクデータ形式である必要はありません。チャンクはサポートされていますが、必須ではありません。実際、データセットを作成する際のデフォルトはh5py、正しくリコールすればチャンクになりません。)

基本的に、データセットの特定のスライスに対する最良の場合のディスク読み取り速度と最悪の場合のディスク読み取り速度は、チャンクHDFデータセットとかなり近くなります(適切なチャンクサイズを選択するか、ライブラリが自動的に選択する場合)。単純なバイナリ配列を使用すると、最良の場合は速くなりますが、最悪の場合ははるかに悪くなります。

SSDを使用している場合、1つの注意点として、読み取り/書き込み速度の大きな違いに気付かないでしょう。ただし、通常のハードドライブでは、シーケンシャルリードはランダムリードよりもはるかに高速です。(つまり、通常のハードドライブには長いseek時間がかかります。)HDFにはSSDでの利点がありますが、生の速度よりも他の機能(メタデータ、編成など)によるものです。


まず、混乱を解消するために、h5pyデータセットにアクセスすると、numpy配列とほぼ同じように動作するオブジェクトが返されますが、スライスされるまでデータはメモリに読み込まれません。(memmapと同様ですが、同一ではない。)を見て持ってh5py紹介詳細については、を。

データセットをスライスすると、データのサブセットがメモリに読み込まれますが、おそらくそれを使って何かを実行したいのですが、その時点でとにかくメモリで必要になります。

コア外の計算を行いたい場合は、pandasまたはを使用すると、表形式のデータをかなり簡単に作成できますpytables。これはh5py(大きなNDアレイではより良い)で可能ですが、少し低いレベルにドロップダウンして、自分で反復を処理する必要があります。

ただし、numpyのようなコア外計算の将来はBlazeです。あなたが本当にその道を進みたいなら、それを見てください


「unchunked」ケース

まず、ディスクに書き込まれた3D Cオーダーの配列を考えます(arr.ravel()結果を呼び出して出力することでシミュレートして、物を見やすくします)。

In [1]: import numpy as np

In [2]: arr = np.arange(4*6*6).reshape(4,6,6)

In [3]: arr
Out[3]:
array([[[  0,   1,   2,   3,   4,   5],
        [  6,   7,   8,   9,  10,  11],
        [ 12,  13,  14,  15,  16,  17],
        [ 18,  19,  20,  21,  22,  23],
        [ 24,  25,  26,  27,  28,  29],
        [ 30,  31,  32,  33,  34,  35]],

       [[ 36,  37,  38,  39,  40,  41],
        [ 42,  43,  44,  45,  46,  47],
        [ 48,  49,  50,  51,  52,  53],
        [ 54,  55,  56,  57,  58,  59],
        [ 60,  61,  62,  63,  64,  65],
        [ 66,  67,  68,  69,  70,  71]],

       [[ 72,  73,  74,  75,  76,  77],
        [ 78,  79,  80,  81,  82,  83],
        [ 84,  85,  86,  87,  88,  89],
        [ 90,  91,  92,  93,  94,  95],
        [ 96,  97,  98,  99, 100, 101],
        [102, 103, 104, 105, 106, 107]],

       [[108, 109, 110, 111, 112, 113],
        [114, 115, 116, 117, 118, 119],
        [120, 121, 122, 123, 124, 125],
        [126, 127, 128, 129, 130, 131],
        [132, 133, 134, 135, 136, 137],
        [138, 139, 140, 141, 142, 143]]])

次の4行目に示すように、値はディスクに順番に格納されます。(ここでは、ファイルシステムの詳細と断片化を無視しましょう。)

In [4]: arr.ravel(order='C')
Out[4]:
array([  0,   1,   2,   3,   4,   5,   6,   7,   8,   9,  10,  11,  12,
        13,  14,  15,  16,  17,  18,  19,  20,  21,  22,  23,  24,  25,
        26,  27,  28,  29,  30,  31,  32,  33,  34,  35,  36,  37,  38,
        39,  40,  41,  42,  43,  44,  45,  46,  47,  48,  49,  50,  51,
        52,  53,  54,  55,  56,  57,  58,  59,  60,  61,  62,  63,  64,
        65,  66,  67,  68,  69,  70,  71,  72,  73,  74,  75,  76,  77,
        78,  79,  80,  81,  82,  83,  84,  85,  86,  87,  88,  89,  90,
        91,  92,  93,  94,  95,  96,  97,  98,  99, 100, 101, 102, 103,
       104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116,
       117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129,
       130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143])

最良のシナリオでは、最初の軸に沿ってスライスを取ってみましょう。これらは配列の最初の36個の値にすぎないことに注意してください。これは非常に高速な読み取りなります!(1回のシーク、1回の読み取り)

In [5]: arr[0,:,:]
Out[5]:
array([[ 0,  1,  2,  3,  4,  5],
       [ 6,  7,  8,  9, 10, 11],
       [12, 13, 14, 15, 16, 17],
       [18, 19, 20, 21, 22, 23],
       [24, 25, 26, 27, 28, 29],
       [30, 31, 32, 33, 34, 35]])

同様に、最初の軸に沿った次のスライスは、次の36個の値になります。この軸に沿った完全なスライスを読み取るために必要なseek操作は1つだけです。読み取るのがこの軸に沿ったさまざまなスライスである場合、これは完璧なファイル構造です。

ただし、最悪のシナリオを考えてみましょう。最後の軸に沿ったスライスです。

In [6]: arr[:,:,0]
Out[6]:
array([[  0,   6,  12,  18,  24,  30],
       [ 36,  42,  48,  54,  60,  66],
       [ 72,  78,  84,  90,  96, 102],
       [108, 114, 120, 126, 132, 138]])

このスライスを読み込むには、すべての値がディスク上で分離されているため、36回のシークと36回の読み込みが必要です。それらのどれも隣接していません!

これはかなりマイナーに思えるかもしれませんが、配列が大きくなるseekにつれて、操作の数とサイズは急速に増加します。この方法で格納され、経由で読み込まれる大規模な(約10Gb)3D配列の場合memmap場合、最新のハードウェアを使用しても、「最悪」軸に沿った完全なスライスの読み取りには数十分かかることがあります。同時に、最適な軸に沿ったスライスの所要時間は1秒未満です。簡単にするために、1つの軸に沿った「完全な」スライスのみを示していますが、データの任意のサブセットの任意のスライスでもまったく同じことが起こります。

ちなみに、これを利用して、巨大な 3Dアレイの3つのコピーをディスクに保存するファイル形式がいくつかあります。(これの例は、GeoprobeのD3D形式ですが、どこに記載されているかはわかりません。)最終的なファイルサイズが4 TBかどうかを気にする人は、ストレージが安価です!その奇妙な点は、主なユースケースが各方向に1つのサブスライスを抽出するため、実行する読み取りが非常に高速であることです。とてもうまくいきます!


単純な「チャンク」ケース

3Dアレイの2x2x2の「チャンク」をディスク上の連続したブロックとして保存するとします。つまり、次のようなものです。

nx, ny, nz = arr.shape
slices = []
for i in range(0, nx, 2):
    for j in range(0, ny, 2):
        for k in range(0, nz, 2):
            slices.append((slice(i, i+2), slice(j, j+2), slice(k, k+2)))

chunked = np.hstack([arr[chunk].ravel() for chunk in slices])

したがって、ディスク上のデータは次のようになりますchunked

array([  0,   1,   6,   7,  36,  37,  42,  43,   2,   3,   8,   9,  38,
        39,  44,  45,   4,   5,  10,  11,  40,  41,  46,  47,  12,  13,
        18,  19,  48,  49,  54,  55,  14,  15,  20,  21,  50,  51,  56,
        57,  16,  17,  22,  23,  52,  53,  58,  59,  24,  25,  30,  31,
        60,  61,  66,  67,  26,  27,  32,  33,  62,  63,  68,  69,  28,
        29,  34,  35,  64,  65,  70,  71,  72,  73,  78,  79, 108, 109,
       114, 115,  74,  75,  80,  81, 110, 111, 116, 117,  76,  77,  82,
        83, 112, 113, 118, 119,  84,  85,  90,  91, 120, 121, 126, 127,
        86,  87,  92,  93, 122, 123, 128, 129,  88,  89,  94,  95, 124,
       125, 130, 131,  96,  97, 102, 103, 132, 133, 138, 139,  98,  99,
       104, 105, 134, 135, 140, 141, 100, 101, 106, 107, 136, 137, 142, 143])

そして、それらがの2x2x2ブロックであることを示すためにarr、これらがの最初の8つの値であることに注意してくださいchunked

In [9]: arr[:2, :2, :2]
Out[9]:
array([[[ 0,  1],
        [ 6,  7]],

       [[36, 37],
        [42, 43]]])

軸に沿ったスライスを読み取るには、6つまたは9つの連続したチャンク(必要なデータの2倍)を読み取り、必要な部分のみを保持します。これは、チャンクされていないバージョンの最悪の場合、最大9回のシークに対して最大36回のシークです。(しかし、最良のケースは、シークに比べてシークが1であるのに対し、シークは6シークです。)シーケンシャル読み取りはシークと比較して非常に高速であるため、任意のサブセットをメモリに読み取るのにかかる時間を大幅に短縮します。この場合も、この効果は配列が大きくなると大きくなります。

HDF5はこれを数ステップ先に進めます。チャンクは連続して保存する必要はなく、Bツリーによってインデックスが付けられます。さらに、ディスク上で同じサイズである必要はないため、各チャンクに圧縮を適用できます。


チャンク配列 h5py

デフォルトでh5pyは、ディスク上にチャンクHDFファイルを作成しません(pytables対照的に、そうだと思います)。chunks=Trueただし、データセットの作成時に指定すると、ディスク上にチャンク配列が作成されます。

簡単な最小限の例として:

import numpy as np
import h5py

data = np.random.random((100, 100, 100))

with h5py.File('test.hdf', 'w') as outfile:
    dset = outfile.create_dataset('a_descriptive_name', data=data, chunks=True)
    dset.attrs['some key'] = 'Did you want some metadata?'

chunks=True伝えh5py自動的に私たちのためにチャンクサイズを選択します。最も一般的な使用例について詳しく知っている場合は、形状のタプルを指定することにより、チャンクのサイズ/形状を最適化できます(たとえば(2,2,2)、上記の簡単な例で)。これにより、特定の軸に沿った読み取りをより効率的にしたり、特定のサイズの読み取り/書き込み用に最適化したりできます。


I / Oパフォーマンスの比較

要点を強調するために、チャンクHDF5データセットからのスライスの読み取りと、まったく同じデータを含む大規模な(〜8GB)Fortran順序3D配列を比較してみましょう。

各実行の間にすべてのOSキャッシュクリアしたので、「コールド」パフォーマンスが表示されています。

ファイルタイプごとに、最初の軸に沿った「完全な」xスライスと最後の軸に沿った「完全な」zスライスの読み取りをテストします。Fortran順メムマップ配列の場合、「x」スライスが最悪のケースであり、「z」スライスが最良のケースです。

使用されているコードは要旨ですhdfファイルの作成を含む)。ここで使用するデータを簡単に共有することはできませんが、同じ形状(621, 4991, 2600)およびタイプ)のゼロの配列によってそれをシミュレートできますnp.uint8

これchunked_hdf.pyは次のようになります。

import sys
import h5py

def main():
    data = read()

    if sys.argv[1] == 'x':
        x_slice(data)
    elif sys.argv[1] == 'z':
        z_slice(data)

def read():
    f = h5py.File('/tmp/test.hdf5', 'r')
    return f['seismic_volume']

def z_slice(data):
    return data[:,:,0]

def x_slice(data):
    return data[0,:,:]

main()

memmapped_array.pyは似ていますが、スライスが実際にメモリに確実に読み込まれるようにするために少し複雑です(デフォルトでは、別のmemmapped配列が返されますが、これはリンゴ同士の比較にはなりません)。

import numpy as np
import sys

def main():
    data = read()

    if sys.argv[1] == 'x':
        x_slice(data)
    elif sys.argv[1] == 'z':
        z_slice(data)

def read():
    big_binary_filename = '/data/nankai/data/Volumes/kumdep01_flipY.3dv.vol'
    shape = 621, 4991, 2600
    header_len = 3072

    data = np.memmap(filename=big_binary_filename, mode='r', offset=header_len,
                     order='F', shape=shape, dtype=np.uint8)
    return data

def z_slice(data):
    dat = np.empty(data.shape[:2], dtype=data.dtype)
    dat[:] = data[:,:,0]
    return dat

def x_slice(data):
    dat = np.empty(data.shape[1:], dtype=data.dtype)
    dat[:] = data[0,:,:]
    return dat

main()

最初にHDFパフォーマンスを見てみましょう。

jofer at cornbread in ~ 
$ sudo ./clear_cache.sh

jofer at cornbread in ~ 
$ time python chunked_hdf.py z
python chunked_hdf.py z  0.64s user 0.28s system 3% cpu 23.800 total

jofer at cornbread in ~ 
$ sudo ./clear_cache.sh

jofer at cornbread in ~ 
$ time python chunked_hdf.py x
python chunked_hdf.py x  0.12s user 0.30s system 1% cpu 21.856 total

「フル」xスライスと「フル」zスライスには、ほぼ同じ時間がかかります(〜20秒)。これが8GBアレイであることを考えると、それほど悪くはありません。ほとんどの時間

そして、これをメムマップされた配列時間と比較すると(Fortran順です:「zスライス」が最良の場合であり、「xスライス」が最悪の場合です)。

jofer at cornbread in ~ 
$ sudo ./clear_cache.sh

jofer at cornbread in ~ 
$ time python memmapped_array.py z
python memmapped_array.py z  0.07s user 0.04s system 28% cpu 0.385 total

jofer at cornbread in ~ 
$ sudo ./clear_cache.sh

jofer at cornbread in ~ 
$ time python memmapped_array.py x
python memmapped_array.py x  2.46s user 37.24s system 0% cpu 3:35:26.85 total

はい、あなたはその権利を読みます。1つのスライス方向で0.3秒、もう1つのスライス方向で約3.5 時間

「x」方向にスライスする時間は、8GBアレイ全体をメモリにロードして必要なスライスを選択するのにかかる時間よりもはるかに長くなります。(これも、Fortranで配列された配列です。Cで配列された配列では、x / zスライスのタイミングが逆になります。)

ただし、常にベストケースの方向に沿ってスライスを取りたい場合は、ディスク上の大きなバイナリアレイが非常に適しています。(〜0.3秒!)

memmapped配列では、このI / Oの不一致に悩まされています(または、異方性がより適切な用語です)。ただし、チャンクHDFデータセットでは、アクセスが等しくなるように、または特定のユースケースに最適化されるように、チャンクサイズを選択できます。柔軟性が大幅に向上します。

要約すれば

うまくいけば、とにかくあなたの質問の一部を片付けるのに役立ちます。HDF5には「生の」memmapに比べて他にも多くの利点がありますが、ここではそれらすべてを拡張する余地はありません。圧縮はいくつかのことをスピードアップすることができ(私が扱うデータは圧縮からあまり恩恵を受けないので、私はそれを使用することはめったにありません)、OSレベルのキャッシュはしばしば「生の」memmapよりもHDF5ファイルでうまく機能します。それ以上に、HDF5は本当に素晴らしいコンテナ形式です。それはあなたのデータを管理する上で多くの柔軟性を与え、多かれ少なかれどんなプログラミング言語からも使用することができます。

全体的に試してみて、ユースケースにうまく機能するかどうかを確認してください。びっくりするかもしれませんね。


3
すばらしい答えです。チャンキングレイアウトを一般的なデータアクセスパターンにカスタマイズできることを付け加えておきます。アクセスパターンにかなり予測可能なステンシルサイズがある場合、通常は常にチャンクを選択して、常にほぼ最適な速度を実現できます。
Eelco Hoogendoorn 2015年

2
正解です。チャンクについて言及されていないことの1つは、チャンクキャッシュの影響です。開いている各データセットには独自のチャンクキャッシュがあり、そのデフォルトサイズは1 MBです。これはCのH5Pset_chunk_cache()を使用して調整できます。アクセスパターンについて考えるとき、メモリに保持できるチャンクの数を考慮することは一般に役立ちます。たとえば、キャッシュが8チャンクを保持でき、データセットがスキャン方向に10チャンクである場合、大量のスラッシングが発生し、パフォーマンスはひどいものになります。
Dana Robinson
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.