Y a-t-il un avantage de vitesse d'analyse ou d'utilisation de la mémoire à l'utilisation de HDF5 pour le stockage de grands tableaux (au lieu de fichiers binaires plats)?

Je traite de grands tableaux 3D, que j'ai souvent besoin de découper de différentes manières pour effectuer une variété d'analyses de données. Un "cube" typique peut être ~100GB (et deviendra probablement plus grand dans le futur)

Il semble que le format de fichier recommandé typique pour les grands ensembles de données en python est d'utiliser HDF5 (h5py ou pytables). Ma question Est: y a-t-il un avantage d'utilisation de la vitesse ou de la mémoire à utiliser HDF5 pour stocker et analyser ces cubes sur les stocker dans de simples fichiers binaires plats? Est HDF5 plus approprié pour les données tabulaires, par opposition aux grands tableaux comme ce que je travaille avec? Je vois que HDF5 peut fournir une bonne compression, mais je suis plus intéressé par la vitesse de traitement et le traitement du débordement de mémoire.

Je veux fréquemment analyser un seul grand sous-ensemble du cube. Un inconvénient de pytables et h5py est qu'il semble que lorsque je prends une tranche du tableau, je récupère toujours un tableau numpy, en utilisant de la mémoire. Cependant, si je découpe un memmap numpy d'un fichier binaire plat, je peux obtenir un vue, qui conserve les données sur le disque. Il semble donc que je puisse analyser plus facilement des secteurs spécifiques de mes données sans surcharger ma mémoire.

J'ai exploré à la fois pytables et h5py, et je n'ai pas vu l'avantage de l'un ou l'autre jusqu'à présent pour mon but.

62
demandé sur Caleb 2014-12-30 21:00:27

1 réponses

Avantages HDF5: organisation, flexibilité, interopérabilité

Certains des principaux avantages de HDF5 sont sa structure hiérarchique (similaire aux dossiers / fichiers), les métadonnées arbitraires facultatives stockées avec chaque élément, et sa flexibilité (par exemple la compression). Cette structure organisationnelle et le stockage des métadonnées peuvent sembler triviaux, mais il est très utile dans la pratique.

Un autre avantage de HDF est que les ensembles de données peuvent être de taille fixe ou de taille flexible. Par conséquent, il est facile d'ajouter des données à un ensemble de données volumineux sans avoir à créer une nouvelle copie entière.

De plus, HDF5 est un format standardisé avec des bibliothèques disponibles pour presque toutes les langues, donc partager vos données sur disque entre, disons Matlab, Fortran, R, C et Python est très facile avec HDF. (Pour être juste, ce n'est pas trop difficile avec un grand tableau binaire, aussi longtemps que vous êtes au courant de L'ordre C vs. F et connaissez la forme, le dtype, etc. du tableau stocké.)

Avantages HDF pour un grand tableau: E/S Plus Rapides d'une tranche arbitraire

Tout comme le TL / DR: pour un tableau 3D de ~8 Go, la lecture d'une tranche" complète " le long de n'importe quel axe a pris ~20 secondes avec un ensemble de données HDF5 en morceaux, et 0,3 seconde (meilleur cas) à plus de trois heures (pire cas) pour un tableau mémmappé des mêmes données.

Au-delà des choses énumérées ci-dessus, il y a un autre gros avantage à un format de données" en morceaux " * sur disque tel que HDF5: lire une tranche arbitraire (l'accent sur l'arbitraire) généralement être beaucoup plus rapide, car les données sur le disque sont plus contiguës en moyenne.

*(HDF5 ne doit pas être un format de données en morceaux. Il prend en charge le chunking, mais ne l'exige pas. En fait, la valeur par défaut pour créer un ensemble de données dans h5py n'est pas de couper, si je me souviens bien.)

Fondamentalement, votre meilleure vitesse de lecture du disque et votre pire vitesse de lecture du disque pour une tranche donnée de votre jeu de données seront assez proches avec un jeu de données HDF en morceaux (en supposant que vous avez choisi un morceau raisonnable taille ou laisser une bibliothèque choisir un pour vous). Avec un tableau binaire simple, Le Meilleur des cas est plus rapide, mais le pire des cas est beaucoup pire.

Une mise en garde, si vous avez un SSD, vous ne remarquerez probablement pas une énorme différence de vitesse de lecture/écriture. Avec un disque dur régulier, cependant, les lectures séquentielles sont beaucoup, beaucoup plus rapides que les lectures aléatoires. (c'est-à-dire Qu'un disque dur ordinaire a un temps seek long.) HDF a toujours un avantage sur un SSD, mais c'est plus dû à ses autres fonctionnalités (par exemple les métadonnées, organisation, etc) qu'en raison de la vitesse brute.


