Existe uma velocidade de análise ou vantagem de uso de memória ao usar HDF5 para armazenamento de grande array (em vez de arquivos binários simples)?

96

Estou processando grandes matrizes 3D, que frequentemente preciso dividir de várias maneiras para fazer uma variedade de análises de dados. Um "cubo" típico pode ter cerca de 100 GB (e provavelmente ficará maior no futuro)

Parece que o formato de arquivo recomendado típico para grandes conjuntos de dados em python é usar HDF5 (h5py ou pytables). Minha pergunta é: há algum benefício de velocidade ou uso de memória em usar HDF5 para armazenar e analisar esses cubos em vez de armazená-los em arquivos binários simples? O HDF5 é mais adequado para dados tabulares, em oposição a grandes matrizes como o que estou trabalhando? Vejo que o HDF5 pode fornecer boa compactação, mas estou mais interessado na velocidade de processamento e em lidar com o estouro de memória.

Freqüentemente, desejo analisar apenas um grande subconjunto do cubo. Uma desvantagem de pytables e h5py é que, ao pegar uma parte do array, sempre recebo de volta um array numpy, usando toda a memória. No entanto, se eu cortar um memmap numpy de um arquivo binário simples, posso obter uma visualização, que mantém os dados no disco. Portanto, parece que posso analisar mais facilmente setores específicos de meus dados sem sobrecarregar minha memória.

Eu explorei pytables e h5py e não vi os benefícios de nenhum deles até agora para o meu propósito.

Caleb
fonte
1
HDF é um formato de arquivo "fragmentado". Em média, ele fornecerá leituras muito mais rápidas para uma fatia arbitrária de seu conjunto de dados. Um memmap terá um melhor caso rápido, mas um pior caso muito, muito lento. h5pyé mais adequado para conjuntos de dados como o seu do que pytables. Além disso, h5pyse não retornar uma matriz numpy em memória. Em vez disso, ele retorna algo que se comporta como um, mas não é carregado na memória (semelhante a uma memmappedmatriz). Estou escrevendo uma resposta mais completa (pode não terminá-la), mas espero que este comentário ajude um pouco enquanto isso.
Joe Kington
Obrigado. Concordo que h5py retorna um conjunto de dados semelhante a um memmap. Mas, se você fizer uma fatia do conjunto de dados h5py, ele retornará uma matriz numpy, que acredito (?) Significa que os dados foram colocados na memória sem necessidade. Um memmamp retorna uma visão para o memmap original, se possível. Em outras palavras: type(cube)h5py._hl.dataset.Dataset. Enquanto type(cube[0:1,:,:])numpy.ndarray.
Caleb
No entanto, seu ponto sobre o tempo médio de leitura é interessante.
Caleb
4
Se você tiver um gargalo de I / O, em muitos casos, a compactação pode realmente melhorar o desempenho de leitura / gravação (especialmente usando bibliotecas de compactação rápida como BLOSC e LZO), pois reduz a largura de banda de I / O necessária ao custo de alguns ciclos extras de CPU . Você pode querer dar uma olhada nesta página , que contém muitas informações sobre como otimizar o desempenho de leitura e gravação usando arquivos PyTables HDF5.
ali_m
2
"se eu fatiar um memmap numpy de um arquivo binário simples, posso obter uma visualização, que mantém os dados no disco" - isso pode ser verdade, mas se você realmente quiser fazer qualquer coisa com os valores dessa matriz, mais cedo ou mais tarde você terá que carregá-los na RAM. Um array mapeado em memória apenas fornece algum encapsulamento para que você não precise pensar exatamente quando os dados serão lidos ou se irão exceder a capacidade de memória do sistema. Em algumas circunstâncias, o comportamento de cache nativo de matrizes memmaped pode ser muito abaixo do ideal .
ali_m

Respostas:

158

Vantagens do HDF5: Organização, flexibilidade, interoperabilidade

Algumas das principais vantagens do HDF5 são sua estrutura hierárquica (semelhante a pastas / arquivos), metadados arbitrários opcionais armazenados com cada item e sua flexibilidade (por exemplo, compressão). Essa estrutura organizacional e armazenamento de metadados podem parecer triviais, mas são muito úteis na prática.

