Esiste una velocità di analisi o un vantaggio nell'utilizzo della memoria nell'utilizzo di HDF5 per l'archiviazione di grandi array (invece di file binari flat)?


96

Sto elaborando array 3D di grandi dimensioni, che spesso ho bisogno di suddividere in vari modi per eseguire una varietà di analisi dei dati. Un tipico "cubo" può essere ~ 100 GB (e probabilmente diventerà più grande in futuro)

Sembra che il formato di file consigliato tipico per set di dati di grandi dimensioni in python sia quello di utilizzare HDF5 (h5py o pytables). La mia domanda è: c'è qualche vantaggio in termini di velocità o di utilizzo della memoria nell'utilizzo di HDF5 per archiviare e analizzare questi cubi rispetto alla memorizzazione in semplici file binari flat? HDF5 è più appropriato per i dati tabulari, al contrario di array di grandi dimensioni come quello con cui sto lavorando? Vedo che HDF5 può fornire una buona compressione, ma sono più interessato alla velocità di elaborazione e alla gestione dell'overflow della memoria.

Spesso voglio analizzare solo un grande sottoinsieme del cubo. Uno svantaggio sia di pytables che di h5py è che quando prendo una fetta dell'array, ottengo sempre un array numpy, utilizzando la memoria. Tuttavia, se affetto una numpy memmap di un file binario flat, posso ottenere una visualizzazione, che mantiene i dati su disco. Quindi, sembra che io possa analizzare più facilmente settori specifici dei miei dati senza sovraccaricare la mia memoria.

Ho esplorato sia pytables che h5py, e finora non ho visto i vantaggi di nessuno dei due per il mio scopo.


1
HDF è un formato di file "chunked". In media, ti darà letture molto più veloci per una porzione arbitraria del tuo set di dati. Una memmap avrà un caso migliore veloce, ma un caso peggiore molto, molto lento. h5pyè più adatto a set di dati come il tuo di pytables. Inoltre, h5pynon non restituire una matrice NumPy in memoria. Invece restituisce qualcosa che si comporta come tale, ma non viene caricato in memoria (simile a un memmappedarray). Sto scrivendo una risposta più completa (potrei non finirla), ma spero che questo commento nel frattempo aiuti un po '.
Joe Kington

Grazie. Sono d'accordo che h5py restituisce un set di dati simile a una memmap. Ma, se esegui una sezione del set di dati h5py, restituisce un array numpy, che credo (?) Significa che i dati sono stati inseriti in memoria inutilmente. Un memmamp restituisce una visualizzazione alla memmap originale, se possibile. In altre parole: type(cube)h5py._hl.dataset.Dataset. Mentre type(cube[0:1,:,:])numpy.ndarray.
Caleb

Tuttavia, la tua osservazione sul tempo medio di lettura è interessante.
Caleb

4
Se si ha un collo di bottiglia I / O, in molti casi la compressione può effettivamente migliorare le prestazioni di lettura / scrittura (specialmente utilizzando librerie di compressione veloce come BLOSC e LZO), poiché riduce la larghezza di banda di I / O richiesta al costo di alcuni cicli CPU aggiuntivi . Potresti dare un'occhiata a questa pagina , che contiene molte informazioni sull'ottimizzazione delle prestazioni di lettura / scrittura utilizzando i file HDF5 di PyTables.
ali_m

2
"se affetto una numpy memmap di un file binario flat, posso ottenere una vista, che mantiene i dati su disco" - potrebbe essere vero, ma se in realtà vuoi fare qualcosa con i valori in quell'array, prima o poi dovrai caricarli nella RAM. Un array mappato in memoria fornisce solo un po 'di incapsulamento in modo da non dover pensare esattamente a quando i dati vengono letti o se supereranno la capacità di memoria del sistema. In alcune circostanze il comportamento nativo della memorizzazione nella cache degli array memmap può essere davvero molto subottimale .
ali_m

Risposte:


159

Vantaggi di HDF5: organizzazione, flessibilità, interoperabilità

