[C#]データの保存形式は何が良い?(非圧縮バイナリ vs GZip vs テキスト)

大量のデータを保存するとき、どの形式で保存するのが良いのかを考えてみたいと思います。ここで大量のデータとは数億個以上のデータを意味しています。一般的にデータを保存する場合というとデータベースを使用するというイメージですけど、データベースへの保存は考えていません。というのも、今回の目的は試行錯誤を繰り返しているような段階だったり、計算の途中経過だったりを保存する目的のためデータベースへの保存は馴染まないという事情があります。

そこで、実験をしてみることにしました。約1.3億個の倍精度浮動小数点を非圧縮のバイナリ、GZip圧縮したバイナリおよびテキストファイルに保存したり読み取ったりするのに要する時間や、出力したファイルのサイズを比較して、最適な保存方法について検討してみるというものです。ここで、約1.3億個とは128×1024×1024で、非圧縮のバイナリファイルのサイズは倍精度浮動小数点型のサイズ(8byte)をかけてちょうど1GB(1GiB)になります。

なお、実験の環境は下記の通りです。

  • HDD: 東芝DT01ACA300(3TB、SATA600、7200rpm)
  • CPU: Intel Core i7-8700(3.2GHz, 6コア12スレッド)
  • RAM: 16GB(2.67GHz)
  • OS: Windows10 21H1
  • Visual Studio2015、.NET Framework4.5

実験は、1.3億個の浮動小数点(0/13.0 ~ (128×1024×1024-1)/13.0)を、非圧縮のバイナリ、GZip圧縮したバイナリおよびテキストファイルに保存した後に、保存した結果を読み取るのに要する時間を調べるものです。読み取る際には、読み取った値とループのインデックスから求めた値とを比較して妥当性を検証しています。上記を9回繰り返して、その最小値を処理時間としました。また、GZip圧縮はオプション「Optimal」と「Fastest」の二つを試行しました。なお、テキストファイルへの保存時は6桁の精度で保存、読み取り時は一定の誤差の範囲に収まっているかで比較しました(末尾のソースを参照)。

さて、上記の実験の結果です。

ファイル形式 Write[sec] Read[sec] ファイルサイズ[MB]
非圧縮バイナリ 3.9 3.7 1,024
GZip(Optimal) 132.9 15.7 248
GZip(Fastest) 51.5 21.0 258
テキスト 51.4 24.0 1,920

まず、速度で見ると非圧縮のバイナリファイルがダントツに速い結果となりました。書き込み、読み取り共にダントツのスピードです。一方で、ダントツで遅かったのがGZip圧縮の「Optimal」の場合で、正直にいって意外な結果でした。最近はCPUが高速化しているので、律速段階はCPUではなくDiskのIOだと勝手に予測していました。そのため、むしろGZip圧縮の方が速くなるんじゃないかと期待がありましたが、流石にそんなことはありませんでした。一方で、速度の面で意外と検討したのがテキストファイルです。非圧縮のバイナリには到底及びませんが、読み取りおよび書き込み共に「Fastest」とした場合のGZip圧縮に匹敵するスピードとなりました。

一方で、ファイル容量を見ると、当然ですが「Optimal」としたGZip圧縮が圧倒的に小さくなり、非圧縮のバイナリの1/4程度となりました。次に小さかったのが「Fastest」とした場合のGZip圧縮で、Optimalの場合と比較しても若干容量が増えた程度でした。しかしながら、今回のデータはかなり規則正しいデータが並んでいます。そのため、圧縮率が高くなった可能性がありますのでご注意ください。テキストファイルの場合は非圧縮のバイナリの倍近い容量になりました。MicrosoftのC#で「e6」で書式設定した場合には仮数部が8文字、指数部が5文字(1.345678e+345)の13文字となり、改行コードと合わせると1つの数字に15byteを使って記録していることとなります。そのため、非圧縮バイナリの1.875倍(15/8)の容量となりました。なお、6桁の精度というと単精度浮動小数点型と概ね同程度になりますので、計算結果を単精度浮動小数点型とした非圧縮バイナリと比べると3.75倍(15/4:sizeof(float))となります。

上記の結果を見ると、ディスク容量に問題が無い場合は非圧縮のバイナリが最も効率が良いことがわかります。一方で、ディスク容量を節約したい場合にはFastestとしたGZip圧縮が良さそうです。OptimalとしたGZip圧縮はFastestの場合よりも大幅に書き込み(圧縮)に時間がかかる一方で、圧縮率はそれほど向上しないという結果になりました(他のデータでも同様の傾向でした)。また、意外にもテキストファイルの読み書きの速度が健闘していました。テキストファイルは値を人間の目で見やすいという強みがあるので、結果のチェックのような場合には良いかもしれません(先頭の一部だけを開いたり、チェック用に一部だけ出力するなど)。

最終的な結論は「目的やディスク容量によりけり」という面白くない結論になりますけど、OptimalのGZip圧縮はメリットが少なそうと言うことはわかりました(他のデータでも処理時間がかかる割に圧縮率も向上しなかった)。

最後に、上記の実験で用いたプログラムのコードを貼り付けておきます。

//最上部に下記が書かれていること
//using System.IO;

const int iTry = 10;
const int iL = 128 * 1024 * 1024;
const string sOutF = @"C:\test\01_圧縮なし.bin";

static void Main(string[] args) {
  using(StreamWriter sw = new StreamWriter(sOutF+"_log.csv", false, Encoding.ASCII)) {
    sw.WriteLine("No.,Write,Read");
    for (int i = 1; i < iTry; i++) {
      Console.Write("{0}/{1} ", i, iTry);
      if (!File.Exists(sOutF)) { File.Delete(sOutF); }
      
      //処理開始時刻を記録
      DateTime dtS = DateTime.Now;
      using (FileStream fs = new FileStream(sOutF, FileMode.Create))
      using (BinaryWriter bw = new BinaryWriter(fs)) {
        for (int j = 0; j < iL; j++) {
          //13で割っているのは小数点になりやすくするため
          bw.Write(BitConverter.GetBytes(j / 13d), 0, sizeof(double));
        }
      }
      Console.Write("w");

      double dTw = (DateTime.Now - dtS).TotalSeconds;
      dtS = DateTime.Now;
      byte[] buf = new byte[sizeof(double)];
      using (FileStream fs = new FileStream(sOutF, FileMode.Open, FileAccess.Read))
      using (BinaryReader br = new BinaryReader(fs)) {
        for (int j = 0; j < iL; j++) {
          br.Read(buf, 0, buf.Length);
          
          //値が正しいかチェック
          if (BitConverter.ToDouble(buf, 0) != j / 13d) { throw new Exception("Err"); }
        }
      }
      double dTr = (DateTime.Now - dtS).TotalSeconds; //処理終了時刻
      sw.WriteLine("{0},{1:f1},{2:f1}", i, dTw, dTr); //書き出し
      Console.Write("r\n");
    }
  }
}
//最上部に下記が書かれていること
//using System.IO;
//using System.IO.Compression;

const int iTry = 10;
const int iL = 128 * 1024 * 1024;
const string sOutF = @"C:\test\03_GZipFastest.gz";

static void Main(string[] args) {
  using (StreamWriter sw = new StreamWriter(sOutF + "_log.csv", false, Encoding.ASCII)) {
    sw.WriteLine("No.,Write,Read");
    for (int i = 1; i < iTry; i++) {
      Console.Write("{0}/{1} ", i, iTry);
      if (!File.Exists(sOutF)) { File.Delete(sOutF); }
      DateTime dtS = DateTime.Now;

      //OptimalとFastestはGZipStreamのコンストラクタのCompressionLevel列挙型で指定
      using (FileStream fs = new FileStream(sOutF, FileMode.Create))
      using (GZipStream gz = new GZipStream(fs, CompressionLevel.Fastest)) {
      //using (GZipStream gz = new GZipStream(fs, CompressionLevel.Optimal)) {
        for (int j = 0; j < iL; j++) {
          gz.Write(BitConverter.GetBytes(j / 13d), 0, sizeof(double));
        }
      }
      Console.Write("w");

      double dTw = (DateTime.Now - dtS).TotalSeconds;
      dtS = DateTime.Now;
      byte[] buf = new byte[sizeof(double)];
      using (FileStream fs = new FileStream(sOutF, FileMode.Open, FileAccess.Read))
      using (GZipStream gz = new GZipStream(fs, CompressionMode.Decompress)) {
        for (int j = 0; j < iL; j++) {
          gz.Read(buf, 0, buf.Length);
          if (BitConverter.ToDouble(buf, 0) != j / 13d) { throw new Exception("Err"); }
        }
      }
      double dTr = (DateTime.Now - dtS).TotalSeconds;
      sw.WriteLine("{0},{1:f1},{2:f1}", i, dTw, dTr);
      Console.Write("r\n");
    }
  }
}
//最上部に下記が書かれていること
//using System.IO;

const int iTry = 10;
const int iL = 128 * 1024 * 1024;
const string sOutF = @"C:\test\04_Text.txt";

static void Main(string[] args) {
  using(StreamWriter swL = new StreamWriter(sOutF+"_log.csv", false, Encoding.ASCII)) {
    swL.WriteLine("No.,Write,Read");
    for (int i = 1; i < iTry; i++) {
      Console.Write("{0}/{1} ", i, iTry);
      if (!File.Exists(sOutF)) { File.Delete(sOutF); }
      DateTime dtS = DateTime.Now;
      using (StreamWriter swV = new StreamWriter(sOutF + ".txt", false, Encoding.ASCII)) {
        for (int j = 0; j < iL; j++) {
          swV.WriteLine("{0:e6}", j / 13d);
        }
      }
      Console.Write("w");

      double dTw = (DateTime.Now - dtS).TotalSeconds;
      dtS = DateTime.Now;
      byte[] bt4 = new byte[4];
      using (StreamReader srV = new StreamReader(sOutF + ".txt", Encoding.ASCII)) {
        for (int j = 0; j < iL; j++) {
          double dR = double.Parse(srV.ReadLine());
          double dC = j / 13d;
          
          //誤差が許容範囲か
          if (1e-5 < Math.Abs(dR / dC - 1)) { throw new Exception("Err"); }
        }
      }
      double dTr = (DateTime.Now - dtS).TotalSeconds;
      swL.WriteLine("{0},{1:f1},{2:f1}", i, dTw, dTr);
      swL.Flush();
      Console.Write("r\n");
    }
  }
}

2021.9.7追記: FileStreamを読み込みで開くときに「FileAccess.Read」を指定するようにしました。