isucon7予選のアプリをRustに移植したから解説するね

κeenです。こういう流れがあったので移植しました。

まずISUCONを知らない方に雑に説明しておくと、意図的に遅く作られたWebアプリケーションが与えられるので7時間くらいでどれくらい高速化できるかを競うコンテストです。 このお題のWebアプリケーションが参加者や流行りに合わせて複数言語で提供されるのですが、今年はRustが来そうだということで参加者の肩慣らしのために過去問を移植しましたというお話。

ひとまずソースコードは こちら

手元でベンチマークをしてみた限り、Pythonの2倍くらいは速いもののGoには劣るようでした。これの考察については後で書きますが、1つ注意してほしいのは実際の予選では1コアマシンが複数与えられたそうですが手元では16コア/32スレッドマシンでベンチマークを取っているので実戦でのスコアを反映するものではありません。

使い方はREADMEを見てもらうとして、以下は主にライブラリやコード例などを解説します。

ライブラリ

RustのWebアプリケーションフレームワーク

出来る限り非同期フレームワークを使いたいですが、今Rustの非同期は丁度バタバタしている領域なので決定版といえるものがありません。

今回検討したというか実際に途中まで書いてみたのは以下の3つ

  • tower-web - mioやtokioなどRustの非同期ライブラリの大本をやっているcarllerche氏によるフレームワークです。使い勝手もよく期待が持てそうでしたがまだ若く、必要なライブラリが足りなかった(具体的にはセッションサポートがなかった)のでやめました。
  • Gotham - 設計上パフォーマンスが出そうと踏んでいたのですがDBコネクションの持たせ方が分からなかったので諦めました。調べ方が悪かったのかもしれません。
  • actix-web - actorフレームワークの上に乗っかったHTTPフレームワークですがactorを無視して使うこともできます。普段の仕事でも使っていますし無難にこれを使いました。

他にもあるかと思いますが検討できていないです。他社でもactix-webの採用事例を聞くので多分外してないと思います。

その他のライブラリ

sha-1

今SHA-1を扱いたいならRustCryptoプロジェクトsha-1になると思います。気をつけてほしいのはcrates.ioにはrust-cryptoクレートも sha1 クレートもありますがどちらも別物です、 sha-1 を使いましょう。私は両方とも踏みました。

テンプレートエンジン

あまりRustでテンプレートエンジンを扱う話を聞かないので決定版がわかりません。 一応handlebarsが人気のようですし、私もよく使っているのでそれを採用しました。

速度や他のエンジンと比べたときの使いやすさはわかりません。みんなが使っている安心感があります。

JSON

serde_json一択です。

MySQL

これは迷いました。普段ならdiselを使いますがISUCONではそこまで大げさなものは必要にならないので適当に見つけたmysqlを使いました。 兄弟ライブラリにmysql_asyncもあるようですがこちらはまだ試せてないです。

余談ですがこのライブラリ、MySQLのプロトコルを自前で実装しているのでlibmysqlclientに依存しません。

エラー

actix-webを使うと自動的にFailureを使うことになります。 移植元のコードも例外は全然気にせず書いてますしこちらもブラックホールのようにエラーを全部Failureに投げ込むことにしました。

コード

Rubyのコードを見ながら移植しました。

コード全体を Result を使って書くか Future を使って書くか迷いましたが Result にしました。

mdo! などの代用品はあるものの ?記法が使えないのは結構辛かったです。

actix-webの基本的な使い方

まず、DBコネクションなどを保持するデータ型を定義します。

#[derive(Clone)]
struct Isu {
    pool: my::Pool,
    templates: Arc<Handlebars>,
}

Rubyでいう App クラスに近い役割を果たします。Appという名前はフレームワーク側で既に使われているので Isu にしました。

そしてハンドラはこう書きます。Sinatraに雰囲気を併せるためにクロージャでハンドラを書きます。

