Чи є перевага швидкості аналізу або використання пам'яті у використанні HDF5 для зберігання великих масивів (замість плоских двійкових файлів)?


96

Я обробляю великі 3D-масиви, які мені часто потрібно нарізати різними способами для різноманітного аналізу даних. Типовий "куб" може складати ~ 100 ГБ (і, швидше за все, збільшиться в майбутньому)

Здається, типовим рекомендованим форматом файлу для великих наборів даних у python є використання HDF5 (або h5py, або pytables). Моє запитання: чи є якісь переваги у швидкості або використанні пам'яті при використанні HDF5 для зберігання та аналізу цих кубів над їхнім зберіганням у простих плоских двійкових файлах? Чи HDF5 більше підходить для табличних даних, на відміну від великих масивів, таких як те, з чим я працюю? Я бачу, що HDF5 може забезпечити приємне стиснення, але мене більше цікавить швидкість обробки та боротьба з переповненням пам'яті.

Я часто хочу проаналізувати лише одну велику підмножину куба. Одним недоліком як pytables, так і h5py є те, що, коли я беру фрагмент масиву, я завжди отримую масив numpy назад, використовуючи пам’ять. Однак, якщо я нарізаю numpy memmap плоского двійкового файлу, я можу отримати представлення, яке зберігає дані на диску. Отже, мені здається, що я можу легше аналізувати конкретні сектори своїх даних, не перевитрачуючи пам’ять.

Я досліджував як pytables, так і h5py, і поки що не бачив переваг жодного з них для своєї мети.


1
HDF - це "фрагментований" формат файлу. У середньому це дасть вам набагато швидше читання для довільного фрагмента вашого набору даних. Меммап матиме швидкий найкращий випадок, але дуже, дуже повільний найгірший випадок. h5pyкраще підходить для наборів даних, таких як ваш, ніж pytables. Крім того , h5pyце НЕ повертає масив Numpy в пам'яті. Натомість він повертає те, що поводиться як одне, але не завантажується в пам’ять (подібно до memmappedмасиву). Я пишу більш повну відповідь (можливо, не закінчую), але, сподіваюся, цей коментар тим часом трохи допоможе.
Джо Кінгтон,

Дякую. Я погоджуюсь, що h5py повертає набір даних, схожий на memmap. Але якщо ви зробите зріз набору даних h5py, він поверне масив numpy, що, на мою думку (?), Означає, що дані були записані в пам’ять без потреби. Memmamp, якщо це можливо, повертає подання до вихідної меммапи. Іншими словами: type(cube)дає h5py._hl.dataset.Dataset. Поки type(cube[0:1,:,:])віддає numpy.ndarray.
Калеб,

Однак ваша думка про середній час читання цікава.
Калеб

4
Якщо у вас є вузьке місце вводу-виводу, то в багатьох випадках стиснення може насправді покращити продуктивність читання / запису (особливо за допомогою бібліотек швидкого стиснення, таких як BLOSC та LZO), оскільки це зменшує пропускну здатність вводу-виводу, необхідну за рахунок деяких додаткових циклів процесора . Можливо, ви захочете заглянути на цю сторінку , на якій є багато інформації про оптимізацію продуктивності читання-запису за допомогою файлів PyTables HDF5.
ali_m

2
"якщо я нарізаю numpy memmap плоского двійкового файлу, я можу отримати представлення, яке зберігає дані на диску" - це може бути правдою, але якщо ви насправді хочете щось зробити зі значеннями в цьому масиві, рано чи пізно вам доведеться завантажувати їх в оперативну пам’ять. Мапірований на пам’ять масив просто забезпечує деяку інкапсуляцію, так що вам не доведеться думати про те, коли саме дані зчитуються або чи вони перевищать об’єм вашої системної пам’яті. За деяких обставин поведінка кешу керованих масивів може бути дуже неоптимальною .
ali_m

Відповіді:


159

Переваги HDF5: Організація, гнучкість, сумісність