Alcuni dei principali vantaggi di HDF5 sono la sua struttura gerarchica (simile a cartelle / file), metadati arbitrari opzionali memorizzati con ogni elemento e la sua flessibilità (ad esempio compressione). Questa struttura organizzativa e l'archiviazione dei metadati possono sembrare banali, ma sono molto utili nella pratica.

Un altro vantaggio di HDF è che i set di dati possono essere di dimensioni fisse o flessibili. Pertanto, è facile aggiungere dati a un set di dati di grandi dimensioni senza dover creare un'intera nuova copia.

Inoltre, HDF5 è un formato standardizzato con librerie disponibili per quasi tutte le lingue, quindi condividere i dati su disco tra, ad esempio Matlab, Fortran, R, C e Python è molto semplice con HDF. (Per essere onesti, non è troppo difficile anche con un grande array binario, purché tu sia a conoscenza dell'ordinamento C contro F e conosci la forma, il dtype, ecc. Dell'array memorizzato.)

Vantaggi HDF per un array di grandi dimensioni: I / O più veloce di uno slice arbitrario

Proprio come TL / DR: per un array 3D da ~ 8 GB, la lettura di una sezione "completa" lungo qualsiasi asse richiedeva circa 20 secondi con un set di dati HDF5 in blocchi e da 0,3 secondi (nel migliore dei casi) a oltre tre ore (nel peggiore dei casi) un array memmap degli stessi dati.

Oltre alle cose elencate sopra, c'è un altro grande vantaggio in un formato di dati su disco "chunked" * come HDF5: la lettura di una slice arbitraria (enfasi sull'arbitrario) sarà tipicamente molto più veloce, poiché i dati su disco sono più contigui su media.

*(HDF5 non deve essere un formato di dati in blocchi. Supporta il raggruppamento in blocchi, ma non lo richiede. In effetti, l'impostazione predefinita per la creazione di un set di dati in h5pynon è il blocco, se ricordo correttamente.)

Fondamentalmente, la velocità di lettura del disco nel caso migliore e la velocità di lettura del disco nel caso peggiore per una data porzione del tuo set di dati saranno abbastanza simili a un set di dati HDF in blocchi (supponendo che tu abbia scelto una dimensione di blocco ragionevole o che una libreria ne scelga uno per te). Con un semplice array binario, il caso migliore è più veloce, ma il caso peggiore è molto peggio.

Un avvertimento, se hai un SSD, probabilmente non noterai un'enorme differenza nella velocità di lettura / scrittura. Con un normale disco rigido, tuttavia, le letture sequenziali sono molto, molto più veloci delle letture casuali. (Ad esempio, un normale disco rigido ha molto seektempo.) HDF ha ancora un vantaggio su un SSD, ma è più dovuto alle sue altre caratteristiche (ad esempio metadati, organizzazione, ecc.) che alla velocità pura.


Prima di tutto, per chiarire la confusione, l'accesso a un h5pyset di dati restituisce un oggetto che si comporta in modo abbastanza simile a un array numpy, ma non carica i dati in memoria finché non vengono tagliati. (Simile a memmap, ma non identico.) Dai un'occhiata h5pyall'introduzione per maggiori informazioni.

Il taglio del set di dati caricherà un sottoinsieme di dati in memoria, ma presumibilmente vuoi farci qualcosa, a quel punto ne avrai comunque bisogno in memoria.

Se vuoi eseguire calcoli out-of-core, puoi abbastanza facilmente per i dati tabulari con pandaso pytables. È possibile con h5py(più bello per i grandi array ND), ma è necessario scendere a un livello inferiore di tocco e gestire l'iterazione da soli.

Tuttavia, il futuro dei calcoli out-of-core di tipo insensibile è Blaze. Dai un'occhiata se vuoi davvero prendere quella strada.


Il caso "unchunked"

Prima di tutto, considera un array 3D C-ordinato scritto su disco (lo simulerò chiamando arr.ravel()e stampando il risultato, per rendere le cose più visibili):

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]]])

I valori verranno memorizzati su disco in sequenza come mostrato nella riga 4 di seguito. (Ignoriamo i dettagli e la frammentazione del filesystem per il momento.)

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])

Nella migliore delle ipotesi, prendiamo una fetta lungo il primo asse. Si noti che questi sono solo i primi 36 valori dell'array. Questa sarà una lettura molto veloce! (una ricerca, una lettura)

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]])

