web-dev-qa-db-fra.com

Y a-t-il un avantage en termes de vitesse d'analyse ou d'utilisation de la mémoire à utiliser HDF5 pour le stockage sur une grande baie (au lieu de fichiers binaires plats)?

Je traite de grands tableaux 3D, que je dois souvent découper de différentes manières pour effectuer diverses analyses de données. Un "cube" typique peut mesurer environ 100 Go (et deviendra probablement plus grand à l'avenir)

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

Je souhaite fréquemment analyser un seul grand sous-ensemble du cube. Un inconvénient des pytables et de 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 tranche une memmap numpy d'un fichier binaire plat, je peux obtenir une vue, qui conserve les données sur le disque. Il semble donc que je puisse plus facilement analyser des secteurs spécifiques de mes données sans surcharger ma mémoire.

J'ai exploré à la fois les pytables et h5py, et je n'ai pas encore vu les avantages de l'un ou de l'autre pour mon objectif.

77
Caleb

Avantages du 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 ils sont très utiles 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 grand ensemble de données 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, par exemple Matlab, Fortran, R, C et Python est très facile avec HDF. (Pour être honnête, ce n'est pas trop difficile avec un grand tableau binaire, aussi longtemps que vous êtes au courant de l'ordre C contre F et que vous connaissez la forme, le type, etc. du tableau stocké.)

Avantages HDF pour une large baie: E/S plus rapides d'une tranche arbitraire

Tout comme le TL/DR: Pour une matrice 3D de ~ 8 Go, la lecture d'une tranche "complète" le long de n'importe quel axe prenait ~ 20 secondes avec un jeu de données HDF5 fragmenté et 0,3 seconde (dans le meilleur des cas) à sur trois heures (dans le pire des cas) pour un tableau mappé des mêmes données.

Au-delà des éléments énumérés ci-dessus, il existe un autre grand avantage à un format de données sur disque "en morceaux" * tel que HDF5: la lecture d'une tranche arbitraire (accentuation sur arbitraire) sera généralement beaucoup plus rapide, car les données sur disque sont plus contiguës sur moyenne.

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

Fondamentalement, votre vitesse de lecture de disque dans le meilleur des cas et votre vitesse de lecture de disque dans le pire des cas pour une tranche donnée de votre ensemble de données seront assez proches avec un ensemble de données HDF fragmenté (en supposant que vous avez choisi une taille de bloc raisonnable ou que la bibliothèque en choisisse une pour vous). Avec un simple tableau binaire, 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 dans la vitesse de lecture/écriture. Cependant, avec un disque dur normal, les lectures séquentielles sont beaucoup, beaucoup plus rapides que les lectures aléatoires. (C'est-à-dire qu'un disque dur ordinaire a une longue durée de seek.) HDF a toujours un avantage sur un SSD, mais c'est plus en raison de ses autres fonctionnalités (par exemple les métadonnées, l'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 découpées. (Similaire à memmap, mais pas identique.) Jetez un œil à la h5py Introduction pour plus d'informations.

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

Si vous voulez faire des calculs hors 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 du noyau numpy est Blaze. Jetez-y un œil si vous voulez vraiment emprunter cette route.


L'affaire "sans échec"

Tout d'abord, considérez 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 de manière séquentielle, comme indiqué sur la ligne 4 ci-dessous. (Ignorons pour le moment les détails et la fragmentation du système de fichiers.)

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 une 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 prochaine tranche le long du premier axe ne sera que les 36 valeurs suivantes. Pour lire une tranche complète le long de cet axe, nous n'avons besoin que d'une seule opération seek. Si tout ce que nous allons lire, c'est plusieurs 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]])

Pour lire cette tranche, nous avons besoin de 36 recherches et 36 lectures, 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 une matrice 3D de grande taille (~ 10 Go) stockée de cette manière et lue via memmap, la lecture d'une tranche complète le long du "pire" axe peut facilement prendre des dizaines de minutes, même avec du matériel moderne. Dans le même temps, une tranche le long du meilleur axe peut prendre moins d'une seconde. Par souci de simplicité, je n'affiche que les tranches "pleines" le long d'un seul axe, mais la même chose se produit exactement avec les tranches arbitraires de n'importe quel sous-ensemble de données.

Soit dit en passant, plusieurs formats de fichiers en profitent et stockent essentiellement trois copies de énorme tableaux 3D sur le disque: un en ordre C, un en ordre F et un intermédiaire entre les deux. (Un exemple de ceci est le format D3D de Geoprobe, bien que je ne suis pas sûr qu'il soit documenté nulle part.) Peu importe si la taille finale du fichier est de 4 To, le stockage est bon marché! La chose folle à ce sujet est que, parce que le cas d'utilisation principal extrait une seule sous-tranche dans chaque direction, les lectures que vous souhaitez effectuer sont très, très rapides. Il fonctionne très bien!


Le cas simple "en morceaux"

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'il s'agit de blocs 2x2x2 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 devons lire soit 6 ou 9 morceaux contigus (deux fois plus de données que nous avons besoin), puis ne conserver que la partie que nous voulions. C'est un maximum de 9 recherches dans le pire des cas contre un maximum de 36 recherches pour la version non fragmentée. (Mais le meilleur cas est toujours 6 recherches contre 1 pour le tableau mappé.) Les lectures séquentielles étant 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 va plus loin. Les morceaux ne doivent pas être stockés de manière contiguë, et ils sont indexés par un B-Tree. De plus, ils ne doivent pas nécessairement avoir la même taille sur le disque, de sorte que la compression peut être appliquée à chaque bloc.


Tableaux fragmentés avec h5py

Par défaut, h5py Ne crée pas de fichiers HDF fragmentés sur le disque (je pense que pytables le 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 fragmenté sur le disque.

Comme 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 bloc pour nous. Si vous en savez plus sur votre cas d'utilisation le plus courant, vous pouvez optimiser la taille/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 les lectures/écritures d'une certaine taille.


Comparaison des performances d'E/S

Pour souligner ce point, comparons la lecture en tranches à partir d'un ensemble de données HDF5 fragmenté et d'un grand tableau 3D (~ 8 Go), commandé 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 les performances "à froid".

Pour chaque type de fichier, nous testerons la lecture dans une tranche X "pleine" le long du premier axe et une tranche Z "pleine" 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 des 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 tapez 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 a une touche plus complexe pour garantir que les tranches sont réellement chargées dans la mémoire (par défaut, un autre tableau memmapped serait retourné, ce qui ne serait pas un apples-to- comparaison de 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()

Voyons 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 "pleine" et une tranche Z "pleine" prennent environ le même temps (~ 20 s). Étant donné qu'il s'agit d'une baie de 8 Go, ce n'est pas trop mal. Le plus souvent

Et si nous comparons cela aux temps du tableau mappé (il est ordonné par Fortran: une "tranche z" est le meilleur des cas et une "tranche x" 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, tu l'as bien lu. 0,3 seconde pour une direction de tranche et ~ 3,5 heures pour l'autre.

Le temps de découpage dans la direction "x" est loin plus long que le temps qu'il faudrait pour charger l'ensemble du tableau de 8 Go en mémoire et sélectionner le découpage que nous voulions! (Encore une fois, il s'agit d'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 prendre une tranche dans le 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 différence d'E/S (ou peut-être que l'anisotropie est un meilleur terme). Cependant, avec un jeu de données HDF fragmenté, vous pouvez choisir la taille de segment de sorte que l'accès soit égal ou optimisé pour un cas d'utilisation particulier. Cela vous donne beaucoup plus de flexibilité.

En résumé

J'espère que cela aidera à clarifier une partie de votre question, en tout cas. HDF5 a de nombreux autres avantages par rapport aux memmaps "bruts", mais je n'ai pas la possibilité de 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 les 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 "bruts". 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 n'importe quel langage de programmation.

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

133
Joe Kington