CPUの浮動小数点演算性能やクロック周波数を測定する

CPUのカタログスペックはメーカーが公表しているので、それを参照することもできますが、本当にそうなのか確かめてみるという話です。 最近のIntelのCPUしか持っていないので、それでしか確かめていないませんが、四種類のCPUで試してみました。

CPUのクロック周波数

CPUのクロック周波数を求めることも、意外と簡単です。依存関係が直列につながっている一連の命令列を何クロックで処理できるかを計測し、それを実時間で割ればクロック周波数が分かります。 それ以外の命令も実行する必要があるので計算が狂ってきそうに思えますが、実際には並列かつアウトオブオーダーに実行されるため、分岐予測が当たる前提のもと、それらを考慮する必要はありません。

#include <iostream>
#include <thread>
#include <chrono>
#include <atomic>

std::atomic<bool> begin;
std::atomic<bool> end;
std::atomic<double> x;

void job() {
  double a = 0.0;
  while( !begin.load() );
  while( !end.load() ) a += 1.0;
  x.store(a);
}

int main() {
  std::thread th( &job );
  const auto startT = std::chrono::system_clock::now();
  begin.store( true );
  system( "sleep 5" );
  end.store( true );
  th.join();
  const auto endT = std::chrono::system_clock::now();
  const auto time = std::chrono::duration_cast<std::chrono::nanoseconds>( endT - startT ).count();
  std::cout << (long long)x << std::endl;
  std::cout << time << std::endl;
  std::cout << x*3 / time << " GHz" << std::endl;
}

コンパイルは、-std=c++11 -pthread -O3 -march=nativeで行います。

直列に実行すべき命令は、a += 1.0;の部分です。この部分は(SandyBridge以降のAVX命令に対応している場合)vaddsd命令となります。 この命令のレイテンシ(結果が出て次の命令で使えるようになるまでの時間)は3クロックなので、加算が行われた回数の三倍がかかったクロック数になります。 これをかかった時間で割ることにより、クロック周波数を求めることができます。

実測してみた結果はこれです。十回行ってもっともよかった結果を採用します。

  • Intel(R) Core(TM) i5 2540M(ターボブースト時3.30GHz)→3.13GHz
  • Intel(R) Core(TM) i5 7200U(ターボブースト時3.10GHz)→3.06GHz*1
  • Intel(R) Xeon(R) E5-2643 v3(ターボブースト時3.70GHz)→3.66GHz
  • Intel(R) Xeon(R) E5-2603 v3(1.60GHz)→1.58GHz

おおむねカタログスペックと同じ値を実験でも得ることができました。

CPUの浮動小数点演算性能

無駄な計算をするだけであれば、CPUの浮動小数点演算性能の理論値の90%程度を出すことは簡単です。 無駄な計算は、コンパイラの最適化が見抜けないように注意して書く必要があります。

#include <iostream>
#include <chrono>
#include <thread>
#include <atomic>
#include <cmath>

constexpr int ND = 32;
constexpr int NThreads = 4;
std::atomic<bool> ready;

void task( int N_loops ) {
    double x[ND];

    for( int i = 0; i < ND; ++i ) {
        x[i] = 1e-9 * i;
    }
    while( !ready.load() );
    for( int i = 0; i < N_loops; ++i ) {
        for( int j = 0; j < ND; ++j ) {
            x[j] = std::fma( x[j], x[j], x[j] );
        }
    }
    for( int i = 0; i < ND; ++i ) {
        std::cout << x[i] << " ";
    }
    std::cout << std::endl;
}

int main( int argc, char* argv[] ) {
    const int N = std::stoi( argv[1] );
    std::thread th[NThreads];
    for( auto& t : th ) {
        t = std::move( std::thread( &task, N ) );
    }
    const auto start = std::chrono::system_clock::now();
    ready.store( true );
    for( auto& t : th ) {
        t.join();
    }
    const auto end = std::chrono::system_clock::now();
    std::cout << N * ND*2. * NThreads / std::chrono::duration_cast<std::chrono::nanoseconds>( end - start ).count() << "GFLOPS" << std::endl;
}

コンパイルは、先ほどと同じオプションで行います。NThreadsは、そのCPUの動かせるスレッド数に応じて適宜変更してください。

std::fmaを計算するには、足し算と掛け算の二演算が必要であるため、2FLOPをこなしたことになります。

理論性能は、AVXの場合、ymmレジスタdoubleが4個入り、二つの命令が同時に実行可能であるので8FLOP/clock・Coreとなります。 また、AVX2の場合は、2FLOPに相当するfma命令が同時に二命令実行可能であるため、16FLOP/clock・Coreとなります。

実験してみた結果はこれです。同様に、十回行ってみて最も良いものを採用します。

  • Intel(R) Core(TM) i5 2540M(8FLOP/clock×3.30GHz×2Core=52.8GFLOPS)→2.09GFLOPS
  • Intel(R) Core(TM) i5 7200U(16FLOP/clock×3.10GHz×2Core=99.2GFLOPS)→94.99GFLOPS