Allo stesso modo, la fetta successiva lungo il primo asse sarà solo i successivi 36 valori. Per leggere una fetta completa lungo questo asse, abbiamo solo bisogno di seekun'operazione. Se tutto ciò che leggeremo sono varie sezioni lungo questo asse, allora questa è la struttura del file perfetta.

Tuttavia, consideriamo lo scenario peggiore: una fetta lungo l'ultimo asse.

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]])

Per leggere questa slice, abbiamo bisogno di 36 ricerche e 36 letture, poiché tutti i valori sono separati su disco. Nessuno di loro è adiacente!

Questo può sembrare piuttosto secondario, ma man mano che arriviamo a array sempre più grandi, il numero e la dimensione delle seekoperazioni crescono rapidamente. Per un array 3D di grandi dimensioni (~ 10Gb) memorizzato in questo modo e letto tramite memmap, la lettura di uno slice completo lungo l'asse "peggiore" può richiedere facilmente decine di minuti, anche con hardware moderno. Allo stesso tempo, una fetta lungo l'asse migliore può richiedere meno di un secondo. Per semplicità, sto solo mostrando le sezioni "complete" lungo un singolo asse, ma la stessa identica cosa accade con le sezioni arbitrarie di qualsiasi sottoinsieme di dati.

Per inciso, ci sono diversi formati di file che sfruttano questo vantaggio e fondamentalmente memorizzano tre copie di enormi array 3D su disco: uno in ordine C, uno in ordine F e uno intermedio tra i due. (Un esempio è il formato D3D di Geoprobe, anche se non sono sicuro che sia documentato da nessuna parte.) Chi se ne frega se la dimensione finale del file è di 4 TB, lo spazio di archiviazione è economico! La cosa pazzesca è che poiché il caso d'uso principale è l'estrazione di una singola sottosezione in ciascuna direzione, le letture che si desidera eseguire sono molto, molto veloci. Funziona molto bene!


Il semplice caso "chunked"

Supponiamo di memorizzare 2x2x2 "blocchi" dell'array 3D come blocchi contigui su disco. In altre parole, qualcosa come:

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])

Quindi i dati su disco sarebbero 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])

E solo per mostrare che sono blocchi 2x2x2 di arr, nota che questi sono i primi 8 valori di chunked:

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

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