Одними з основних переваг HDF5 є його ієрархічна структура (подібна до папок / файлів), необов'язкові довільні метадані, що зберігаються з кожним елементом, та гнучкість (наприклад, стиснення). Ця організаційна структура та зберігання метаданих може здатися тривіальним, але це дуже корисно на практиці.

Ще однією перевагою HDF є те, що набори даних можуть бути як фіксованого, так і гнучкого розміру. Тому легко додати дані до великого набору даних, не створюючи повністю нової копії.

Крім того, HDF5 - це стандартизований формат із бібліотеками, доступними майже для будь-якої мови, тому обмін даними на диску між, скажімо, Matlab, Fortran, R, C та Python дуже простий із HDF. (Чесно кажучи, це не надто складно і з великим двійковим масивом, якщо ви знаєте порядок C проти F та знаєте форму, тип і т.д. збереженого масиву.)

Переваги HDF для великого масиву: Швидше введення / виведення довільного фрагмента

Так само, як TL / DR: для 3D-масиву ~ 8 Гб читання "повного" фрагмента вздовж будь-якої осі займало ~ 20 секунд із фрагментованим набором даних HDF5 та 0,3 секунди (найкращий випадок) - понад три години (найгірший випадок) для збережений масив тих самих даних.

Окрім перерахованих вище речей, є ще одна велика перевага у "шматованому" * форматі даних на диску, наприклад HDF5: Читання довільного фрагмента (наголос на довільному) зазвичай відбувається набагато швидше, оскільки дані на диску є суміжнішими середній.

*(HDF5 не обов’язково має бути форматом фрагментованих даних. Він підтримує фрагментування, але цього не вимагає. Насправді, за замовчуванням для створення набору даних за замовчуванням h5pyне передбачається фрагментація, якщо я правильно пам’ятаю.)

В основному, ваша найкраща швидкість читання з диска та найгірша швидкість читання з диска для даного фрагмента вашого набору даних будуть досить близькими до фрагментованого набору даних HDF (за умови, що ви вибрали розумний розмір фрагменту або дозволите бібліотеці вибрати один для вас). За допомогою простого двійкового масиву найкращий випадок є швидшим, а найгірший - набагато гіршим.

Одне застереження: якщо у вас є SSD, ви, швидше за все, не помітите величезної різниці в швидкості читання / запису. Однак на звичайному жорсткому диску послідовне читання відбувається набагато швидше, ніж випадкове читання. (Тобто звичайний жорсткий диск має довгий seekчас.) HDF ще має перевагу на SSD, але це більше з - за його інші функції (наприклад , метадані, організації і т.д.) , ніж з - за сиру швидкість.


По-перше, для усунення плутанини доступ до h5pyнабору даних повертає об’єкт, який поводиться досить подібно до масиву numpy, але не завантажує дані в пам’ять, доки їх не наріжуть. (Подібно до memmap, але не ідентично.) Подивіться на h5pyвступ для отримання додаткової інформації.

Нарізання набору даних завантажить підмножину даних в пам’ять, але, мабуть, ви хочете щось з цим зробити, і в цей момент це все одно вам знадобиться в пам’яті.

Якщо ви хочете робити позаядерні обчислення, ви можете досить легко отримати табличні дані за допомогою pandasабо pytables. Це можливо за допомогою h5py(приємніше для великих масивів ND), але вам потрібно опуститись на нижчий рівень і обробити ітерацію самостійно.

Тим не менш, майбутнє за нумі-подібними позаядерними обчисленнями - Blaze. Подивіться на це, якщо ви дійсно хочете піти цим шляхом.


Справа "без відключення"

Спочатку розглянемо 3D-впорядкований масив, записаний на диск (я змоделюю його, зателефонувавши arr.ravel()та роздрукувавши результат, щоб зробити речі більш видимими):

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

Ці значення будуть зберігатися на диску послідовно, як показано в рядку 4 нижче. (Давайте ігноруємо деталі та фрагментацію файлової системи на даний момент.)

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

У найкращому випадку візьмемо зріз уздовж першої осі. Зверніть увагу, що це лише перші 36 значень масиву. Це буде дуже швидко читати! (один шукати, один читати)

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