Tout d'abord, pour dissiper la confusion, l'accès à un ensemble de données h5py renvoie un objet qui se comporte de manière assez similaire à un tableau numpy, mais ne charge pas les données en mémoire jusqu'à ce qu'elles soient tranchées. (Similaire à memmap, mais pas identique.) Jetez un oeil à la h5py introduction pour plus d'informations.

Le découpage de l'ensemble de données chargera un sous-ensemble des données en mémoire, mais vous voudrez probablement en faire quelque chose, à quoi point vous en aurez besoin en mémoire de toute façon.

Si vous voulez faire des calculs hors du cœur, vous pouvez assez facilement pour les données tabulaires avec pandas ou pytables. C'est possible avec h5py (plus agréable pour les grands tableaux N-D), mais vous devez descendre à un niveau inférieur et gérer l'itération vous-même.

Cependant, l'avenir des calculs hors cœur de type numpy est Blaze. voir si vous voulez vraiment prendre cette route.


Le " unchunked" affaire

Tout d'abord, considérons un tableau 3D ordonné en C écrit sur le disque (je vais le simuler en appelant arr.ravel() et en imprimant le résultat, pour rendre les choses plus visibles):

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

Les valeurs seraient stockées sur le disque séquentiellement comme indiqué sur la ligne 4 ci-dessous. (Ignorons les détails du système de fichiers et la fragmentation pour le moment.)

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

Dans le meilleur des cas, prenons une tranche le long du premier axe. Notez que ce ne sont que les 36 premières valeurs du tableau. Ce sera un très lecture rapide! (une recherche, une lecture)

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

De même, la tranche suivante le long du premier axe sera juste les 36 valeurs suivantes. Pour lire une tranche complète le long de cet axe, nous n'avons besoin que d'une opération seek. Si tout ce que nous allons lire Est différentes tranches le long de cet axe, alors c'est la structure de fichier parfaite.

Cependant, considérons le pire des cas: une tranche le long du dernier axe.

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

Lire cette tranche, nous avons besoin de 36 cherche et 36 lit, car toutes les valeurs sont séparées sur le disque. Aucun d'eux n'est adjacent!

Cela peut sembler assez mineur, mais à mesure que nous arrivons à des tableaux de plus en plus grands, le nombre et la taille des opérations seek augmentent rapidement. Pour un tableau 3D de grande taille (~10 Go) stocké de cette manière et lu via memmap, la lecture d'une tranche complète le long de l'axe "pire" peut facilement prendre des dizaines de minutes, même avec du matériel moderne. En même temps, une tranche le long du meilleur axe peut prendre moins d'une seconde. Pour des raisons de simplicité, je suis ne montrant que des tranches "complètes" le long d'un seul axe, mais la même chose se produit exactement avec des tranches arbitraires de n'importe quel sous-ensemble des données.

Incidemment, il existe plusieurs formats de fichiers qui en profitent et stockent essentiellement trois copies deénormes tableaux 3D sur disque: un en ordre C, Un en ordre F et un à l'intermédiaire entre les deux. (Un exemple de ceci est le format D3D de Geoprobe, bien que je ne sois pas sûr qu'il soit documenté n'importe où.) Qui se soucie si la taille finale du fichier est 4 TB, de stockage est pas cher! La chose folle à ce sujet est que parce que le cas d'utilisation principal est d'extraire une seule sous-tranche dans chaque direction, les lectures que vous voulez faire sont très, très rapides. Il fonctionne très bien!


Le cas simple "chunked"

Disons que nous stockons des "morceaux" 2x2x2 du tableau 3D sous forme de blocs contigus sur le disque. En d'autres termes, quelque chose comme:

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

Ainsi, les données sur le disque ressembleraient à 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])

Et juste pour montrer qu'ils sont 2x2x2 les blocs de arr, notez que ce sont les 8 premières valeurs de chunked:

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

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

Pour lire dans n'importe quelle tranche le long d'un axe, nous lisions en 6 ou 9 morceaux contigus (deux fois plus de données que nécessaire) et ne conservions que la partie que nous voulions. C'est un maximum de cas pire de 9 Cherche vs un maximum de 36 cherche pour la version non-chunked. (Mais le meilleur des cas est toujours 6 Cherche vs 1 pour le tableau memmapped.) Parce que les lectures séquentielles sont très rapides par rapport aux recherches, cela réduit considérablement le temps nécessaire pour lire un sous-ensemble arbitraire en mémoire. Encore une fois, cet effet devient plus grand avec des tableaux plus grands.

