メモリの使い方と計算速度について

はじめに

ROOT(というか本質的にはC++)でのメモリの使い方と処理の速さについて興味を持ったので実験してみました。

C++では様々な変数を定義し計算に使用するときにその変数をメモリに格納します。 C++ではプログラムの中で使用する変数用メモリには2領域あって、一つはstack領域、もう一つはheap領域です。

stack領域は通常の変数、例えば int a; などを定義するときにそのプログラムの区切られたスコープ内でのみ生きて、外に出ると開放される変数です。 変わってheap領域はプログラムの中で動的に使用出来、任意の場所から参照出来るものです。“new"演算子を用いてheap領域にメモリを確保します。正確には、動的にheap領域を確保して、そのアドレスをstack領域に格納するということをやっています。

で、最近コードを書いているときに、loopの中でROOTの関数を使用したいことが多々あるのです。 例えば、データ点をFitしてPeak searchをしてその値をデータとして詰めなおすとか。 そういったときにTGraph, TF1のオブジェクトを作らないといけないのですが、それは

TGraph graph();
TF1 func("func","func");
graph.Fit(&func);

とオブジェクトをstack領域に確保するのが良いのか、はたまた

TGraph *graph = new TGraph();
TF1 *func = new TF1("func","func");
graph->Fit(func);
delete graph;
delete func;

とheap領域に確保して使って自分で消すのが良いのか、またまた、メモリ確保は最小にしてそれを使い回せるようにしたほうが良いのか、結局どれが早いんですかいと疑問に思ったので、実験してみました。

実験

前提条件

一応計算機のスペックなど。

  • CPU: Intel(R) Xeon(R) Processor E5-2640 (15Mキャッシュ、2.50 GHz)
  • メモリ: 31.4 GB
  • OS: CentOS 6.9
  • ROOT: 5.34.36
  • コンパイラ: GCC 4.4.7

方法

ROOTにはTStopwatchという、計算中のTime costを測定できるクラスがあるのでこれを使用します。

ヒストグラムTH1Dを1000個生成してそれぞれにGaus乱数を1000個詰め、そのヒストグラムをTF1型のGausでFitします。この時間を計測し、それを更に10000回試行して統計的に処理します。無論、計算内容などは同等になるようにします。

まずはstack領域を使用したコード

#include "TRandom.h"
#include "TH1D.h"
#include "TF1.h"
#include "TStopwatch.h"
#include <iostream>

