HDF5ファイルに多次元配列をC++ (.cpp)で書き込む
HDF5を使うきっかけ
C++で書いたプログラムで計算したデータをpythonで集計処理するプログラムを作成しています。もともとデータのやり取りが軽量で、json形式でやり取り していたのですが、重めのデータを扱う必要があり、バイナリ書き出しが必須になりました。
自分でバイナリのインターフェースを作成していたのですが、データサイズの変更などの際に2つのインターフェースで書き換えるのはなかなか大変で便利なライブラリがないか調べたところ、HDF5に出会いました。このライブラリは、階層構造を持って、データをバイナリで保存してくれるライブラリで、さまざまな言語のインターフェースがあり私のユースケースにぴったりでした。
c++側については本記事で扱うとして、python側にはh5pyという非常に便利なライブラリがあります。これを使うことで自由にpythonからhdf5のファイルを読み書きできます。それについては以下の記事が詳しいでしょう。
HDF5のinstall
c/cppで使うためのインストール方法については、以下の記事のように
sudo apt install libhdf5-serial-dev
などでできます。
もし管理者権限がなく、source buildする必要がある場合は以下の手順で入れることができます。
build_dir=$HOME/hdf5_build #: ダウンロードしたファイルを展開するディレクトリ
install_dir=$HOME/.local/hdf5-1.8.23 #: ライブラリとヘッダファイルを置いておくディレクトリ
cd $HOME
mkdir $build_dir
cd $build_dir
wget https://support.hdfgroup.org/ftp/HDF5/prev-releases/hdf5-1.8/hdf5-1.8.23/src/hdf5-1.8.23.tar.gz # リンクが切れているかもしれません。https://support.hdfgroup.org/ftp/HDF5/を確認し、必要なバージョンをお選びください。
tar -zxvf hdf5-1.8.15-patch1.tar.gz
cd hdf5-1.8.23
mkdir build
cd build
../configure --enable-cxx --prefix=$install_dir # 新しめのコンパイラを用いている場合は、CFLAGSでc/c++のバージョンを指定する必要があるかもしれません。
make -j 8 # cpuのcore数
make check
make install # install場所が管理者権限が必要な場合は、sudoをつける
installが完了したら、.bashrc
に以下を転記します。
export HDF5_place=$HOME/.local/hdf5-1.8.23
export PATH="$PATH:${HDF5_place}/bin"# ここでは紹介しませんが、いくつか便利なtoolもコンパイルされています。
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${HDF5_place}/lib"#ここでの例ではHDF5は実行時に共有オブジェクトとのリンクさせるので、指定が必要です。
編集したら、
source ~/.bashrc
を実行し、変更を反映させます。
こちらの記事を後から見つけました。インストールの要点がまとまっており、わかりやすいです:
シンプルなコンパイルテスト
hdf5はC言語のライブラリです。まずはC言語apiで正しく機能することを確認します。
cでのテストコード
#include <stdio.h>
#include <stdlib.h>
#include "hdf5.h"
#define FILENAME "example.h5"
#define DATASETNAME "data"
int main()
{
hid_t file_id, dataset_id, dataspace_id;
herr_t status;
// 行と列のサイズ
const int rows = 3;
const int columns = 4;
// 2次元のデータを作成
double data[rows][columns];
for (int i = 0; i < rows; ++i)
{
for (int j = 0; j < columns; ++j)
{
data[i][j] = i * columns + j + 1;
}
}
// HDF5ファイルを作成または開く
file_id = H5Fcreate(FILENAME, H5F_ACC_TRUNC, H5P_DEFAULT, H5P_DEFAULT);
if (file_id < 0)
{
fprintf(stderr, "Unable to create or open file.\n");
return EXIT_FAILURE;
}
// データセットの作成
hsize_t dims[2] = {rows, columns};
dataspace_id = H5Screate_simple(2, dims, NULL);
dataset_id = H5Dcreate2(file_id, DATASETNAME, H5T_NATIVE_DOUBLE, dataspace_id,
H5P_DEFAULT, H5P_DEFAULT, H5P_DEFAULT);
// データを書き込む
status = H5Dwrite(dataset_id, H5T_NATIVE_DOUBLE, H5S_ALL, H5S_ALL,
H5P_DEFAULT, data);
// ファイルを閉じる
status = H5Dclose(dataset_id);
status = H5Sclose(dataspace_id);
status = H5Fclose(file_id);
// ファイルを再度開く
file_id = H5Fopen(FILENAME, H5F_ACC_RDONLY, H5P_DEFAULT);
if (file_id < 0)
{
fprintf(stderr, "Unable to reopen file.\n");
return EXIT_FAILURE;
}
// データセットを取得
dataset_id = H5Dopen2(file_id, DATASETNAME, H5P_DEFAULT);
// データセットのサイズを取得
dataspace_id = H5Dget_space(dataset_id);
int ndims = H5Sget_simple_extent_ndims(dataspace_id);
hsize_t dims_out[2];
H5Sget_simple_extent_dims(dataspace_id, dims_out, NULL);
// データを読み込む
double data_out[rows][columns];
status = H5Dread(dataset_id, H5T_NATIVE_DOUBLE, H5S_ALL, H5S_ALL,
H5P_DEFAULT, data_out);
// 読み込んだデータを表示
printf("Read data from HDF5 file:\n");
for (int i = 0; i < rows; ++i)
{
for (int j = 0; j < columns; ++j)
{
printf("%lf ", data_out[i][j]);
}
printf("\n");
}
// ファイルを閉じる
status = H5Dclose(dataset_id);
status = H5Sclose(dataspace_id);
status = H5Fclose(file_id);
return EXIT_SUCCESS;
}
このコードはChat GPTに、「Cでhdf5を使う例を教えて」と聞くことで入手しました。コンパイルは、makefileを使うと便利でしょう。
hdf5LibDir=-L$(HDF5_place)/lib/ # 共有オブジェクトの参照ディレクトリの追加
hdf5IncludeDir=-I$(HDF5_place)/include/ # includeファイルの参照ディレクトリの追加
hdf5Lib = -lhdf5 # 利用する共有オブジェクト名
all:
gcc hdf5_test.c -o runner $(hdf5LibDir) $(hdf5IncludeDir) $(hdf5Lib)
このmakefileを実行して生成されるrunner
を実行すると、
$ ./runner
Read data from HDF5 file:
1.000000 2.000000 3.000000 4.000000
5.000000 6.000000 7.000000 8.000000
9.000000 10.000000 11.000000 12.000000
のように表示されて、ディレクトリに、example.h5
が追加されているでしょう。
test_dir
├ hdf5_test.c
├ runner
├ Makefile
└ example.h5
High level apiについて
テストでは上記のコードを用いましたが、Cでhdf5を用いる場合は、このやり方は少し冗長なようです。hdf5にはより簡単に読み書きできるHigh level apiが提供されています:
これを使うと、上記のコードをより簡単に表現できます。
High level apiを利用する場合のファイル読み込み例
#include <stdio.h>
#include <stdlib.h>
#include "hdf5_hl.h"
#define FILENAME "example.h5"
#define DATASETNAME "data"
int main()
{
hid_t file_id;
herr_t status;
// 行と列のサイズ
const int rows = 3;
const int columns = 4;
// 2次元のデータを作成
double data[rows][columns];
// ファイルを再度開く
file_id = H5Fopen(FILENAME, H5F_ACC_RDONLY, H5P_DEFAULT);
if (file_id < 0)
{
fprintf(stderr, "Unable to reopen file.\n");
return EXIT_FAILURE;
}
// データセットを取得し読み込む(High-Level APIを使用)
status = H5LTread_dataset_double(file_id, DATASETNAME, &data[0][0]);
// 読み込んだデータを表示
printf("Read data from HDF5 file:\n");
for (int i = 0; i < rows; ++i)
{
for (int j = 0; j < columns; ++j)
{
printf("%lf ", data[i][j]);
}
printf("\n");
}
// ファイルを閉じる
status = H5Fclose(file_id);
return EXIT_SUCCESS;
}
このコードはCHAT GPTで「HDF5 High level apiを用いた例をおしえて」と入力することで生成したコードを少し改変しました。
コンパイルはMakefileに、
+ highApi:
+ gcc hl_example.c -o hl_ex $(hdf5LibDir) $(hdf5IncludeDir) $(hdf5Lib) -lhdf5_hl
を追記し、make highApi
を実行することでできます。example.h5
がある環境で、hl_ex
を実行すると、example.h5を読み込んでくれます。
C++(.cpp)での利用方法
実はc++での利用方法について書かれたすばらしい記事があります。場合によっては、こちらの4つの記事で解説されている内容のほうがありがたいでしょう。
qiitaのzhidao氏の記事リンク
これらの記事の二番煎じともいえる内容なのですが、C++での利用方法について説明します。
C++でCのHDFのapiを利用することは可能です。ですがbuild時に--enable-cxx
を付与することで、C++用の便利なラッパーapiがコンパイルされます。C++用のapiはH5Cpp.h
で提供されています。
以下のページに、cのapiとの対応関係が簡単にまとまっています。
Datasetの作成
HDFにおいて、group
、dataset
、attribute
の3つをfileの中に作成することができます。
-
group
: fileの中の階層構造で、ディレクトリを作成するようにデータの位置を定めることができます。 -
dataset
: hdf5に保存するdataそのものです。 -
attribute
: datasetに簡単な説明を添付できます。
そのうちもっとも基本となるのはdataset
でしょう。dataset
の作成して、その中にdataを書き込むには4つの手続きが必要です。
- fileのopen
- 作成するdatasetのメモリサイズの確保
- datasetの作成
- datasetへのdataの書き込み
ここでは以下の要件のデータを保存することにします。
- 書き込み先:
example.h5
- dataset名:
example_data
- 次元:
2\times2\times2 - 型:double
- 要素:
- コード:
double data[2][2][2] = {{{1,2},{3,4}},{{5,6},{7,8}}};
fileのopen
fileのopenはH5::H5File
で提供されます。第1引数にファイル名、第2引数に、fileの開き方に関するflagを指定します。flagは以下の定数で提供されています。
-
H5F_ACC_TRUNC
: 既存のfileがある場合は、消去して作成 -
H5F_ACC_EXCL
: fileがある場合は、作成を失敗させる -
H5F_ACC_RDONLY
: Readオンリーで開く。fileがない場合は失敗させる -
H5F_ACC_RDWR
: Real/Writeで開く。fileがない場合は失敗させる
ここでは、fileを上書き作成したいので、H5F_ACC_TRUNC
を用いることにします。
H5::H5File file_object("example.h5", H5F_ACC_TRUNC);
作成するdatasetのメモリサイズの確保
データを保存するためのメモリサイズを決めます。それはH5::DataSpace
で提供されています。第一引数に第二引数の配列の大きさ、第2引数にhsize_t
の配列の先頭のポインタを指定することで、メモリサイズが決まります。
H5::DataSpace dataspace(3, (std::vector<hsize_t>{2,2,2}).data());
datasetの作成
datasetはfileのmethodで提供されます。データセット名、格納するdataのtype、dataspaceを引数にとります。
格納するdataのtypeについてH5::PredType::
以下にあります。かなりいろんな種類がありますが、基本的にはNATIVE_*
を使うとよいでしょう。hdf5ビルド時に確認したエンディアンでデータを保存してくれるみたいです。
H5::DataSet dataset = file_object.createDataSet("example_data", H5::PredType::NATIVE_DOUBLE, dataspace);
datasetの書き込み
こうして生成したdataset
にデータを書き込むには、datasetのメソッドのwrite
関数を利用します。第1引数には、書き込むデータの先頭のポインタ、第2引数には、そのデータ型を指定します。
dataset.write(data, H5::PredType::NATIVE_DOUBLE);
これで書き込みが行われます。最後にdataset
とfile
をcloseしましょう。
dataset.close();
file_object.close();
まとめると次のようなコードになります。
コード
#include "H5Cpp.h"
#include <vector>
int main()
{
double data[2][2][2] = {{ {1,2},{3,4}}, {{5,6},{7,8}}};
H5::H5File file_object("example.h5", H5F_ACC_TRUNC);
H5::DataSpace dataspace(3, (std::vector<hsize_t>{2,2,2}).data());
H5::DataSet dataset = file_object.createDataSet("example_data", H5::PredType::NATIVE_DOUBLE, dataspace);
dataset.write(data, H5::PredType::NATIVE_DOUBLE);
dataset.close();
file_object.close();
return 0;
}
コンパイルは始めのMakefileに以下を追記し、make cppApi
で実行できます。ここで大切なのは、-lhdf5_cpp
を指定することです。これはC++バージョンで、HDF5ライブラリを使うコンパイルに必須です。
+ cppApi:
+ g++ save_data.cpp -o save_data $(hdf5LibDir) $(hdf5IncludeDir) $(hdf5Lib) -lhdf5_cpp
大きなデータの取り扱い
HDF5を使う目的は大量のデータを書き込むことでした。しかし上記の例ではスタック領域にメモリを確保しており、データが巨大化するとスタックオーバーフローを起こしてしまいます。そうしたことをおこさず安全に配列を保存するには、ヒープ領域でデータを確保しなければいけないのですが、ここに落とし穴があります。(正しくは私が無知すぎただけで、きっと常識的なことなんだと思います。)
C++においてヒープ領域を用いる一番オーソドックスな方法はstd::vector
を利用することでしょう。そのため、1次元のデータの場合はシンプルにstd::vector<double>
を使うことで、問題が解決します。std::vectorの先頭のポインタを取得するにはメソッドのdata()
関数を利用します。
std::vector<double> data(100000);
dataset.write(data.data(), H5::PredType::NATIVE_DOUBLE);
しかし多次元配列を利用する場合はすこしやっかいです。上記の例でみたように、dataset.write()
の第1引数には行優先のデータの先頭のポインタを指定してあげることで、書き込むようになっています。
多次元配列を保持する多くの場合、std::vectorをネストさせることで実現することが多いでしょう:
std::vector<std::vector<double>> data(100, std::vector<double>(100);
この場合、1次元配列のように、data.data()
と指定してもうまくデータを保存することができません。このとき2次元になっているデータは連続して配置されているわけではありません。std::vector
は要素が連続して配置されるようにできていますが、std::vector<double>
を要素に持つstd::vector<std::vector<double>>
は、そのようになっていません。そのためメモリの参照エラーが起きてしまうのです。
この問題に対処するには主に2つの方法があります。
- 1次元の
std::vector<double>
にデータに流し込む - boostのmulti_arrayを利用する
std::vector<double>
にデータを流し込む。
1次元の高次元になっている配列を一次元配列にして、書き込みを行います。
std::vector<std::vector<double>> data = {{1,2,3,4,5,6,7,8,9,10},{11,12,13,14,15,16,17,18,19,20}};
std::vector<double> dump_data(20);
for (int i=0; i <data.size();++i )
{
for (int j=0; j <data[0].size(); ++j)
{
dump_data[i*data[0].size() +j] = data[i][j];
}
}
H5::H5File file_object("ex2.h5", H5F_ACC_TRUNC);
H5::DataSpace dataspace(2, (std::vector<hsize_t>{2,10}).data());
H5::DataSet dataset = file_object.createDataSet("example_data", H5::PredType::NATIVE_DOUBLE, dataspace);
dataset.write(dump_data.data(), H5::PredType::NATIVE_DOUBLE);
dataset.close();
file_object.close();
multi_array
を使う
boostの1次元のstd::vector<double>
に流し込むやり方は便利なようで、どこか配列のサイズを間違えてしまいそうですし、そういう処理が面倒だから自分でバイナリファイルの読み書きをせずに、HDF5を使っているので、なにかしっくりきません。
動的に配列を確保して行優先で情報を保持できる便利な配列はないかと調べたところ、boostで実装されていました。使い方の説明は、
にあります。利用した場合の例を以下に示します。#include "H5Cpp.h"
#include "boost/multi_array.hpp"
#include <vector>
int main()
{
boost::multi_array<double, 2> data(boost::extents[2][10]);
double val = 1.0;
for (int i = 0; i < 2; ++i)
{
for (int j = 0; j <10; ++j)
{
data[i][j] = val;
++val;
}
}
H5::H5File file_object("ex3.h5", H5F_ACC_TRUNC);
H5::DataSpace dataspace(2, (std::vector<hsize_t>{2, 10}).data());
H5::DataSet dataset = file_object.createDataSet("example_data", H5::PredType::NATIVE_DOUBLE, dataspace);
dataset.write(data.data(), H5::PredType::NATIVE_DOUBLE);
dataset.close();
file_object.close();
return 0;
}
このようにすると、ファイルの書き込みをシンプルにすることができます。コンパイルは以下のmakefileを作成します。multi_arrayはヘッダオンリーで動くので、単にコンパイラにヘッダファイルの位置を与えてあげればよいでしょう。boostのインストール方法については、multi_array
はヘッダファイルのインストールで使えるので、なんとか入れていただければと思います。
BoostIncludeDir=-I$HOME/.local/boost-1.76.0/include/ #ここはご自身のBoostのヘッダファイルがあるディレクトリを参照してください。
mai:
g++ multi_array_input.cpp -o mai $(hdf5LibDir) $(hdf5IncludeDir) $(hdf5Lib) -lhdf5_cpp $(BoostIncludeDir)
これで、make mai
と入力することでmai
がコンパイルされます。
終わりに
ここまでHDF5に多次元配列をC++を用いて書き込む方法について説明しました。書き込む方法がわかったら、読み込む方法や、グルーピングの方法などが気になるかもしれません。そうしたことはzhidao氏がqiitaにて書かれた記事が詳しいのでそちらを参照されるとよいかもしれません。私もおおいに参考にさせていただきました。
また合わせて、公式のexampleもよいでしょう。こちらはコードにコメントアウトで説明があるだけの荒削りなものですが、利用する分には十分な説明があります。さいごにvscodeで編集している方に耳寄りな拡張機能を紹介して終わりたいと思います。HDF5ファイルを生成したもののバイナリファイルであるため、容易には中身を見ることができません。。よいviewerがvscodeの拡張機能として提供されています。
以下のようにデータを直接見ることができます。
これはかなり便利ですよね。もしかしたら場合によってはテキストファイルの書き出しよりもHDF5で書き出した方がよいというシーンも出てくるかもしれません。
みなさんの研究・開発が一層進むことを切に願って文章を終えたいと思います。
Discussion