Аналогічним чином, наступний зріз уздовж першої осі буде просто наступними 36 значеннями. Щоб прочитати повний зріз уздовж цієї осі, нам потрібна лише одна seekоперація. Якщо все, що ми будемо читати, - це різні фрагменти вздовж цієї осі, то це ідеальна структура файлу.

Однак давайте розглянемо найгірший сценарій: зріз вздовж останньої осі.

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

Щоб прочитати цей фрагмент, нам потрібно 36 пошуків та 36 читань, оскільки всі значення розділені на диску. Жоден з них не примикає!

Це може здатися досить незначним, але по мірі того, як ми потрапляємо до все більших і більших масивів, кількість і розмір seekоперацій швидко зростає. Для великого (~ 10 Гб) 3D-масиву, що зберігається таким чином і зчитується через memmap, читання повного фрагмента вздовж "найгіршої" осі може легко зайняти десятки хвилин, навіть із сучасним обладнанням. У той же час зріз уздовж найкращої осі може зайняти менше секунди. Для простоти я показую лише «повні» зрізи вздовж однієї осі, але те саме відбувається з довільними зрізами будь-якої підмножини даних.

До речі, існує кілька форматів файлів, які цим скористалися і в основному зберігають на диску три копії величезних тривимірних масивів: одну в C-порядку, одну в F-порядку та одну в проміжних між ними. (Прикладом цього є формат D3D Geoprobe, хоча я не впевнений, що це десь задокументовано.) Кому цікаво, чи остаточний розмір файлу становить 4 ТБ, зберігання дешеве! Божевільне в цьому полягає в тому, що, оскільки основним варіантом використання є вилучення одного під-зрізу в кожному напрямку, читання, які ви хочете зробити, дуже і дуже швидкі. Це працює дуже добре!


Простий "кусатий" випадок

Скажімо, ми зберігаємо 2x2x2 "шматки" 3D-масиву як суміжні блоки на диску. Іншими словами, щось на зразок:

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

Тож дані на диску будуть виглядати так 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])

І лише, щоб показати, що це блоки 2x2x2 arr, зверніть увагу, що це перші 8 значень chunked:

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

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

Щоб читати будь-який зріз уздовж осі, ми читали 6 або 9 суміжних шматків (удвічі більше даних, ніж нам потрібно), а потім зберігали лише ту частину, яку хотіли. Це найгірший випадок - максимум 9 пошуків проти максимум 36 пошуків для версії без фрагментів. (Але найкращий випадок все-таки 6 шукає проти 1 для заповненого масиву.) Оскільки послідовне читання відбувається дуже швидко в порівнянні з пошуками, це значно зменшує час, необхідний для читання довільної підмножини в пам’яті. Ще раз, цей ефект стає більшим із більшими масивами.

HDF5 робить це на кілька кроків далі. Шматки не потрібно зберігати суміжно, і вони індексуються B-Деревом. Крім того, вони не повинні мати однаковий розмір на диску, тому стиснення можна застосувати до кожного фрагмента.


Колоні масиви з h5py

За замовчуванням h5pyне створює фрагментовані файли HDF на диску (я думаю pytables, навпаки). chunks=TrueОднак, якщо ви вкажете при створенні набору даних, ви отримаєте фрагментований масив на диску.

Як швидкий, мінімальний приклад:

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

Зверніть увагу , що chunks=Trueговорить , h5pyщоб автоматично вибрати розмір порції для нас. Якщо ви знаєте більше про найпоширеніший варіант використання, ви можете оптимізувати розмір / форму шматка, вказавши кортеж фігури (наприклад, (2,2,2)у простому прикладі вище). Це дозволяє зробити читання вздовж певної осі більш ефективними або оптимізувати для читання / запису певного розміру.


Порівняння продуктивності вводу-виводу

Щоб наголосити на цьому, давайте порівняємо читання фрагментами з фрагментованого набору даних HDF5 та великого (~ 8 Гб) замовленого Фортраном 3D-масиву, що містить ті самі точні дані.