#ifdef __ACLIC__
void stack(void) {
#else
int main(void) {
#endif

   const Int_t nHist = 1e3;
   const Int_t nData = 1e3;
   const Int_t nTime = 1e4; 
   TH1D *timehist = new TH1D("timehist_stack","Time Cost (Use stack area)",1000,0,5);
   TStopwatch *sw = new TStopwatch();
   for (Int_t iTime = 0; iTime != nTime; ++iTime) {
      Double_t constant[nHist], mean[nHist], sigma[nHist];
      sw->Start();
  // ----------------------- 計測開始 ----------------------- 
      for (Int_t iHist = 0; iHist != nHist; ++iHist) {
         TH1D hist(TString::Format("hist%d",iHist),
                   TString::Format("hist%d",iHist),
                   1000, -10, 10);
         for (Int_t iData = 0; iData != nData; ++iData) {
            hist.Fill(gRandom->Gaus(0,1));
         }
         TF1 func(TString::Format("func%d",iHist),
                  "gaus", -10, 10);
         hist.Fit(&func,"q","goff");

         constant[iHist] = func.GetParameter(0);
         mean[iHist]     = func.GetParameter(1);
         sigma[iHist]    = func.GetParameter(2);
      }
  // ----------------------- 計測終了 ----------------------- 
      sw->Stop();
      timehist->Fill(sw->CpuTime());
      std::cout << "Progress: " << (Double_t)iTime/nTime * 100 << "%" <<std::endl; 
      sw->Print();
   }
}

次にheap領域を確保・開放を繰り返すコード

#include "TRandom.h"
#include "TH1D.h"
#include "TF1.h"
#include "TStopwatch.h"
#include <iostream>

#ifdef __ACLIC__
void heap(void) {
#else
int main(void) {
#endif

   const Int_t nHist = 1e3;
   const Int_t nData = 1e3;
   const Int_t nTime = 1e4;
   TH1D *hist;
   TF1  *func;
   TH1D *timehist = new TH1D("timehist_heap","Time Cost (Use heap area)",1000,0,5);
   TStopwatch *sw = new TStopwatch();
   for (Int_t iTime = 0; iTime != nTime; ++iTime) {
      Double_t constant[nHist], mean[nHist], sigma[nHist];
      sw->Start();
  // ----------------------- 計測開始 ----------------------- 
      for (Int_t iHist = 0; iHist != nHist; ++iHist) {
         hist = new TH1D(TString::Format("hist%d",iHist),
                         TString::Format("hist%d",iHist),
                         1000, -10, 10);
         for (Int_t iData = 0; iData != nData; ++iData) {
            hist->Fill(gRandom->Gaus(0,1));
         }
         func = new TF1(TString::Format("func%d",iHist),
                        "gaus", -10, 10);
         hist->Fit(func,"q","goff");

         constant[iHist] = func->GetParameter(0);
         mean[iHist]     = func->GetParameter(1);
         sigma[iHist]    = func->GetParameter(2);

         delete hist;
         delete func;
      }
  // ----------------------- 計測終了 ----------------------- 
   	  sw->Stop();
      timehist->Fill(sw->CpuTime());
      std::cout << "Progress: " << (Double_t)iTime/nTime * 100 << "%" <<std::endl; 
      sw->Print();
   } 
}

最後に、heap領域を一度確保してそれを再利用するコード

#include "TRandom.h"
#include "TH1D.h"
#include "TF1.h"
#include "TStopwatch.h"
#include <iostream>

#ifdef __ACLIC__
void memory_reuse(void) {
#else
int main(void) {
#endif
   const Int_t nHist = 1e3;
   const Int_t nData = 1e3;
   const Int_t nTime = 1e4;
   TH1D *timehist = new TH1D("timehist_reuse_memory","Time Cost (Reuse memory)",1000,0,5);
   TStopwatch *sw = new TStopwatch();
   for (Int_t iTime = 0; iTime != nTime; ++iTime) {
      Double_t constant[nHist], mean[nHist], sigma[nHist];
      sw->Start();
  // ----------------------- 計測開始 ----------------------- 
      TH1D *hist = new TH1D("","",1000,-10,10);
      TF1  *func = new TF1("","gaus",-10,10);
      for (Int_t iHist = 0; iHist != nHist; ++iHist) {
         for (Int_t iTime = 0; iTime != nTime; ++iTime) {
            hist->Fill(gRandom->Gaus(0,1));
         }
         hist->Fit(func,"q","goff");
         constant[iHist] = func->GetParameter(0);
         mean[iHist]     = func->GetParameter(1);
         sigma[iHist]    = func->GetParameter(2);

         hist->Clear();
      }
  // ----------------------- 計測終了 ----------------------- 
      sw->Stop();
      timehist->Fill(sw->CpuTime());
      std::cout << "Progress: " << (Double_t)iTime/nTime * 100 << "%" <<std::endl; 
      sw->Print();

      delete hist;
      delete func;
   }
}

これらのコードをACLiCでコンパイルして実行させます。つまり

root [] .L stack.cc++
root [] stack()

のようにして実行します。また、コンパイラによる最適化オプション"+O"をつけてTime costが変わるかどうかもチェックしてみました。

そして更に、GCCを用いてコンパイルし、実行することもしてみました。こちらを参考に、Makefile

CPPFLAGS += `root-config --cflags`
LDLIBS   += `root-config --libs`

を作成し、make stack などとg++でコンパイルし、出来た実行ファイルを./stackなどと実行します。

結果

平均値 +/- sigmaの比較です。

  • stack: 1.048 +/- 0.020 (sec)
  • heap : 1.043 +/- 0.019 (sec)
  • reuse: 1.526 +/- 0.042 (sec)

この結果ではstack領域にメモリを確保するのとheap領域にメモリを確保し開放するのとではほぼ同じTime costになることが分かりました。ネイティブなC++ではstack領域に確保したほうがCPUキャッシュの都合上速いとのことでしたが、オブジェクトの展開にはそれなりに時間がかかるということなのでしょうか? そして、結構速いんじゃないかと密かに思っていたメモリの使い回しは実は遅いという結果に。heap領域にアクセスする回数も開放して再確保するのと変わらないし、理由が分からない…。

そして、コンパイラによる最適化をして同様の処理をさせた結果は

  • stack: 1.062 +/- 0.018 (sec)
  • heap : 1.065 +/- 0.017 (sec)
  • reuse: 1.537 +/- 0.061 (sec)

と、逆に遅くなってるんですけど…。 メモリ再利用に関してはうまいことやってくれているのではないかと思っていたのですけどね。

次に、GCCを用いてコンパイルしたバージョンです。

  • stack: 1.038 +/- 0.007 (sec)
  • heap : 1.042 +/- 0.009 (sec)
  • reuse: 1.557 +/- 0.053 (sec)

GCCでコンパイルした場合でも3つのプログラムの中での実行速度の差についての結果は変わらず。メモリの確保・開放を繰り返す処理では、僅かではあるが速くなっている。

結論

heap領域に確保するでも、stack領域に確保するでも計算速度は有意には変わらない。変わって、メモリをLoopの中で使い回すコードは約1.5倍遅くなる。

ちょっとこの結果は自分の中で懐疑的なのでもう少し詳しく勉強して、改めて考察したいと思います。

Shoichiro Masuoka

CNS, the Univ. of Tokyo. Dcotoral student

関連項目