Outra vantagem do HDF é que os conjuntos de dados podem ser de tamanho fixo ou flexível. Portanto, é fácil anexar dados a um grande conjunto de dados sem ter que criar uma nova cópia inteira.

Além disso, HDF5 é um formato padronizado com bibliotecas disponíveis para quase todas as linguagens, portanto, compartilhar seus dados em disco entre, digamos, Matlab, Fortran, R, C e Python é muito fácil com HDF. (Para ser justo, não é muito difícil com uma grande matriz binária também, contanto que você esteja ciente da ordem C vs. F e conheça a forma, o tipo de d, etc. da matriz armazenada.)

Vantagens do HDF para uma grande matriz: E / S mais rápida de uma fatia arbitrária

Assim como o TL / DR: para uma matriz 3D de ~ 8 GB, a leitura de uma fatia "completa" ao longo de qualquer eixo levou cerca de 20 segundos com um conjunto de dados HDF5 fragmentado e 0,3 segundos (melhor caso) a mais de três horas (pior caso) para uma matriz memmapped dos mesmos dados.

Além das coisas listadas acima, há outra grande vantagem em um formato de dados em disco "fragmentado" *, como HDF5: ler uma fatia arbitrária (ênfase em arbitrário) normalmente será muito mais rápido, pois os dados em disco são mais contíguos em média.

*(HDF5 não precisa ser um formato de dados em partes. Ele suporta fragmentação, mas não exige isso. Na verdade, o padrão para criar um conjunto de dados em h5pynão é fragmentar , se bem me lembro.)

Basicamente, seu melhor caso de velocidade de leitura de disco e seu pior caso de velocidade de leitura de disco para uma determinada fatia de seu conjunto de dados será bastante semelhante a um conjunto de dados HDF fragmentado (assumindo que você escolheu um tamanho de bloco razoável ou deixe uma biblioteca escolher um para você). Com uma matriz binária simples, o melhor caso é mais rápido, mas o pior caso é muito pior.

Uma ressalva: se você tiver um SSD, provavelmente não notará uma grande diferença na velocidade de leitura / gravação. Com um disco rígido normal, porém, as leituras sequenciais são muito, muito mais rápidas do que as leituras aleatórias. (ou seja, um disco rígido normal tem muito seektempo.) HDF ainda tem uma vantagem em um SSD, mas é mais devido aos seus outros recursos (por exemplo, metadados, organização, etc) do que devido à velocidade bruta.


Em primeiro lugar, para esclarecer a confusão, acessar um h5pyconjunto de dados retorna um objeto que se comporta de forma bastante semelhante a uma matriz numpy, mas não carrega os dados na memória até que sejam fatiados. (Semelhante ao memmap, mas não idêntico.) Dê uma olhada na h5pyintrodução para obter mais informações.

Cortar o conjunto de dados carregará um subconjunto dos dados na memória, mas provavelmente você deseja fazer algo com ele e, nesse ponto, precisará dele na memória de qualquer maneira.

Se você deseja fazer cálculos fora do núcleo, pode facilmente obter dados tabulares com pandasou pytables. É possível com h5py(mais agradável para grandes matrizes ND), mas você precisa descer para um nível ligeiramente inferior e lidar com a iteração você mesmo.

No entanto, o futuro das computações fora do núcleo parecidas com entorpecentes é Blaze. Dê uma olhada se você realmente deseja seguir esse caminho.


O caso "não selecionado"

Em primeiro lugar, considere uma matriz 3D C ordenada escrita no disco (vou simular chamando arr.ravel()e imprimindo o resultado, para tornar as coisas mais visíveis):

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

Os valores seriam armazenados no disco sequencialmente, conforme mostrado na linha 4 abaixo. (Vamos ignorar os detalhes e a fragmentação do sistema de arquivos por enquanto.)

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

Na melhor das hipóteses, vamos fazer uma fatia ao longo do primeiro eixo. Observe que esses são apenas os primeiros 36 valores da matriz. Esta será uma leitura muito rápida! (uma busca, uma leitura)

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