SandyBridge世代のi5 2540MはAVXしか動かせず、AVX2で追加されたFMA命令に対応していないため、std::fmaがライブラリ関数コールとなり、非常に低速になります。 そこで、以下のように計算部分を変更します。

    for( int i = 0; i < ND; ++i ) {
        x[i] = 1 + 1e-9 * i;
    }
    while( !ready.load() );
    for( int i = 0; i < N_loops; ++i ) {
        for( int j = 0; j < ND; ++j ) {
            x[j] = x[j] * x[j];
        }
    }

この時、計算部分は以下のようにコンパイルされており、最適になっています。

 .LBB0_13:                               # =>This Inner Loop Header: Depth=1
 vmulpd %ymm7, %ymm7, %ymm7
 vmulpd %ymm6, %ymm6, %ymm6
 vmulpd %ymm3, %ymm3, %ymm3
 vmulpd %ymm0, %ymm0, %ymm0
 vmulpd %ymm1, %ymm1, %ymm1
 vmulpd %ymm2, %ymm2, %ymm2
 vmulpd %ymm4, %ymm4, %ymm4
 vmulpd %ymm5, %ymm5, %ymm5
 vmulpd %ymm7, %ymm7, %ymm7
 vmulpd %ymm6, %ymm6, %ymm6
 vmulpd %ymm3, %ymm3, %ymm3
 vmulpd %ymm0, %ymm0, %ymm0
 vmulpd %ymm1, %ymm1, %ymm1
 vmulpd %ymm2, %ymm2, %ymm2
 vmulpd %ymm4, %ymm4, %ymm4
 vmulpd %ymm5, %ymm5, %ymm5
 addl $-2, %ecx
 jne .LBB0_13

結果は、以下の通りです。

  • Intel(R) Core(TM) i5 2540M(8FLOP/clock×3.30GHz×2Core=52.8GFLOPS)→48.11GFLOPS

元のstd::fmaを使ったソースコードで出力されるアセンブリコードは、ここまで品質が良くなく、途中でレジスタ移動が発生しています。 イントリンシックを使って最適な機械語コードになるようにしてみます。

#include <iostream>
#include <chrono>
#include <thread>
#include <atomic>
#include <immintrin.h>

constexpr int NR = 16;
constexpr int ND = 64;
constexpr int NThreads = 12;
std::atomic<bool> ready;

void task( long long N_loops ) {
  double x[ND];
  __m256d ymm[NR];

  for( int i = 0; i < ND; ++i ) {
    x[i] = 1e-9 * i;
  }
  for( int i = 0; i < NR; ++i ) {
    ymm[i] = _mm256_load_pd( &x[i*4] );
  }
  while( !ready.load() );

  for( long long i = 0; i < N_loops; ++i ) {
    for( int j = 0; j < NR; ++j ) {
      ymm[j] = _mm256_fmadd_pd( ymm[j], ymm[j], ymm[j] );
    }
  }
  for( int i = 0; i < NR; ++i ) {
    _mm256_store_pd( &x[i*4], ymm[i] );
  }
  for( int i = 0; i < ND; ++i ) {
    std::cout << x[i] << " ";
  }
  std::cout << std::endl;
}

int main( int argc, char* argv[] ) {
  const long long N = std::stoll( argv[1] );
  std::thread ts[NThreads];
  for( int i = 0; i < NThreads; ++i ) {
    ts[i] = std::move( std::thread( &task, N ) );
  }
  const auto start = std::chrono::system_clock::now();
  ready.store( true );
  for( int i = 0; i < NThreads; ++i ) {
    ts[i].join();
  }
  const auto end = std::chrono::system_clock::now();
  std::cout << N * ND * 2. * NThreads / std::chrono::duration_cast<std::chrono::nanoseconds>( end - start ).count() << " GFLOPS" << std::endl;
}

これを使った時の結果は以下の通りです。

  • Intel(R) Core(TM) i5 7200U(16FLOP/clock×3.10GHz×2Core=99.2GFLOPS)→98.02GFLOPS
  • Intel(R) Xeon(R) E5-2643 v3(16FLOPS/clock×3.70GHz×6Core×2Socket=710.4GFLOPS)→669.8GFLOPS
  • Intel(R) Xeon(R) E5-2603 v3(16FLOPS/clock×1.60GHz×6Core=153.6GFLOPS)→152.2GFLOPS

最高の場合、理論性能の99%程度を出すことに成功しています。E5-2643v3ではそこまで達成できていませんが、全てのコアをターボクロック周波数にできない制約によるもの(つまり括弧内の計算による理論性能は正しくない)だと思われます。

*1:vaddsd命令のレイテンシは4クロックなので該当部分を変更したソースコードで実験