fn app(isu: Isu) -> App<Isu> {
    let mut app: App<Isu> = App::with_state(isu);

    // ...

    app = app.route(
        "/initialize",
        Method::GET,
        |state: State<Isu>| -> Result<HttpResponse, Error> {
            state.exec_sql("DELETE FROM user WHERE id > 1000", ())?;
            state.exec_sql("DELETE FROM user WHERE id > 1000", ())?;
            state.exec_sql("DELETE FROM image WHERE id > 1001", ())?;
            state.exec_sql("DELETE FROM channel WHERE id > 10", ())?;
            state.exec_sql("DELETE FROM message WHERE id > 10000", ())?;
            state.exec_sql("DELETE FROM haveread", ())?;
            Ok(http_status(204))
        },
    );
   //...
}

actix-webはハンドラの引数にほしいものを書いたら自動で渡してくれるタイプのフレームワークです。 State<Isu> がRubyの App 内での self に近い存在です。 Deref<Target = Isu> を実装しているので Isu のメソッドがそのまま使えます。

パスパラメータ、クエリパラメータなどの取り出しは一旦型を定義してあげて

#[derive(Deserialize)]
struct ParamChannelId {
    channel_id: u64,
}

以下のようにハンドラの引数に Path<ParamChannelId と書いておくと channel_id という名前のプレースホルダから値を取得してくれます。

    app = app.route(
        "/channel/{channel_id}",
        Method::GET,
        |state: State<Isu>,
         session: Session,
         path: Path<ParamChannelId>|
         -> Result<HttpResponse, Error> {
           // ...
         }
     );

型を定義する手間はありますが #[derive(Deserialize)] のようにメタプログラミングで色々やってくれるメリットもあるので一長一短です。

これでフレームワークは大体使えると思うのであとは書いていくだけです。

JSON

マクロがあるのでjsonをそのまま書けます。

json!({
    "user": user,
    "channels": channels
})

エラー

あらゆるエラーを一旦Failureのエラーに潰してからactix-webのエラーに変換する関数です。 どんなエラーが来ても.map_err(err)で処理できるようになります。

fn err(e: impl ::failure::Fail) -> Error {
    let e: FailureError = e.into();
    e.into()
}

エラーハンドリングをまともにしないISUCON用のものなのであまり真似しないで下さい。

マルチパート

アイコンの扱いのところでマルチパートが出てきます。 actix-web は一応使えないことはないくらいのサポート具合でしたのでかなりつらい対応になりました。 MultipartItemなどのほぼプロトコルそのままマッピングしたデータ型を扱います。 POST /profile のハンドラだけ異様な形をしていますが半分がマルチパートサポートの貧弱さのせい、もう半分が非同期プログラミングのせいです。

DB

まずテープルに対応するデータ型を定義して

#[derive(Debug, Clone, Serialize, Deserialize)]
struct User {
    id: u64,
    name: String,
    salt: String,
    password: String,
    display_name: String,
    avatar_icon: String,
    created_at: NaiveDateTime,
}

DBから取得したデータとのマッピングを書いて

impl FromRow for User {
    fn from_row(row: my::Row) -> Self {
        Self::from_row_opt(row).expect("failed to deserialize data")
    }

    fn from_row_opt(row: my::Row) -> Result<Self, my::FromRowError> {
        FromRow::from_row_opt(row).map(
            |(id, name, salt, password, display_name, avatar_icon, created_at)| Self {
                id,
                name,
                salt,
                password,
                display_name,
                avatar_icon,
                created_at,
            },
        )
    }
}

使うのは1行です

fn db_get_user(&self, user_id: u64) -> Result<Option<User>, Error> {
    self.first_sql("SELECT * FROM user WHERE id = ?", (user_id,))
}

DBとデータとのマッピングはフルスタックのORMならメタプログラミングで自動生成してくれるのですがこれは軽量ライブラリなので手書きのようです。

また、データ型の定義が面倒ならタプルで取り出す方法もあります。

let (name, display_name, avatar_icon): (String, String, String)
  = state
    .first_sql("SELECT name, display_name, avatar_icon FROM user WHERE id = ?", (message.user_id,))
    .map(|opt| opt.expect("application reached inconsistent state"))?;