Da mesma forma, a próxima fatia ao longo do primeiro eixo terá apenas os próximos 36 valores. Para ler uma fatia completa ao longo deste eixo, precisamos apenas de uma seekoperação. Se tudo o que vamos ler são várias fatias ao longo deste eixo, essa é a estrutura de arquivo perfeita.

No entanto, vamos considerar o pior cenário: uma fatia ao longo do último eixo.

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

Para ler esta fatia, precisamos de 36 buscas e 36 leituras, pois todos os valores estão separados no disco. Nenhum deles é adjacente!

Isso pode parecer muito pequeno, mas à medida que chegamos a matrizes cada vez maiores, o número e o tamanho das seekoperações aumentam rapidamente. Para um array 3D grande (~ 10 Gb) armazenado dessa maneira e lido via memmap, ler uma fatia inteira ao longo do "pior" eixo pode facilmente levar dezenas de minutos, mesmo com hardware moderno. Ao mesmo tempo, uma fatia ao longo do melhor eixo pode levar menos de um segundo. Para simplificar, estou mostrando apenas fatias "completas" ao longo de um único eixo, mas exatamente a mesma coisa acontece com fatias arbitrárias de qualquer subconjunto de dados.

Aliás, existem vários formatos de arquivo que tiram vantagem disso e basicamente armazenam três cópias de enormes arrays 3D no disco: um na ordem C, um na ordem F e um intermediário entre os dois. (Um exemplo disso é o formato D3D do Geoprobe, embora eu não tenha certeza se ele está documentado em qualquer lugar.) Quem se importa se o tamanho final do arquivo é 4 TB, o armazenamento é barato! O mais louco disso tudo é que, como o caso de uso principal é extrair uma única sub-fatia em cada direção, as leituras que você deseja fazer são muito, muito rápidas. Funciona muito bem!


O caso simples "fragmentado"

Digamos que armazenemos "pedaços" 2x2x2 do array 3D como blocos contíguos no disco. Em outras palavras, algo como:

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

Portanto, os dados no disco seriam 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 apenas para mostrar que eles são blocos 2x2x2 de arr, observe que estes são os primeiros 8 valores de chunked:

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

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

Para ler em qualquer fatia ao longo de um eixo, leríamos em 6 ou 9 blocos contíguos (o dobro de dados de que precisaríamos) e então manteríamos apenas a parte que desejávamos. Isso é um máximo de pior caso de 9 buscas contra um máximo de 36 buscas para a versão não fragmentada. (Mas o melhor caso ainda é 6 buscas versus 1 para o array mapeado por mem.) Como as leituras sequenciais são muito rápidas em comparação com as buscas, isso reduz significativamente o tempo que leva para ler um subconjunto arbitrário na memória. Mais uma vez, esse efeito se torna maior com matrizes maiores.

HDF5 leva isso alguns passos adiante. Os pedaços não precisam ser armazenados de forma contígua e são indexados por uma B-Tree. Além disso, eles não precisam ter o mesmo tamanho no disco, então a compressão pode ser aplicada a cada pedaço.


Matrizes fragmentadas com h5py

Por padrão, h5pynão cria arquivos HDF fragmentados no disco (acho que pytablessim, em contraste). Se você especificar chunks=Trueao criar o conjunto de dados, no entanto, obterá uma matriz fragmentada no disco.

Como um exemplo rápido e mínimo:

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

Observe que chunks=Truediz h5pypara escolher automaticamente um tamanho de bloco para nós. Se você sabe mais sobre seu caso de uso mais comum, pode otimizar o tamanho / forma do bloco especificando uma tupla de formato (por exemplo, (2,2,2)no exemplo simples acima). Isso permite que você torne as leituras ao longo de um eixo específico mais eficiente ou otimize leituras / gravações de um determinado tamanho.


Comparação de desempenho de E / S

Apenas para enfatizar o ponto, vamos comparar a leitura em fatias de um conjunto de dados HDF5 fragmentado e um grande array 3D ordenado por Fortran (~ 8GB) contendo os mesmos dados exatos.

