Perl Hackers Hub

第17回Webアプリケーションのパフォーマンス改善(3)

(1)こちら⁠2)こちらから。

改善、確認のためのベンチマーク

特定できたボトルネックに対して修正した結果、本当に速くなっているのかどうかを確認しなくてはなりません。ここではそのためのベンチマーク手法について紹介していきます。

Benchmark.pm

Benchmark.pmはPerlの標準モジュールです。サブルーチンリファレンスなどで与えたコードを指定した回数(0を指定するとCPU時間で3秒間消費するまで)繰り返し実行し、それぞれの実行時間を計測、比較して表示します。

リスト9はDateTimeのインスタンス生成時に、タイムゾーン情報としてDateTime::TimeZoneオブジェクトを渡す場合と、文字列を渡す場合でどの程度処理速度が異なるのかを計測するベンチマークの例です図6⁠。

リスト9 DateTimeインスタンス生成のベンチマーク
use Benchmark qw/:all/;
use DateTime;
use DateTime::TimeZone;
my $name = "Asia/Tokyo";
my $tz = DateTime::TimeZone->new( name => $name );
my $results = timethese( 0, {
    obj_tz => sub { DateTime->now( time_zone => $tz ) },
    str_tz => sub { DateTime->now( time_zone => $name ) },
});
cmpthese($results);
図6 リスト9の実行結果
Benchmark: running obj_tz, str_tz for at least 3 CPU seconds...
    obj_tz: 3 wallclock secs ( 3.21 usr + 0.01 sys = 3.22 CPU) @ 3286.34/s (n=10582)
    str_tz: 3 wallclock secs ( 3.22 usr + 0.01 sys = 3.23 CPU) @ 2667.49/s (n=8616)
        Rate str_tz obj_tz
str_tz 2667/s -- -19%
obj_tz 3286/s 23% --

Benchmark.pmはPerlが使用したCPU時間をもとに比較するため、ネットワークに対して通信するようなコードを計測すると、通信の待ち時間は計測対象になりません。結果を読み取るときには注意が必要です。たとえばPerl側の処理に10ミリ秒、通信に90ミリ秒かかる処理をBenchmark.pmで計測すると、計測結果に利用されるのはPerlがCPUを利用した10ミリ秒の部分のみです。実時間で経過した100ミリ秒ではありません。

実質的な速度差を考える

計測した結果、たとえば2つの処理に2倍の速度差があったとしても、それが全体に対してどの程度影響するのかも考慮して、採用するコードを選ぶ必要があります。

一度のリクエストで1回しか呼ばれない処理について、10,000回/秒で実行できるコードと20,000回/秒で実行できるコードがあった場合、2倍高速なコードに入れ替えたとしても全体では0.05ミリ秒程度の違いしか生じません。その程度の速度差であれば、速度よりもメンテナンス性の良いコードを選ぶという判断も十分あり得ます。

しかしその処理が一度のリクエストで1,000回呼ばれるのであれば、全体では50ミリ秒程度の速度差になります。これはアプリケーションによっては、レスポンスタイムに対して有意な影響を与える差です。

Parallel::Benchmarkでの並列処理ベンチマーク

Benchmark.pmで計測できるのは、1プロセスでPerl内部のCPU時間を基準としたベンチマークでした。

本番環境同様の複数プロセスが動作する状態で、通信を伴う外部リソースの呼び出しなどを含めた実時間ベースでのベンチマークを計測する場合には、筆者の作成したParallel::Benchmarkが便利です。

Cache::Memcached::Fastでmemcachedにアクセスを行う場合のベンチマークコードはリスト10のようになります図7⁠。

リスト10 Parallel::Benchmarkでmemcachedにアクセスするベンチマーク
use Parallel::Benchmark;
use Cache::Memcached::Fast;
my $bm = Parallel::Benchmark->new(
    setup => sub {
        my ($self) = @_;
        $self->stash->{cache}
            = Cache::Memcached::Fast->new({
                servers => ["127.0.0.1:11211"],
            });
    },
    benchmark => sub {
        my ($self, $id) = @_;
        $self->stash->{cache}->get("foo");
        return 1; # score
    },
    teardown => sub {
        my ($self) = @_;
        delete $self->stash->{cache};
    },
    concurrency => 10,
    time => 3,
);
my $result = $bm->run;
図7 リスト10の実行結果
2012-08-19T13:59:06 [INFO] starting benchmark: concurrency: 10, time: 3
2012-08-19T13:59:11 [INFO] done benchmark: score 67980, elapsed 3.014 sec = 22552.610 / sec

