libtaskとPthreadの比較

Russ Coxさんが作ったコルーチンライブラリlibtaskを試してみた。雰囲気はPlan9のlibthreadによく似ている。対応している環境は、Linux (ARM and x86)、FreeBSD (x86)、OS X (PowerPC and x86)、SunOS Solaris (Sparc)とのことだ。

コルーチンはプログラマが明示的にタスクの切り替えを記述する必要があり(プリエンプトされない)、スレッドとはモデルが違うが、より軽量な仕組みである。Pthreadは通常ユーザスレッドとカーネルスレッドが1:1に対応付けられるのに対して、コルーチンは完全にユーザレベルでの実装であり、1つのカーネルスレッド上で動く。どれくらい性能が違うのかなと思い、まずpthread_createとtaskcreateを比較するベンチマークを書いてみた。1〜1000個のスレッド(タスク)を生成するのにかかる時間を測定している。実行環境はMacOS X 10.4、Core 2 Duo/2 GHz。1000スレッド時で2.5倍弱ぐらい、libtaskが高速という結果になった。また、Pthreadはスレッド数が増えるにつれて実行時間のばらつきも大きくなっている。



なぜPthreadの結果がこんなにばらつくんでしょうね? 同様の実験をLinuxでも試してみました*1。環境はCentOS 5.2、Pentium4/3 GHz dualです。性能差は広がりましたが、バラツキは消えましたね。MacOS Xの実装*2が原因かぁ。



以下は、使用したプログラムの説明。libtaskの場合、main関数の代わりにtaskmain関数がエントリになる。taskcreate関数でタスクを作り、すぐにtaskyield関数を呼んでCPUを放棄している。

#include <stdio.h>
#include <task.h>
#include <sys/time.h>

void
do_nothing(void *arg)
{
  while (1)
    taskyield();
}

void
taskmain(int argc, char **argv)
{
  int i, n;
  struct timeval tv;
  double begin, end;

  n = atoi(argv[1]);

  gettimeofday(&tv, NULL);
  begin = tv.tv_sec + tv.tv_usec * 1e-6;

  for (i = 0; i < n; i++)
    taskcreate(do_nothing, 0, 32768);

  gettimeofday(&tv, NULL);
  end = tv.tv_sec + tv.tv_usec * 1e-6;
  printf("%d\t%g\n", n, end - begin);

  taskexitall(0);
}

続いて、Pthread版。

/* bench.pthread.c */
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <sys/time.h>

void *
do_nothing(void *arg)
{
  return;
}

int
main(int argc, char **argv)
{
  int n, i, cc;
  struct timeval tv;
  double begin, end;
  pthread_t thr[1000];
  pthread_attr_t thr_attr;

  n = atoi(argv[1]);

  pthread_attr_init(&thr_attr);
  pthread_attr_setstacksize(&thr_attr, 32768);

  gettimeofday(&tv, NULL);
  begin = tv.tv_sec + tv.tv_usec * 1e-6;

  for (i = 0; i < n; i++) {
    cc = pthread_create(&thr[i], NULL, do_nothing, NULL);
    if (cc) {
      perror("pthread_create");
      exit(1);
    }
  }

  gettimeofday(&tv, NULL);
  end = tv.tv_sec + tv.tv_usec * 1e-6;
  printf("%d\t%g\n", n, end - begin);

  for (i = 0; i < n; i++) {
    cc = pthread_join(thr[i], NULL);
    if (cc) {
      perror("pthread_join");
      exit(1);
    }
  }

  return 0;
}

蛇足だと思うが、グラフを作るまでの手順を書いておく。まず、実行出力をログファイルに吐き出す。

$ for i in {1..1000}; do ./bench.libtask $i; done > libtask.log
$ for i in {1..1000}; do ./bench.pthread $i; done > pthread.log

グラフはgnuplotを使って出力している。

$ cat bench.gp
set terminal png
set output "bench.png"
set size 0.7,0.7
set key right bottom
set xlabel "Number of threads"
set ylabel "Completion time (millisecond)"
set ytics nomirror
plot \
"pthread.log" usi 1:($2*1000) axis x1y1 ti "pthread" w l lw 2, \
"libtask.log" usi 1:($2*1000) axis x1y1 ti "libtask" w l lw 2
$ gnuplot bench.gp
$ open bench.png

ところで、この前にforkの実行時間を図ってみたんだけど、1回の呼出しに1.5ミリ秒もかかっている。一桁違う気がするんだけど、MacOS Xシステムコール呼出しってどうなっているんだろう?

*1:デフォルトのスタックサイズは10MBなので、メモリ1Gのマシンでは1000スレッドを実行できないことに気付く。ということでlibtaskの場合に合わせて32KBにした。MacOS Xの場合のグラフも(結果はほとんど変わらなかったけど)差し替えた。

*2:たしかMachスレッドにPthreadの皮をかぶせているんだよなぁ。