Per leggere in qualsiasi fetta lungo un asse, leggiamo in 6 o 9 blocchi contigui (il doppio dei dati di cui abbiamo bisogno) e quindi manterremo solo la parte che volevamo. Si tratta di un massimo di 9 ricerche nel caso peggiore contro un massimo di 36 ricerche per la versione non suddivisa in blocchi. (Ma il caso migliore è ancora 6 seek vs 1 per l'array memmap). Poiché le letture sequenziali sono molto veloci rispetto alle seek, questo riduce significativamente la quantità di tempo necessaria per leggere un sottoinsieme arbitrario in memoria. Ancora una volta, questo effetto diventa maggiore con array più grandi.

HDF5 fa qualche passo in più. I blocchi non devono essere archiviati in modo contiguo e sono indicizzati da un albero B. Inoltre, non devono avere le stesse dimensioni su disco, quindi la compressione può essere applicata a ciascun blocco.


Array in blocchi con h5py

Per impostazione predefinita, h5pynon crea file HDF in blocchi su disco (penso che lo pytablesfaccia, al contrario). Se si specifica chunks=Truedurante la creazione del set di dati, tuttavia, si otterrà un array in blocchi su disco.

Come esempio rapido e minimo:

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?'

Nota che chunks=Truedice h5pydi scegliere automaticamente una dimensione di blocco per noi. Se sai di più sul tuo caso d'uso più comune, puoi ottimizzare la dimensione / forma del blocco specificando una tupla di forma (ad esempio (2,2,2)nel semplice esempio sopra). Ciò consente di rendere più efficienti le letture lungo un particolare asse o di ottimizzarle per letture / scritture di una certa dimensione.


Confronto delle prestazioni di I / O

Solo per enfatizzare il punto, confrontiamo la lettura in sezioni da un set di dati HDF5 in blocchi e un grande array 3D (~ 8 GB) ordinato da Fortran contenente gli stessi dati esatti.

Ho cancellato tutte le cache del sistema operativo tra ogni esecuzione, quindi stiamo assistendo a prestazioni "fredde".

Per ogni tipo di file, testeremo la lettura in una sezione x "completa" lungo il primo asse e in una dimensione z "completa" lungo l'ultimo asse. Per l'array memmap ordinato da Fortran, la sezione "x" è il caso peggiore e la sezione "z" è il caso migliore.

Il codice utilizzato è in un gist (inclusa la creazione del hdffile). Non riesco a condividere facilmente i dati usati qui, ma potresti simularli con una matrice di zeri della stessa forma ( 621, 4991, 2600)e tipo np.uint8.

Si chunked_hdf.pypresenta così:

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è simile, ma ha un tocco in più di complessità per garantire che le fette siano effettivamente caricate in memoria (per impostazione predefinita, memmappedverrebbe restituito un altro array, che non sarebbe un confronto tra mele e mele).

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()

Diamo prima un'occhiata alle prestazioni dell'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

Una x-slice "completa" e una z-slice "completa" impiegano circa la stessa quantità di tempo (~ 20sec). Considerando che si tratta di un array da 8 GB, non è poi così male. La maggior parte delle volte

E se confrontiamo questo con i tempi dell'array memmap (è ordinato da Fortran: uno "z-slice" è il caso migliore e un "x-slice" è il caso peggiore.):

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

Sì, avete letto bene. 0,3 secondi per una direzione di fetta e ~ 3,5 ore per l'altra.

Il tempo necessario per eseguire lo slice nella direzione "x" è molto più lungo del tempo necessario per caricare l'intero array da 8 GB in memoria e selezionare lo slice che volevamo! (Di nuovo, questo è un array ordinato da Fortran. Il tempo di slice x / z opposto sarebbe il caso di un array ordinato da C.)

Tuttavia, se vogliamo sempre prendere una fetta nella direzione migliore, il grande array binario su disco è molto buono. (~ 0,3 sec!)

Con un array memmap, sei bloccato con questa discrepanza I / O (o forse anisotropia è un termine migliore). Tuttavia, con un set di dati HDF in blocchi, è possibile scegliere la dimensione in blocchi in modo che l'accesso sia uguale o ottimizzato per un particolare caso d'uso. Ti dà molta più flessibilità.

In sintesi

Si spera che questo aiuti a chiarire almeno una parte della tua domanda. HDF5 ha molti altri vantaggi rispetto alle memmap "non elaborate", ma non ho spazio per espanderle tutte qui. La compressione può velocizzare alcune cose (i dati con cui lavoro non beneficiano molto della compressione, quindi la uso raramente) e il caching a livello di sistema operativo spesso viene riprodotto più bene con i file HDF5 che con le memmap "non elaborate". Oltre a ciò, HDF5 è un formato contenitore davvero fantastico. Offre molta flessibilità nella gestione dei dati e può essere utilizzato da più o meno qualsiasi linguaggio di programmazione.

Nel complesso, provalo e vedi se funziona bene per il tuo caso d'uso. Penso che potresti essere sorpreso.


3
Bella risposta. Vorrei aggiungere che è possibile personalizzare il layout di suddivisione in blocchi in base al tipico modello di accesso ai dati. Se si accede al pattern ha una dimensione dello stencil abbastanza prevedibile, in genere è possibile scegliere il chunking in modo da ottenere sempre una velocità quasi ottimale.
Eelco Hoogendoorn

2
Bella risposta! Una cosa che non viene menzionata sul chunking è l'effetto della chunk cache. Ogni set di dati aperto ha una propria cache di blocchi, la cui dimensione predefinita è 1 MB, che può essere regolata utilizzando H5Pset_chunk_cache () in C.È generalmente utile considerare quanti blocchi possono essere tenuti in memoria quando si pensa ai modelli di accesso. Se la tua cache può contenere, diciamo, 8 blocchi e il tuo set di dati è di 10 blocchi nella direzione della scansione, ti batterai molto e le prestazioni saranno terribili.
Dana Robinson,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.