Parallel::Benchmarkはconcurrencyの数だけ子プロセスをforkし、それぞれの子プロセスでsetupが完了するまで待ちます。そのあと、子プロセスがbenchmarkで指定されたコードを実行し、timeで指定した秒数後にteardownを処理、結果を表示して終了します。

ベンチマーク結果のスコアは、各子プロセスがbenchmarkで返した数値を合算したものを、実行時間で割り算したものが表示されます。

シグナルとParallel::Scoreboardを利用して子プロセスの制御を行うため、多数の子プロセスを起動した場合でもベンチマークの処理開始タイミングがそろいます。また、途中で[Ctrl][C]を押してベンチマークを終了させた場合でも、その時点までのスコアを集計してくれるなど、便利に使うための細かい配慮をしてあります。

Parallel::BenchmarkとFurlを使った負荷テストツール

Parallel::Benchmarkは子プロセスを複数起動して並列処理を行うため、高速なHTTPクライアントモジュールFurlと組み合わせることで、簡単に独自のHTTPベンチマークツールを作成できます[2]⁠。

10プロセスを立ち上げ、1プロセスのみがPOSTを連続で行い、そのほかのプロセスはGETを繰り返すようなベンチマークシナリオを実装すると、リスト11のようになります。

リ スト11 HTTPアクセスして負荷をかけるツールの例
use Parallel::Benchmark;
use Furl;
my $bm = Parallel::Benchmark->new(
    setup => sub {
        my ($self) = @_;
        $self->stash->{ua} = Furl->new;
    },
    benchmark => sub {
        my ($self, $id) = @_;
        my $ua = $self->stash->{ua};
        my $sub_score = 0;
        if ($id == 1) {
            my $res = $ua->post(
                "http://example.com/comment",
                [], [ comment => "foo" ]
            );
            $sub_score++ if $res->is_success;
        }
        else {
            for my $path (qw/ list board1 board2 /) {
                my $res = $ua->get("http://example.com/$path");
                $sub_score++ if $res->is_success;
            }
        }
        return $sub_score;
    },
    teardown => sub {
        my ($self) = @_;
        delete $self->stash->{ua};
    },
    concurrency => 10,
    time => 60,
);
$bm->run;

benchmarkに与えるサブルーチンリファレンスの第2引数には、forkされた子プロセスを識別するためのIDが1からの連番で渡されます。それを利用して、各子プロセスごとにアプリケーション上の別のユーザとして振る舞うような挙動を実装することで、多数のユーザが同時にアプリケーションにアクセスする場合をシミュレートした、独自のベンチマークが容易に実装できます。

HTTPのベンチマークツールとしては、Apacheに付属するApacheBench(ab)http_loadのようなシンプルなベンチマークツール、Apache JMeterのような高機能なツールなど既存のものが多数ありますが、使い慣れた言語で柔軟にカスタマイズできるベンチマークツールを作成するフレームワークとして、Parallel::Benchmarkも有用だと思います。

負荷テストの重要性

ソーシャルゲームなど、リリース直後から大きなトラフィックが予想されるアプリケーションでは、リリース前の段階で実アプリケーションに近いアクセスで負荷試験を行っておくことが、リリース後の運用に非常に役立ちます。

CPU負荷、クエリ数、レスポンスタイムなどをモニタリングした状態で負荷をかけ、あらかじめ限界値を見極めておくことで、負荷が増大したりレスポンスタイムが悪化した場合に、想定された上限に近づいているのでサーバを増設する必要があるのか、それともトラブルが発生して性能が劣化しているのかなどをすばやく見極めることができるのです。

まとめ

一口にパフォーマンスチューニングと言っても、扱う要素は多岐にわたります。対象をサーバサイドのWebアプリケーションのみに絞ったとしても、リクエストを受け取ってからレスポンスを返すまでに経由する、すべてのコンポーネントのどこかに潜んでいるボトルネックを発見し、解消しないことにはけっしてパフォーマンスは上がりません。

繰り返して強調したいことは、一番大事なのはボトルネックの見極め、ということです。プログラマとして実装を担当した場合には、自分で書いたもののどこかが悪いに違いないという予断で、確信なくコードに手を入れてしまって時間を無駄にした経験をお持ちの方も多いのではないでしょうか。限られた時間で最大限の効果を発揮するためにも、普段のシステムの様子を把握し、異変を察知してすばやくボトルネックを発見できるような運用を心がけたいですね。

さて、次回の執筆者はtokuhiromさんで、テーマは「Amon2で高速にWebアプリケーションを開発する!」です。

おすすめ記事

記事・ニュース一覧