Я очистив усі кеші ОС між кожним запуском, тому ми спостерігаємо "холодну" продуктивність.

Для кожного типу файлу ми перевіримо читання у вигляді "повного" x-зрізу вздовж першої осі та "повного" z-розміру вздовж останньої осі. Для упорядкованого Фортраном меммапсованого масиву зріз «x» є найгіршим випадком, а зріз «z» - найкращим.

Використовуваний код міститься в сутності (включаючи створення hdfфайлу). Я не можу легко поділитися даними, які тут використовуються, але ви можете імітувати їх за допомогою масиву нулів однакової форми ( 621, 4991, 2600)і типу np.uint8.

У chunked_hdf.pyвиглядає наступним чином :

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схожий, але має набагато складніший спосіб забезпечити завантаження фрагментів у пам’ять (за замовчуванням memmappedбуде повернено інший масив, що не буде порівнянням між яблуками та яблуками).

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

Давайте спочатку подивимось на продуктивність 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

"Повний" х-зріз і "повний" зріз займають приблизно однакову кількість часу (~ 20 с). Враховуючи, що це масив розміром 8 Гб, це не так вже й погано. Більшу частину часу

І якщо ми порівняємо це із запам'ятовуваним часом масиву (це впорядковано Фортраном: "z-зріз" - найкращий випадок, а "x-зріз" - найгірший випадок.):

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

Так, ви правильно прочитали. 0,3 секунди для одного напрямку зрізу та ~ 3,5 години для іншого.

Час нарізки в напрямку "х" набагато довший, ніж час, необхідний для завантаження всього масиву 8 Гб в пам'ять і вибору потрібного нам зрізу! (Знову ж таки, це масив, впорядкований Фортраном. Протилежний час зрізу x / z мав би місце для масиву, упорядкованого C).

Однак, якщо ми завжди хочемо взяти фрагмент у найкращому випадку, великий двійковий масив на диску дуже хороший. (~ 0,3 сек!)

З заповненим масивом ви застрягли в цій невідповідності вводу / виводу (або, можливо, анізотропія - кращий термін). Однак, із фрагментованим набором даних HDF, ви можете вибрати зменшення, щоб доступ був рівним або оптимізованим для конкретного випадку використання. Це надає вам набагато більшу гнучкість.

Підсумовуючи

Будемо сподіватися, що це допоможе прояснити одну частину вашого питання, у будь-якому випадку. HDF5 має багато інших переваг перед "необробленими" меммапами, але тут у мене немає місця розширювати всі. Стиснення може пришвидшити деякі речі (дані, з якими я працюю, не мають великої користі від стиснення, тому я їх рідко використовую), а кешування на рівні ОС часто грає приємніше з файлами HDF5, ніж із "необробленими" меммапами. Крім цього, HDF5 - це справді фантастичний формат контейнера. Це дає вам велику гнучкість в управлінні вашими даними і може використовуватися з більш-менш будь-якої мови програмування.

Загалом, спробуйте і перевірте, чи добре це працює для вашого випадку використання. Я думаю, ви можете бути здивовані.


3
Чудова відповідь. Я хотів би додати, що ви можете налаштувати макет розміщення фрагментів відповідно до типового шаблону доступу до даних. Якщо шаблон доступу має досить передбачуваний розмір трафарету, ви, як правило, можете вибрати свій шматок, щоб завжди досягти майже оптимальної швидкості.
Eelco Hoogendoorn

2
Чудова відповідь! Щось не згадується про шматування, це ефект кеш-пам’ятки. Кожен відкритий набір даних має власний кеш-пам’ять, розмір за замовчуванням якого становить 1 МБ, який можна налаштувати за допомогою H5Pset_chunk_cache () в C. Загалом корисно врахувати, скільки фрагментів можна зберегти в пам’яті, думаючи про ваші шаблони доступу. Якщо ваш кеш вміщує, скажімо, 8 фрагментів, а ваш набір даних - 10 фрагментів у напрямку сканування, ви будете багато тремтіти, а продуктивність буде жахливою.
Дана Робінсон
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.