HDF5 prend cela quelques pas plus loin. Les morceaux n'ont pas besoin d'être stockés de manière contiguë, et ils sont indexés par un b-Tree. En outre, ils ne doivent pas avoir la même taille sur le disque, de sorte que la compression peut être appliquée à chaque morceau.


Tableaux en morceaux avec h5py

Par défaut, h5py ne crée pas de fichiers HDF groupés sur le disque (je pense pytables fait, en revanche). Cependant, si vous spécifiez chunks=True lors de la création de l'ensemble de données, vous obtiendrez un tableau en morceaux sur le disque.

Comme un exemple rapide et minimal:

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

Notez que chunks=True indique à h5py de choisir automatiquement une taille de morceau pour nous. Si vous en savez plus sur votre cas d'utilisation le plus courant, vous pouvez optimiser la taille/la forme du bloc en spécifiant un tuple de forme (par exemple (2,2,2) dans l'exemple simple ci-dessus). Cela vous permet de rendre les lectures le long d'un axe particulier plus efficaces ou d'optimiser pour les lectures/écritures d'une certaine taille.


Comparaison des performances d'E/S

Juste pour souligner le point, comparons la lecture en tranches à partir d'un ensemble de données HDF5 en morceaux et d'un grand tableau 3D (~8 Go), ordonné par Fortran, contenant les mêmes données exactes.

J'ai effacé tous les caches du système d'exploitation entre chaque exécution, donc nous voyons la performance "froide".

Pour chaque type de fichier, Nous allons tester la lecture dans un X-slice "plein" le long du premier axe et un Z-slize" plein " le long du dernier axe. Pour le tableau memmapped ordonné par Fortran, la tranche " x "est le pire des cas, et la tranche" z " est le meilleur cas.

Le code utilisé est dans un gist (y compris la création du fichier hdf). Je ne peux pas facilement partager les données utilisées ici, mais vous pouvez les simuler par un tableau de zéros de la même forme (621, 4991, 2600) et type np.uint8.

Le chunked_hdf.py ressemble à ceci:

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 est similaire, mais un peu plus de complexité pour s'assurer que les tranches sont réellement chargés en mémoire (par défaut, un autre tableau memmapped serait retourné, ce qui ne serait pas une comparaison entre pommes et pommes).

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

Regardons d'abord les performances 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

Une tranche x "complète" et une tranche z" complète " prennent à peu près le même temps (~20sec). Considérant que c'est un tableau 8GB, ce n'est pas trop mal. La plupart du temps

Et si nous comparons cela aux temps de tableau memmapped (c'est Fortran-ordered: une "z-slice" est le meilleur cas et une "X-slice" est le pire des cas.):

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

Oui, vous avez bien lu. 0.3 secondes pour une direction de tranche et ~3.5 heures pour l'autre.

Le temps de découper dans la direction "x" est loin plus long que le temps qu'il faudrait pour charger l'ensemble du tableau 8GB en mémoire et sélectionner la tranche que nous voulions! (Encore une fois, c'est un tableau ordonné par Fortran. Le timing de tranche X/z opposé serait le cas pour un tableau ordonné en C.)

Cependant, si nous voulons toujours prenez une tranche le long de la direction du meilleur des cas, le grand tableau binaire sur le disque est très bon. (~0,3 sec!)

Avec un tableau memmapped, vous êtes coincé avec cette divergence d'E/S (ou peut-être que l'anisotropie est un meilleur terme). Cependant, avec un ensemble de données HDF en blocs, vous pouvez choisir la taille de chunk de telle sorte que l'accès soit égal ou optimisé pour un cas d'utilisation particulier. Il vous donne beaucoup plus de flexibilité.

En résumé

Espérons que cela aidera à éclaircir une partie de votre question, à tous les prix. HDF5 a beaucoup d'autres avantages par rapport aux memmaps "raw", mais je n'ai pas de place pour les développer tous ici. La Compression peut accélérer certaines choses (les données avec lesquelles je travaille ne bénéficient pas beaucoup de la compression, donc je l'utilise rarement), et la mise en cache au niveau du système d'exploitation joue souvent plus bien avec les fichiers HDF5 qu'avec les memmaps "raw". Au-delà de cela, HDF5 est un format de conteneur vraiment fantastique. Il vous donne beaucoup de flexibilité dans la gestion de vos données, et peut être utilisé à partir de plus ou moins toute programmation langue.

Globalement, essayez-le et voyez si cela fonctionne bien pour votre cas d'utilisation. Je pense que vous pourriez être surpris.

113
répondu Joe Kington 2017-04-13 12:36:27