Eu já limpou todos os caches OS entre cada corrida, por isso estamos vendo o desempenho "frio".

Para cada tipo de arquivo, testaremos a leitura em um corte x "completo" ao longo do primeiro eixo e um corte z "completo" ao longo do último eixo. Para a matriz memmapped ordenada por Fortran, a fatia "x" é o pior caso, e a fatia "z" é o melhor caso.

O código usado está em uma essência (incluindo a criação do hdfarquivo). Não consigo compartilhar facilmente os dados usados ​​aqui, mas você pode simular isso por uma matriz de zeros da mesma forma ( 621, 4991, 2600)e tipo np.uint8.

A chunked_hdf.pyaparência é assim:

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é semelhante, mas tem um pouco mais de complexidade para garantir que as fatias sejam realmente carregadas na memória (por padrão, outro memmappedarray seria retornado, o que não seria uma comparação maçãs com maçãs).

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

Vamos dar uma olhada no desempenho do HDF primeiro:

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

Uma fatia x "completa" e uma fatia z "cheia" levam aproximadamente a mesma quantidade de tempo (~ 20 segundos). Considerando que este é um array de 8 GB, isso não é tão ruim. A maior parte do tempo

E se compararmos isso com os tempos da matriz mapeada por mem (é ordenada por Fortran: uma "fatia z" é o melhor caso e uma "fatia x" é o pior caso.):

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

Sim, você leu certo. 0,3 segundos para uma direção de corte e ~ 3,5 horas para a outra.

O tempo para fatiar na direção "x" é muito maior do que o tempo que levaria para carregar todo o array de 8 GB na memória e selecionar a fatia que queríamos! (Novamente, esta é uma matriz ordenada por Fortran. O tempo de fatia x / z oposto seria o caso para uma matriz ordenada por C.)

No entanto, se sempre quisermos obter uma fatia ao longo da direção do melhor caso, o grande array binário no disco é muito bom. (~ 0,3 seg!)

Com uma matriz memmapped, você está preso a esta discrepância de E / S (ou talvez anisotropia seja um termo melhor). No entanto, com um conjunto de dados HDF fragmentado, você pode escolher o tamanho do fragmento de forma que o acesso seja igual ou otimizado para um caso de uso específico. Isso dá a você muito mais flexibilidade.

Em suma

Espero que isso ajude a esclarecer uma parte de sua pergunta, de qualquer forma. HDF5 tem muitas outras vantagens sobre os memmaps "brutos", mas não tenho espaço para expandir todas elas aqui. A compactação pode acelerar algumas coisas (os dados com os quais trabalho não se beneficiam muito da compactação, então raramente os uso), e o armazenamento em cache no nível do sistema operacional geralmente funciona mais bem com arquivos HDF5 do que com memmaps "brutos". Além disso, HDF5 é um formato de contêiner realmente fantástico. Ele oferece muita flexibilidade no gerenciamento de seus dados e pode ser usado em mais ou menos qualquer linguagem de programação.

De modo geral, experimente e veja se funciona bem para o seu caso de uso. Eu acho que você pode se surpreender.

Joe Kington
fonte
3
Ótima resposta. Gostaria de acrescentar que você pode personalizar seu layout de chunking para seu padrão de acesso a dados típico. Se o padrão de acesso tiver um tamanho de estêncil bastante previsível, você pode escolher tipicamente seu pedaço de modo a atingir a velocidade ideal em todos os momentos.
Eelco Hoogendoorn
2
Ótima resposta! Uma coisa que não é mencionada sobre o chunking é o efeito do cache do chunk. Cada conjunto de dados aberto tem seu próprio cache de chunk, o tamanho padrão do qual é 1 MB, que pode ser ajustado usando H5Pset_chunk_cache () em C. Geralmente é útil considerar quantos chunks podem ser mantidos na memória ao pensar sobre seus padrões de acesso. Se o seu cache pode conter, digamos, 8 chunks e seu conjunto de dados tem 10 chunks na direção da varredura, você sofrerá muito e o desempenho será péssimo.
Dana Robinson