Rubyとの比較とか

Rubyから移植したのでRubyっぽいコードになってます。 Rustのお手本的コードは無駄がなく速いコードになるんですがそもそも遅いアプリケーションがお題なのでどこまで効率的に書くか悩ましかったです。

Rustはコストやリスクが目に見える言語です。

たとえばRubyの

pass_digest = Digest::SHA1.hexdigest(salt + password)

というコードはRustではほぼ直訳して

let pass_digest = format!("{:x}", Sha1::digest_str(&(salt.clone() + password)));

としています。しかし salt.clone() のようにデータをコピーしていたりそもそも結合する必要のない文字列を結合していたりしてあまりよろしくないです。

あるいはRubyの

statement = db.prepare('SELECT name, display_name, avatar_icon FROM user WHERE id = ?')
statement.execute(row['user_id']).first

というコードはRustでは

state.first_sql(
        "SELECT name, display_name, avatar_icon FROM user WHERE id = ?",
        (message.user_id,),
    )
      .map(|opt| opt.expect("application reached inconsistent state"))?;

と翻訳しています。 opt.expect("application reached inconsistent state") とリスクが目に見える形になっています。

上記のように基本的にRustで書くとRubyより冗長になるのですが案外Rustの方が短いケースもあります。

rubyのこのコードは

description = ''
channels.each do |channel|
  if channel['id'] == focus_channel_id
    description = channel['description']
    break
  end
end

このように翻訳されます。

let description = channels
    .iter()
    .find(|ch| Some(ch.id) == focus_channel_id)
    .and_then(|ch| ch.description.clone())
    .unwrap_or_else(|| "".into());

パフォーマンスとか

先述のとおり初期状態でPythonより速くてGoより遅かったです。 N+1クエリが仕込まれてるので最初はアプリケーションの速さはあまり問題にはならなくて、ほぼDBの速度で決まります。 そんな中Goだとgoroutineでブロッキングする部分を上手く分離できるので効率が良かったんじゃないかなと推測します。 Rustもmysql_asyncを使ったら速くなるかもしれません。

しかしそんなことより普通にN+1クエリを解消してインデックスを張ってDBを速くするのであまり初期スコアには意味がないと思います。 DBアクセスを非同期にするのはまずは筋が悪い部分を消してからでしょう。

Rustで出るチームへのアドバイス

私は参加登録してないので好き放題言えます。

言語の基本性能ではRustはGoよりは速いはずなのである程度アプリケーションにボトルネックが移ったらRustの方が有利になる可能性があります。 競技中にそこまでボトルネックが移らない可能性も十分にあります。

cargo build--release を付け忘れないようにしましょう。

cargo watchでソースが更新されたらビルドされるようにしておくと古いバイナリを見ることがないかも?

非同期コードを書く時はnightlyを使ってasync/awaitで挑む手があるかもしれません。少なくとも生のFutureだとかなりつらいので何かしらを手を用意した方がよさそうです。

cargo build--release を付け忘れないようにしましょう。

普段使わないところはライブラリ選びからになるので一通り肩慣らししておくといいと思います。

クロスコンパイルはどうせハマるので大人しくサーバでコンパイルした方がいいと思います。

cargo build--release を付け忘れないようにしましょう。

移植してみた感想

思ったより大変でした。マルチパートのユーティリティを除いても900行オーバーのアプリケーションになりました。Goが750行くらいなので20%くらい長いですね。

実装もそうですがライブラリの選定で苦労しました。 一回実装して中途半端に使いづらくて別ライブラリで実装し直したりしてました。普段扱わないことやると大変ですね。

因みにRustで書きあがった後ベンチマーカが完走するまでに出たバグは5,6個(種類)でした。 ほとんどがhandlebarsの記法由来で、Rust側ではほとんどバグはなかったです。 こういう点は静的型付き言語の良いところでしょうか。

最後に

とりあえずで実装したので荒いコードですが皆様是非練習にお使い下さい。

Written by κeen
Later article
Thoughts on GCs