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 h5py
nã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 seek
tempo.) 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 h5py
conjunto 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 h5py
introduçã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 pandas
ou 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 seek
operaçã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 seek
operaçõ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, h5py
não cria arquivos HDF fragmentados no disco (acho que pytables
sim, em contraste). Se você especificar chunks=True
ao 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=True
diz h5py
para 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 hdf
arquivo). 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.py
aparê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 memmapped
array 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.
h5py
é mais adequado para conjuntos de dados como o seu do quepytables
. Além disso,h5py
se 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 umamemmapped
matriz). Estou escrevendo uma resposta mais completa (pode não terminá-la), mas espero que este comentário ajude um pouco enquanto isso.