miauのブログ

はてなダイアリー「miauの避難所」をはてなブログに移行しました

Google Code Jam 2009 の思い出 (3)

Google Code Jam 2011 も Round1 で撃沈したのでそのことでも書こうと思っているですが、もう少し検証したいこともあるので、先に一昨年&昨年の GCJ の話を吐き出しておきます。

ということで、一年前に書いた

の続きで、パフォーマンス関連の話題になります。

概観

この年私は全て Perl で解いていたんですが、GCJ 2009 Round1A の A 問題

が肝で。この問題、Small は解けたんですが Large については計算が 8 分の制限時間内にぜんぜん終わる気配がありませんでした。

ほかの人はどうだったんだろう?ということで統計ページを除いてみると・・・LL の通過率が異常に低い。

通過率を抜き出すとこんな感じ。(※2009 年に抜き出したものなので統計が変わってるかもしれません。)

Language Small Large Small通過者のLarge通過率
C 43 11 25.6%
C# 119 22 18.5%
C++ 1042 350 33.6%
Haskell 13 0 0.0%
Java 394 79 20.1%
Pascal 21 10 47.6%
Perl 37 1 2.7%
PHP 21 1 4.8%
Python 205 3 1.5%
Ruby 43 0 0.0%
Scala 6 0 0.0%
Scheme 5 1 20.0%
Erlang 1 0 0.0%
F# 2 0 0.0%
Factor 1 0 0.0%
Groovy 2 0 0.0%
Javascript 4 0 0.0%
Lisp 3 0 0.0%
Objective-C 2 0 0.0%
OCaml 2 2 100.0%
Standard ML 1 0 0.0%
Visual Basic 4 0 0.0%
Total 1971 481 24.4%

Python は Small を解いた 205 人中 3 人しか Large 通過してないし、Ruby については Small を通過した 43 人が Large の通過に失敗しているという、ちょっと壮絶な感じです。

Perl で解いた人はどうやってる?

を見ると Perl で Large を解いたのは 2 人ということになっているんですが、ErickW は C++ で計算して Perl を補助的に使っているみたいなので除外して。ちゃんと Perl で解いているのは cugbcat 一人だけと。

この解法と自分の解法を見比べてみたんですが、基本的な解き方には大差なくて、アルゴリズムがいくらか洗練されている感じ?これで 8 分以内に終わるのかな?

と、ためしに自分の環境で実行してみると・・・8 分以内に全然終わらなかった。この人の PC どれだけ高性能なんだろう・・・。でも自分のスクリプトよりは数倍早いみたい。

C だとどんなもの?

C の人のコードを適当に引っ張ってみると、Large は 42 秒くらいで計算が終わっているようで。やっぱり Perl と C じゃ実行速度が比較になりませんね。

処理を改善してみる

まず cugbcat の処理を参考に少し手直ししてみたんですけど、速度はあまり変化なく。どこでこの数倍の差が出てしまってるんだろう?

DProf でプロファイリング→あんまり意味なかった

チューニングするならちゃんとプロファイルとらないとだめだろうということで、

ここを参考にやってみました。

>perl -d:DProf multi_base_happiness.pl < large.in
>dprofpp
Total Elapsed Time = 27.94769 Seconds
  User+System Time = 27.09869 Seconds
Exclusive Times
%Time ExclSec CumulS #Calls sec/call Csec/c  Name
 157.   42.56 42.566 641651   0.0000 0.0000  main::calc
 0.06   0.015  0.015      5   0.0030 0.0030  Data::Dump::BEGIN
 0.00       - -0.000      1        -      -  Exporter::import
 0.00       - -0.000      1        -      -  warnings::BEGIN
 0.00       - -0.000      1        -      -  warnings::import
 0.00       - -0.000      1        -      -  subs::import
 0.00       - -0.000      2        -      -  strict::bits
 0.00       - -0.000      2        -      -  vars::import
 0.00       - -0.000      1        -      -  overload::BEGIN
 0.00       - -0.000      3        -      -  strict::import
 0.00       - -0.000      4        -      -  warnings::register::mkMask
 0.00       - -0.000      3        -      -  vars::BEGIN
 0.00       - -0.000      2        -      -  warnings::register::import
 0.00       -  0.015      4        - 0.0037  main::BEGIN

・・うん。メインの計算処理で時間を食ってると。そりゃそうだ。
もっと関数を小さくしないとプロファイリング意味ないなぁ。でもそうすると関数呼び出しのコストが・・・うーん。

浮動小数点の計算をなくしてみる

cugbcat のスクリプトをちまちま移植したりして念入りに比較すると、私のスクリプト

$m /= $base;

と書いていたところが、cugbcat のほうは

$m = int($m / $base);

