目次


Perlを最適化する

コードから、できる限り余分なものを削り取る

お粗末なプログラミングで、お粗末なパフォーマンス

正直に言いましょう。私はPerlが好きで、どこにでもPerlを使います。Webサイトも書きましたし、管理スクリプトも書きましたし、ゲームもPerlを使って書きました。宝くじの数字から株価の数字まで、時には私のeメールの自動ファイリングまで、Perlに自動的に何かをさせたりチェックさせたりすることによって、時間を節約できるのです。ただPerlを使うと、非常に簡単にこれらができてしまうので、最適化が忘れられがちです。多くの場合、最適化を忘れたからといってこの世の終わりになるわけではありません。株価のレポートを調べたり、ログ・ファイルを構文解析したりするのに数ミリ秒かかったところで、どうということはないのです。

ところが、小さなアプリケーションでは数ミリ秒に過ぎない、このお粗末な習慣が、大規模な開発プロジェクトでは何倍にも増幅されます。これはPerlのお念仏、TMTOWTDI (There's More Than One Way To Do It: 他にも手がある)が問題となり得る側面の一つです。スピードが必要な場合、最速の結果を得るための方法は1つか2つしかないかもしれませんが、遅くてよければどんな方法でもできてしまうものです。究極的に言うと、お粗末なプログラミングは、(たとえ望む結果が得られたとしても)お粗末なパフォーマンスで終わるものです。ですからこの記事では、Perlアプリケーションから余分なサイクルを削り取るために鍵となる手法の幾つかを見て行きます。

最適化に近づく

最初に、Perlはコンパイル言語であることを忘れないことが重要です。皆さんの書くソースコードは、そのまま実行されるバイトコードにコンパイルされます。バイトコード自体は広範な命令に基づいていますが、そうした命令はどれも、高度に最適化された形式のCで書かれています。ただしそうした命令であっても、(結果は同じであっても)一部の命令は他の命令よりも、より高度に最適化されている場合があります。これは全体として、使われている論理シーケンスと、そこから生成されるバイトコードの組み合わせが最終的なパフォーマンスに影響することを意味しています。似たような操作でも、ある場合には劇的な差が現れます。リスト1と2にあるコードを考えてみてください。どちらも連結ストリングを作りますが、一方は通常の連結を用いており、もう一方は配列を生成してそれをjoinで連結しています。

リスト1. ストリングを連結する、バージョン1
my $string = 'abcdefghijklmnopqrstuvwxyz';
my $concat = '';
foreach my $count (1..999999)
{
    $concat .= $string;
}
リスト2. ストリングを連結する、バージョン2
my $string = 'abcdefghijklmnopqrstuvwxyz';
my @concat;
foreach my $count (1..999999)
{
    push @concat,$string;
}
my $concat = join('',@concat);

リスト1を実行すると1.765秒ですが、リスト2では5.244秒かかります。どちらもストリングを生成しているのですが、何が時間を取っているのでしょう。(Perlのチームも含めて)従来の知恵で考えると、ストリングの連結というのは時間のかかるプロセスです。これは、変数に対するメモリー割り当てを拡張してから、ストリングとそれに追加されたものを新しい変数にコピーする必要があるためです。逆に、配列にストリングを追加するのは比較的容易なはずです。ここでは、join()を使ってストリング連結を繰り返すという、もう一つの問題があり、これによって1秒余計に時間がかかっています。

この例での問題点は、配列の中にストリングをpush()するのは時間を食うものだ、ということです。つまり(アイテムをスタックにpushし、また後で取り出すという)ファンクション・コールがあり、その上に配列管理のオーバーヘッドがあります。対照的にストリングの連結では単に、既存のストリング変数にストリング変数を付加するopcodeを実行するだけです。オーバーヘッドを軽減するために($#concat = 999999を使って)配列サイズを設定したとしても、もう1秒減らせるだけです。

上記の例は極端なものであり、配列を使ったほうがストリングを使うよりもずっと早い場合もあります。その良い例が、特定なシーケンスを異なった順序で、あるいは別の文字を入れて文字列に境い目を作って再利用する必要があるような場合です。当然ながら配列はまた、内容を再配置したり再整列したりする場合には便利なものです。ちなみにこの例でアルファベットを999,999回繰り返すストリングを生成する方法として、さらに早い方法は下記を使うことです。

$concat = 999999 x 'abcdefghijklmnopqrstuvwxyz';

個々に見ると、ここで説明した手法の多くでは大きな差が現れるわけではありません。ところが一つのアプリケーションの中で組み合わせると、何百ミリ秒か、あるいは何秒かをPerlアプリケーションから削減できるのです。

リファレンスを使う

大きな配列やハッシュを扱い、ファンクションの引き数として使う場合には、変数の代わりに直接リファレンスを使うようにします。リファレンスを使うことによって、その情報を指すようにファンクションに対して伝えるのです。リファレンスを使わないと、配列やハッシュ全体をファンクション・コールのスタックにコピーし、それから再度ファンクション内でもコピーすることになります。リファレンスはメモリーも節約します(使用メモリーも管理オーバーヘッドも少なくなります)。またプログラミングも単純化できます。

ストリング処理

アプリケーションの中で静的ストリングを多用している場合、例えばWebアプリケーションなどでは、ダブルクォートではなくシングルクォートを使うようにします。ダブルクォートを使うと、Perlに対して強制的に潜在的な補間情報を探させることになり、次のストリングを出力するオーバーヘッドを追加することになってしまいます。

print 'A string','another string',"\n";

また私は最初にストリングを連結するために、引き数の区切りにはピリオドではなく、カンマを使いました。これによってプロセスが簡単になります。printは単純に、各引き数を出力ファイルに送ります。連結はストリングを連結し、それを一つの引き数として出力するのです。

ループ

ご覧の通り、引き数のついたファンクション・コールは高価なものです。これはファンクション・コールが動作するためには、Perlは引き数をコール・スタックに置き、ファンクションをコールし、それから再度スタックを通してレスポンスを受け取る必要があるためです。このそれぞれに対して、恐らく必要のないオーバーヘッドや処理が必要になってくるのです。この理由から、ループの中でファンクション・コールを使いすぎるのは普通、良いこととは言えません。ここでも数字の比較になってきます。1,000個のアイテムをループしてファンクションに情報を渡せば、1,000回のファンクション・コールをトリガーすることになります。これを避けるためには、単にシーケンスを逆にします。リスト3のフォーマットを使う代わりに、リスト4の手法を使うのです。

リスト3. ファンクションを呼ぶループ
foreach my $item (keys %{$values})
{
    $values->{$item}->{result} = calculate($values->{$item});
}
sub calculate
{
    my ($item) = @_;
    return ($item->{adda}+$item->{addb});
}
リスト4. ループを使うファンクション
calculate_list($values);
sub calculate_list
{
    my ($list) = @_;
    foreach my $item (keys %{$values})
    {
        $values->{$item}->{result} = ($item->{adda}+$item->{addb});
    }
}

この例のような単純な計算や、他の単純なループ処理に対してもっと良いのは、mapを使うことです。

map { $values->{$_}->{result} = $values->{$_}->{adda}+$values->{$_}->{addb} } keys %{$values};

また、ループを繰り返す度に時間を浪費することも忘れないでください。ですから、同じループを何度も繰り返すよりも、そのループを通るうちに全てのアクションを一つのパスで済ませるように心がけるのです。

並べ替え

ループに関連した一般的な操作として、情報の並べ替え、特にハッシュ中のキーの並べ替えがあります。この場合では、リスト要素に対する何らかの処理を、並べ替え操作の中に埋め込んでしまいたくなるものです。一例をリスト5に示します。

リスト5. 悪い並べ替え
my @marksorted = sort {sprintf('%s%s%s',
      $marked_items->{$b}->{'upddate'},
      $marked_items->{$b}->{'updtime'},
      $marked_items->{$a}->{itemid}) <=>
      sprintf('%s%s%s',
            $marked_items->{$a}->{'upddate'},
            $marked_items->{$a}->{'updtime'},
            $marked_items->{$a}->{itemid}) } keys %{$marked_items};

これは複雑なデータの並べ替えとして、ごく典型的なものです。この例では、日付や時間、ID番号で並べ替えるために、数字順に並べ替えられるように数字を連結して一つの数字にしています。問題は、並べ替えはアイテムのリスト全体に対して動作し、比較操作の結果に基づいてアイテムをリスト内で上下に移動するということです。これは実質的には一種のループですが、先に見たループの例とは異なり、比較の度にsprintfコールが行われる必要があります。これは繰り返し毎に最低2回ということであり、また実際の繰り返し回数は、最初にリストがどの程度順序が揃っていたかに依存します。例えば10,000アイテムのあるリストでは、240,000回以上のsprintfコールをする可能性もあります。

これを解決するには、並べ替え情報を含むリストを作り、並べ替えフィールドの情報を一度だけ生成するようにすることです。リスト5の例を元にして、リスト6のようなコードに書き直すことにします。

リスト6. 改善された並べ替え
map { $marked_items->{$_}->{sort} = sprintf('%s%s%s',
      $marked_items->{$_}->{'upddate'},
      $marked_items->{$_}->{'updtime'},
      $marked_items->{$_}->{itemid}) } keys %{$marked_items};
my @marksorted = sort { $marked_items->{$b}->{sort} <=>
      $marked_items->{$a}->{sort} } keys %{$marked_items};

毎度毎度sprintfをコールする代わりに、ハッシュの並べ替えフィールド生成のために各アイテムに対して一度だけsprintfを呼ぶようにし、並べ替えの時には、直接その並べ替えフィールドを使うようにします。並べ替えプロセスは、並べ替えフィールドの値にアクセスすればよいだけです。これによって10,000アイテムのハッシュでのコールを、240,000回から単に10,000回のみにまで減らすことができます。その並べ替え部分で元々何をしているかに依存しますが、リスト6に示す方法を使うことによって、半分程度の時間にまで減らすこともできます。

こうしたハッシュをデータベースへのクエリー(MySQLや類似のもの)の結果から作る場合には、つまりクエリー内で並べ替えを使ってからハッシュを構築しつつ順序を記録することによってハッシュを作る場合には、その情報に対して再度繰り返す必要はありません。

短絡ロジックを使う

並べ替え操作に関連して、代替値のリストをどのように処理するかという問題もあります。ifステートメントを多く使うと、信じられないほど時間を消費するのです。例えばリスト7のコードを見てください。

リスト7. 選択をする
if ($userchoice > 0)
{
    $realchoice = $userchoice;
}
elsif ($systemchoice > 0)
{
    $realchoice = $systemchoice;
}
else
{
    $realchoice = $defaultchoice;
}

内容に比較して空間を浪費していることとは別に、この構造には幾つかの問題があります。プログラミング的には、これらの変数に有効な値があるかどうかをチェックしていないという問題があり、これは警告がオンになっていると大きく目立つことになります。さらに、望むものにたどり着くまで、それぞれのオプションをチェックしなければなりません。(特にストリングの)比較操作は時間がかかることを考えれば、これは無駄なことです。どちらの問題も短絡ロジックを使うことで解決できます。

Perlはlogical||operatorを使用すると、左から右の順番で、突き当たる最初の真(true)の値を使います。有効な値を見つけると、他の値の処理には全く関知しません。さらに、Perlは真の値を探しているので、未定義の値があっても文句を言わず、そうした未定義の値も無視するのです。ですから上の例を、次のような一行に書き換えることができます。

$realchoice = $userchoice || $systemchoice || $defaultchoice;

もし$userchoiceが真の値であれば、Perlは他の変数を見ることもしません。$userchoiceが偽の場合(表1)には、最後の値に行き着くまで、(最後の値は真偽によらず必ず使われます)$systemchoiceなどの値をチェックします。

表1. $userchoiceの値
論理値
負の数
ゼロ
正の数
空のストリング
空でないストリング
未定義の値
空のリスト(ハッシュを含む)
最低一つの要素を持つリスト(ハッシュを含む)

AutoLoaderを使う

Perlスクリプトを実行する上で最も高くつくのは、ソースコードをコンパイルして実際に実行されるバイトコードを作る部分です。外部モジュールのない小さなスクリプトでは、このプロセスはミリ秒程度の時間しかかかりません。ところが自分独自の外部モジュールを幾つか含め始めると、時間が長くかかるようになります。この理由として、Perlはテキストをインポートして同じコンパイル段階で実行すること以外、モジュールに対してほとんど何もしないのです。これによって、200行のスクリプトがすぐに、10,000行とか20,000行のスクリプトになってしまいます。その結果、スクリプトが何も仕事をしないうちに、コンパイル・プロセスの初期段階が増加してしまうのです。

スクリプトを普通に実行している時には、こうしたモジュールの中で定義された全ファンクションの中で実際に使うのは10%か、あるいはもっと少なく、5%程度でしょう。そうだとすると、スクリプトが起動する時になぜ全てをロードする必要があるのでしょうか。この解決方法としては、Perlモジュールに対してちょっとした動的ローダーのように動作する、AutoLoaderを使うことです。これには、モジュールを個々のファンクションに分割するAutoSplitによって生成されたファイルを使います。useでモジュールをロードする時には、モジュールのスタブ・コードをロードすることだけです。モジュール内部に含まれているファンクションをコールする時にだけAutoLoaderが入り込んできて、そのファンクション用のコードだけをロードしてコンパイルするのです。その結果、モジュールを持つ、その20,000行のスクリプトが変換されて200行のスクリプトに戻り、初期ローディングとコンパイル段階が高速化されることになります。

私はプリローディングの代わりにAutoLoaderシステムを使うようにアプリケーションを変換することで、2秒も節約したことがあります。これを使うのは簡単で、単にリスト8に示すフォーマットからリスト9に示すものにモジュールを変更し、必要なロード機能を作るのに必ずAutoSplitを使うようにするだけです。これでExporterを使う必要が無くなることに注意してください。個々のファンクションを明示的にリストしなくても、AutoLoaderは自動的にロード処理するのです。

リスト8. 標準モジュール
package MyModule;
use OtherModule;
require 'Exporter';
@EXPORT = qw/MySub/;
sub MySub
{
   ...
}
1;
リスト9. 自動ロード・モジュール
package MyModule;
use OtherModule;
use AutoLoader 'AUTOLOAD';
1;
__END__
sub MySub
{
   ...
}

ここでの主な違いは、自動ロードしたいと思っているファンクションはもはやモジュールのパッケージ空間内では定義されておらず、モジュールの最後(__END__トークンの後)にあるデータ・セクションで定義されているという点です。AutoSplitは、ここで定義される全てのファンクションを、特別なAutoLoaderファイルに置きます。モジュールを分割するには、次のコマンドラインを使います。

perl -e 'use AutoSplit; autosplit($ARGV[0], $ARGV[1], 0, 1, 1)' MyModule.pm auto

バイトコードとコンパイラー・バックエンドを使う

コンパイラーには3つの使い方があります。バイトコード生成、完全コンパイル、そして単純なデバッグ/最適化ツールとして、という使い方です。最初の2つでは元のPerlソースを、コンパイルしたバイトコード形式に変換し、このプリコンパイル版を実行用に保存します。これがperlccコマンドで一番よく使われるモードです。この2つのモードは同じ基本モデルに従っていますが、最終結果は異なります。バイトコード・モードでは、コンパイルしてできるバイトコードは別のPerlスクリプトに書き出されます。このスクリプトはByteLoaderプリアンブルから成り、コンパイルされたコードはバイト・ストリングとして保存されます。このバイトコード版を作るには、perlccコマンドに対して-Bオプションを使います。例えば下記のようになります。

$ perlcc -B script.pl

これで、a.outというファイルができます。ところが、出力はあまりWebに使いやすいものではありません。できあがるファイルは、どんなプラットフォームの、どんなPerlでも実行することができます(Perlバイトコードはプラットフォームに依存しません)。

$ perl a.out

これは、ソースコードからバイトコードへとPerlが毎回スクリプトをコンパイルする必要が無いようにしているのです。代わりに、単に生成されたバイトコードを実行するだけになります。これはJavaコンパイルの背景にあるプロセスに似ており、実際、本当に言語をコンパイルした形式とはワンステップ離れたものになっているという点では同じです。短いスクリプトでは、特に外部モジュールを幾つか使うようなものでは、特別早くなったとは感じられないでしょう。ところが、多くの外部モジュールを持たない「スタンドアローンの」大きなスクリプトでは、目立って早くなることに気がつくはずです。

完全コンパイル・モードもほとんど同じですが、異なるのは、コンパイル済みバイトコードが埋め込まれたPerlスクリプトを生成する代わりに、perlccはCソースに埋め込まれたものを生成します。このCソースが、完全なスタンドアローンの実行可能ファイルにまでコンパイルされます。これはクロス・プラットフォームの互換性はありませんが、これによってソースを渡すことなく、実行可能形式のPerlスクリプトを配布できるようになります。ただし、これはPerlをCに変換しているわけではなく、単にPerlバイトコードをCベースのアプリケーションの中に埋め込むだけであることに注意してください。これは実はperlccのデフォルトのモードなのです。ですから単純に$ perlcc script.plとするだけで、a.outと呼ばれるスタンドアローンのアプリケーションが作られてコンパイルされます。

コードのデバッグや最適化のためのソリューションとして、あまり知られていないのは、数多くある「バックエンド」と共にPerlコンパイラーを使う方法です。

バックエンドというのは実はperlccコマンドを駆動するものであり、バックエンド・モジュールを直接使って調査可能なCソース・ファイルを作ることができます。Perlコンパイラーは、生成されたバイトコードから様々な方法で結果を出力することで動作します。コンパイル段階で生成されたopcodeを見ているので、Perl自体の内部最適化が適用された後のコードを見ていることになります。ですからPerl opcodeを知っている人であれば、潜在的なボトルネックがどこにあるかを特定することができるようになります。デバッグの観点から見ると、Terse(これ自体がConciseのラッパーです)やShowlexなどのようなバックエンドを使います。リスト10を見れば、Terseバックエンドを使うと元のリスト1がどのようになるかが分かるでしょう。

リスト10. バイトコードを調べるためにTerseを使う
LISTOP (0x306230) leave [1]
    OP (0x305f60) enter
    COP (0x3062d0) nextstate
    BINOP (0x306210) sassign
        SVOP (0x301ab0) const [7] PV (0x1809f9c) "abcdefghijklmnopqrstuvwxyz"
        OP (0x305c30) padsv [1]
    COP (0x305c70) nextstate
    BINOP (0x305c50) sassign
        SVOP (0x306330) const [8] PV (0x180be60) ""
        OP (0x306310) padsv [2]
    COP (0x305f20) nextstate
    BINOP (0x305f00) leaveloop
        LOOP (0x305d10) enteriter [3]
            OP (0x305cf0) null [3]
            UNOP (0x305cd0) null [141]
                OP (0x305e80) pushmark
                SVOP (0x3065d0) const [9] IV (0x180be30) 1
                SVOP (0x3065f0) const [10] IV (0x1801240) 999999
        UNOP (0x305ee0) null
            LOGOP (0x305ec0) and
                OP (0x305d50) iter
                LISTOP (0x305e60) lineseq
                    COP (0x305e10) nextstate
                    BINOP (0x305df0) concat [6]
                        OP (0x305d70) padsv [2]
                        OP (0x305dd0) padsv [1]
                    OP (0x305ea0) unstack
concat1.pl syntax OK

他のツール

ここまで説明してきたのは全て、アプリケーションを構成するコードに対するものです。問題の大部分はコード部分にあるものですが、究極的にはパフォーマンスを向上するように、コードの中にある問題を特定し、見つけ出すために使用できるツールやシステムもあります。

Warnings/strict実行

一般的に推奨されるものですが、本当に差が出るのです。変数の使い方やタイプミス、その他の矛盾など、何もおかしなことが起きていないかどうかをwarningsstrictプラグマ(pragmas)を使って確認します。これらを全てのスクリプトで使うことで、パフォーマンスのボトルネックとなり得るような、あらゆる種類の問題を防ぐことができます。これらのプラグマが拾い上げてくれる一般的な失敗項目としては、曖昧な参照や逆参照、未定義値の使用があり、またこのプラグマには、未使用または未定義ファンクションの元になっているタイプミスの特定を助けるようなヘルプもあります。

ただしこれには、僅かながらパフォーマンスのコストを伴います。私はプログラミングやデバッグの時にはwarningsstrictをオンにしておきますが、そのスクリプトが実世界で使えるようになったらオフにしています。大幅な節約にはなりませんが、ミリ秒単位でも、やがて影響してくるのです。

プロファイリング

プロファイリングはコード最適化のツールとしては便利ですが、プロファイリングがすることというのは潜在的な問題がどの場所にあるかを特定することだけであり、その問題が何なのか、とか、どのように解決するかを実際に指定するわけではないのです。またプロファイリングは、アプリケーションの様々な部分の実行回数の監視に依存しているので、場合によると、問題の場所について、そしてその最適な解決方法に関して、誤った助言をすることがあるのです。

とはいえプロファイリングはやはり便利であり、最適化プロセスの中では、しばしば不可欠なものとなります。ただし、知りたいことを全て教えてくれるなどと期待すべきではありません。

デバッグ

私としては、不適当に最適化されたプログラムというのはバグがあることだと思います。逆もまた真です。つまり、バグは多くの場合、パフォーマンスの問題につながります。古典的な例としては、不適切に逆参照された変数や、間違った情報を読み取ったりフィルターしたりすることなどがあります。デバッグ手法としてprintステートメントを使っているのかPerlに提供されている本格的なデバッガーを使っているのかに関わらず、バグの除去は早ければ早いほど、アプリケーションの最適化を開始できるのです。

まとめ

これで手法は理解できたと思いますので、それらをどのように組み合わせれば最適化したアプリケーションを生み出せるのかをまとめてみましょう。私は普通、下記のような手順に従うことで最適化を行っています。

  1. 上記の手法を使って、可能な限り最適化したプログラムを書きます。こうした手法を定常的に使うようになれば、それ以外のプログラム方法は使わないようになります。
  2. プログラムが終了したら、あるいは少なくともリリースできる状態になったら、二重チェックとして最も効率的な手法である、コードを読むことでチェックします。単に再度見るだけで、幾つかの問題を見つけられ、場合によると潜在的なバグも見つけられる可能性もあります。
  3. プログラムをデバッグします。バグはパフォーマンスの問題を引き起こします。ですから、高度な最適化を進める前に、まずバグを取り去るようにすべきです。
  4. プロファイラーを実行します。私は重要なアプリケーションでは必ずこれを一度行います。何か・・・多くの場合は明白ですが・・・見逃していないかどうかを念のため確認するのです。
  5. ステップ1に戻って繰り返します。私は第1回目では最適化しきれなかったことが何度もあります。ですから、元に戻って一度に2、3回同じプロセスを繰り返すか、あるいはそのままにしておいて他のプロジェクトの作業をし、何日か何週か、あるいは何ヶ月後かに戻って来るようにします。何週間か何ヶ月かすると、時間が短縮できるような代替手段に気が付くことがよくあるものです。

結局の所、皆さんのソフトウェアを最適化してくれるような魔法の杖があるわけではありません。デバッガーやプロファイラーを使ったとしても、何がパフォーマンス問題の原因らしいのか、という情報が手に入るだけであって、問題を修正するために何をすべきかという助言が得られるとは限りません。また、最適化できることには限界があることを意識する必要があります。操作によっては、完了するためにとにかく長い時間を要するものもあります。10,000アイテムのハッシュを相手にするのであれば、そのプロセスを単純化する方法はありません。ただしご覧になった通り、それぞれの場合では、オーバーヘッドを削減する方法があるかも知れないことは念頭に置くべきでしょう。


ダウンロード可能なリソース


関連トピック

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Linux
ArticleID=226802
ArticleTitle=Perlを最適化する
publish-date=10192004