💽

HDF5ファイルに多次元配列をC++ (.cpp)で書き込む

2023/12/31に公開

HDF5を使うきっかけ

C++で書いたプログラムで計算したデータをpythonで集計処理するプログラムを作成しています。もともとデータのやり取りが軽量で、json形式でやり取り していたのですが、重めのデータを扱う必要があり、バイナリ書き出しが必須になりました。

自分でバイナリのインターフェースを作成していたのですが、データサイズの変更などの際に2つのインターフェースで書き換えるのはなかなか大変で便利なライブラリがないか調べたところ、HDF5に出会いました。このライブラリは、階層構造を持って、データをバイナリで保存してくれるライブラリで、さまざまな言語のインターフェースがあり私のユースケースにぴったりでした。

c++側については本記事で扱うとして、python側にはh5pyという非常に便利なライブラリがあります。これを使うことで自由にpythonからhdf5のファイルを読み書きできます。それについては以下の記事が詳しいでしょう。

https://qiita.com/simonritchie/items/23db8b4cb5c590924d95

HDF5のinstall

c/cppで使うためのインストール方法については、以下の記事のように

shell
sudo apt install  libhdf5-serial-dev

などでできます。

https://stackoverflow.com/questions/31719451/install-hdf5-and-pytables-in-ubuntu

もし管理者権限がなく、source buildする必要がある場合は以下の手順で入れることができます。

download.sh(コマンドラインで1つずつ実行することを推奨します)
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

を実行し、変更を反映させます。
https://gist.github.com/stephenturner/6046337d1237ecd627a8

こちらの記事を後から見つけました。インストールの要点がまとまっており、わかりやすいです:
https://qiita.com/matsu1024/items/44d0121bc8eeee61a423

シンプルなコンパイルテスト

hdf5はC言語のライブラリです。まずはC言語apiで正しく機能することを確認します。

cでのテストコード
hdf5_test.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を使うと便利でしょう。

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を実行すると、

shell
$ ./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が追加されているでしょう。

directory
test_dir
├ hdf5_test.c
├ runner
├ Makefile
└ example.h5

High level apiについて

テストでは上記のコードを用いましたが、Cでhdf5を用いる場合は、このやり方は少し冗長なようです。hdf5にはより簡単に読み書きできるHigh level apiが提供されています:

https://davis.lbl.gov/Manuals/HDF5-1.8.7/HL/index.html

これを使うと、上記のコードをより簡単に表現できます。

High level apiを利用する場合のファイル読み込み例
hl_example.c
#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に、

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との対応関係が簡単にまとまっています。
https://docs.hdfgroup.org/archive/support/HDF5/doc1.8/cpplus_RM/index.html

Datasetの作成

HDFにおいて、groupdatasetattributeの3つをfileの中に作成することができます。

  • group: fileの中の階層構造で、ディレクトリを作成するようにデータの位置を定めることができます。
  • dataset: hdf5に保存するdataそのものです。
  • attribute: datasetに簡単な説明を添付できます。

https://docs.hdfgroup.org/hdf5/develop/_h5_a__u_g.html

そのうちもっとも基本となるのはdatasetでしょう。datasetの作成して、その中にdataを書き込むには4つの手続きが必要です。

  1. fileのopen
  2. 作成するdatasetのメモリサイズの確保
  3. datasetの作成
  4. datasetへのdataの書き込み

ここでは以下の要件のデータを保存することにします。

  • 書き込み先:example.h5
  • dataset名:example_data
  • 次元: 2\times2\times2
  • 型:double
  • 要素:
\begin{bmatrix} 1.0 & 2.0 \\ 3.0 & 4.0 \end{bmatrix} \, \begin{bmatrix} 5.0 & 6.0 \\ 7.0 & 8.0 \end{bmatrix}
  • コード:
save_data.cpp
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を用いることにします。

save_data.cpp
H5::H5File file_object("example.h5", H5F_ACC_TRUNC);

https://docs.hdfgroup.org/archive/support/HDF5/doc1.8/cpplus_RM/class_h5_1_1_h5_file.html

作成するdatasetのメモリサイズの確保

データを保存するためのメモリサイズを決めます。それはH5::DataSpaceで提供されています。第一引数に第二引数の配列の大きさ、第2引数にhsize_tの配列の先頭のポインタを指定することで、メモリサイズが決まります。

save_data.cpp
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ビルド時に確認したエンディアンでデータを保存してくれるみたいです。

https://docs.hdfgroup.org/archive/support/HDF5/doc1.8/cpplus_RM/class_h5_1_1_pred_type.html#details

save_data.cpp
H5::DataSet dataset = file_object.createDataSet("example_data", H5::PredType::NATIVE_DOUBLE, dataspace);

datasetの書き込み

こうして生成したdatasetにデータを書き込むには、datasetのメソッドのwrite関数を利用します。第1引数には、書き込むデータの先頭のポインタ、第2引数には、そのデータ型を指定します。

save_data.cpp
 dataset.write(data, H5::PredType::NATIVE_DOUBLE);

これで書き込みが行われます。最後にdatasetfileをcloseしましょう。

save_data.cpp
dataset.close();
file_object.close();

まとめると次のようなコードになります。

コード
save_data.cpp
#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ライブラリを使うコンパイルに必須です。

Makefile
+ 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. 1次元のstd::vector<double>にデータに流し込む
  2. boostのmulti_arrayを利用する

1次元のstd::vector<double>にデータを流し込む。

高次元になっている配列を一次元配列にして、書き込みを行います。

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

boostのmulti_arrayを使う

1次元のstd::vector<double>に流し込むやり方は便利なようで、どこか配列のサイズを間違えてしまいそうですし、そういう処理が面倒だから自分でバイナリファイルの読み書きをせずに、HDF5を使っているので、なにかしっくりきません。

動的に配列を確保して行優先で情報を保持できる便利な配列はないかと調べたところ、boostで実装されていました。使い方の説明は、
https://qiita.com/StoneDot/items/45f3288ae464ac6567f7
https://boostjp.github.io/archive/boost_docs/libs/multi_array/user.html
にあります。利用した場合の例を以下に示します。

multi_array_input.cpp
#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のインストール方法については、
https://boostjp.github.io/howtobuild.html
などを参照するとよいかもしれません。※意外とBoost.Pythonなどのライブラリを含めてbuildするのは難しい印象です。ですが、multi_arrayはヘッダファイルのインストールで使えるので、なんとか入れていただければと思います。

Makefile
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にて書かれた記事が詳しいのでそちらを参照されるとよいかもしれません。私もおおいに参考にさせていただきました。
https://qiita.com/zhidao
また合わせて、公式のexampleもよいでしょう。こちらはコードにコメントアウトで説明があるだけの荒削りなものですが、利用する分には十分な説明があります。
https://docs.hdfgroup.org/archive/support/HDF5/doc1.8/cpplus_RM/examples.html

さいごにvscodeで編集している方に耳寄りな拡張機能を紹介して終わりたいと思います。HDF5ファイルを生成したもののバイナリファイルであるため、容易には中身を見ることができません。。よいviewerがvscodeの拡張機能として提供されています。

https://marketplace.visualstudio.com/items?itemName=h5web.vscode-h5web

以下のようにデータを直接見ることができます。

これはかなり便利ですよね。もしかしたら場合によってはテキストファイルの書き出しよりもHDF5で書き出した方がよいというシーンも出てくるかもしれません。

みなさんの研究・開発が一層進むことを切に願って文章を終えたいと思います。

Discussion