となっていました。・・・あー、私の処理は一度割り算が発生して以降は浮動小数で計算されていたから遅かったと。とりあえずこの一行を変えるだけで cugbcat と同じくらいの速度は出るようになりました。でもまだまだ 8 分で終わるところまでは辿りつけないので、もう少しがんばってみます。

Perlソースコードに手を入れたり→無駄足

なんでこの人の Perl こんなに速いの?なんか手を入れてるんじゃないの?とか思って自分も手を入れてみることにしました。具体的には浮動小数での計算に入ってしまう可能性のある処理を整数での計算に書き換えたり。・・・とかいろいろやってて気づいたんですが、

use integer;

していればその辺はうまいこと整数だけで計算してくれるみたいで、完全に無駄足でした。use integer してれば上に書いたような int のキャストも要りませんし、整数計算しかしないときは何も考えずにつけておくとよさげです。

Inline::C を使ってみる

いざとなったらインラインで別の言語を使えるのも Perl のいいところ、ということで Inline::C に手を出してみました。

ActivePerl ではうまく動かなかったので mingw のほうで。あとパスにスペースあるとうまく動作しないようなので、スペースを含まないパスで実行する必要がありました。

どうも割り算まわりの処理がまだ重そうなので、

use Inline 'C' => q<int divi(int a, int b) { return a / b; }>

みたいに C で書いてみたり。でも Perl と C のインターフェイスの部分がまだ重そう。

use Inline 'C' => q<void divi(SV* a, SV* b) { SvIV_set(a, SvIVX(a) / SvIVX(b)); }>;

みたいにすればインターフェイスがシンプルになるかなと思ったらそうでもなく。剰余も利用する形にすればいいのかな?と

use Inline 'C' => q<
#include <stdlib.h>
void divi(SV* a, SV* b, SV* c) {
	div_t temp;
	temp = div(SvIV(a), SvIV(b));
	SvIV_set(a, temp.quot);
	SvIV_set(c, temp.rem);
}
>;

みたいにやってみると・・・処理遅くなった。もうメイン処理部分 C で書いちゃおう、と

use Inline 'C' => q<
#include <stdlib.h>
int get_next(int num, int base) {
	int next = 0;
	while (num) {
		div_t temp = div(num, base);
		next += temp.rem * temp.rem;
		num = temp.quot;
	}
	return next;
}
>;

ここまでやると、ようやく計算が 8 分前後で終わるようになりました。でもここまでやるんなら最初から C で書いたほうがいいような・・・。

その後わかったこと

OS の問題

に書いたように、Windows 上の Perl は動作が極端に遅かったりもするようです。「どういう PC 使ってるんだ?」と思ってたけど、私が使っていた環境が遅かっただけかもしれません・・・。

メモリの問題

Linux で動かすと速いのかな?ということでLinux で試しに動かしてみたんですが、メモリ不足でちゃんと実行できませんでした。中で気軽に memoize してるけど、今回は 10 種類の基数に対して最大 2,000,000 くらいまでのデータをキャッシュする必要があって、データを保持しきれないようです。Windows だとちゃんと動作したんですけど、環境によっていろいろ違うんですね・・・。

場合によっては配列を連想配列に変えることでメモリ節約できたりもするんですが、今回は処理的にそういうことはできないので、どうしたもんだか・・・。

模範解答

ということで一昨年は悩んだ末に「やっぱり LL だけじゃきついかも」と LL への情熱が冷めてしまっていたんですけど、今年見るとちゃんと解説が載ってました。

この解説によると、

  • 全組み合わせは 500 通りくらいなもんだから、あらかじめ全部計算しておいて、Small とか Large の時間内にはその結果を使う処理を動かすだけでいいんだよ。
  • あと 1,000,000 以上の計算はキャッシュしなくてもいいよね。一回の計算ですぐ収束するから 1,000 くらいまで持っておけば十分でしょ

とのことで。C とかだと力技で解けちゃってたみたいですけど、LL では力技では解きにくいってだけで、解く手段が用意されてないわけではなかったみたいです。さすが良く作られてる・・・。

Python 勢は?

Python ではふつうに解いてる人が 3 人いたわけですが、こちらも覗いてみました。

まずこちら。

なんだか洗練されたコードです。当時「なんで yield 使ってるんだろう?」って疑問を持っていたんですが、

の最後のほうに Python の yield の特徴とか載っていて納得。スタックオーバーフロー起こしにくくなると。

あと面白いのはこの解答。

import psyco してる・・・(参考: Psyco - Wikipedia)。こういう裏技っぽいのが使えるのはいいなぁ。

雑感

上にちらっと書いたとおりで、このコンテストを通して LL 一辺倒なのはちょっと問題だなぁと思ったのでした。普段生活の中で書くスクリプトの 95% くらいは速度が問題にならないけど、残りの 5% くらいで頼れるような実行速度が速い言語を身につけたい、というのがその後の目標になってました。

そんな感じで、GCJ 2010 の話に続きます。たぶん。