🪑

ベンチが通る!ISUCON12予選Swift参考実装

2022/09/16に公開

背景

ISUCON12予選に参加しましたが、惜しくも敗戦しました。
チームではGo言語で参加しましたが、使い慣れない言語で実装が遅くもどかしかったです。
競技終了後、今年の問題は特に面白く感じ、使い慣れたSwiftで再実装してみたいと思いました。

WebサーバとしてのSwiftの速度を計測する意図もありました。ISUCON問題には素晴らしいベンチマークツールが付随しており、実際のサービス利用シナリオに沿った負荷をかけられます。
普段何気なく記述するコードがそれぞれどのくらいの負荷なのかを実測値として知っておくことは、実装方針を決める支えになってくれます。

できたもの

https://github.com/sidepelican/isucon12-qualify-swift

他言語の参考実装となるべく同じになるように書かれています。
競技の特性ゆえに「アレ?」って思うような実装がたくさんありますが概ね仕様です。

遊び方

リポジトリのREADME.mdに書いたのでそちらを参照してください。

https://github.com/matsuu/cloud-init-isucon/tree/main/isucon12q に沿ってセットアップすると便利です。セットアップ後、Swiftの実装をコピーするだけです。

使ったフレームワークとか

Swift 5.7

Swift5.7ではasync/await周りのコンパイラのチェック機能が強化されています。タイプセーフな正規表現リテラルも使えます。
Swift最新機能をすぐ使えるのがSwift on Serverの魅力でもありますね。
本記事の公開も、Swift5.7のDockerイメージの公開に合わせました(開発時はnightlyビルドでやってました)。

// タイプセーフな正規表現リテラル (Regex<Substring>型)
let tenantNameRegexp = #/^[a-z][a-z0-9-]{0,61}[a-z0-9]$/#

Vapor

SwiftでWebサーバを書く上で定番のWebフレームワークとなっています。
他にも細々としたフレームワークはありますが、メンテナンスされている度合いなどを考えると現状これ一択かと思います。

https://github.com/vapor/vapor

MySQLKit & SQLiteKit

ISUCON12予選問題ではSQLiteとMySQLが使われており、リファクタリングする上でそれら間を移行する作業が発生し得ます。
MySQLKitとSQLiteKitはどちらもSQLKitのインターフェースを備えており、同じインターフェースで両方のデータベースを操作できるのでこれにしました。

他にもVaporコミュニティで提供されているORMとしてFluentがあります。Fluentは上記MySQLKitなどをさらにラップする存在です。しかしこれはクラスによるミュータブルなモデルを取り扱う形になっており、値型の強いSwiftの利便性を失っています。
またISUCONの参考実装は基本的にORMを使わないため、それに合わせて利用しませんでした。

// クエリの例
let ... = try await tenantDB.sql().execute(
    "SELECT * FROM competition WHERE tenant_id=\(bind: tenant.id)"
).all(decoding: CompetitionRow.self)

tenant_id=\(bind: tenant.id)の部分に注目してください。StringInterpolationによって型安全かつその場にプレースホルダを埋め込めて便利ですね!
\(bind:)に使われてる値がEncodableかどうか検査されます。また\(name)のようなSQLインジェクションにつながる書き方は利用できなくなっています!)

実装していて気になったところ

Sendableチェックの回避

NIOのEventLoopの内部で行われる処理は並行安全になっていますが、それをコンパイラは知らないのでちょくちょくSendableチェックが通らない箇所があります。
そのような箇所のためにUncheckedBox<T>という型を用意し、Sendableチェックを迂回できるようにしました。
ただしこの迂回が本当に合法なのかどうか、自信を持ちきれない部分もあります。

// SQLiteConnectionは並行安全ではないが、このコンテキスト内では同時に触られることがないのでSendable境界をまたいでもよい
.flatMapWithEventLoop { (conn: SQLiteConnection, eventLoop: EventLoop) in
    let conn = UncheckedBox(value: conn)
    return eventLoop.performWithTask { // ← ここにSendable境界
        try await closure.value(conn.value)
    }
    ...

似た例として、Non-Sendableな@escapingクロージャ引数もあります。そのクロージャが並行安全に実行される前提であるためにSendableを不要としていますが、並行安全じゃないパターンを見過ごしていないか?という不安があります。

EventLoopからGlobal Executorへのホップによるオーバヘッド

async関数を実行するDefault Global ExecutorとNIOで使われているEventLoopは、それぞれ異なるスレッドプール上で動作します。そのため、EventLoopFuture<T>によって通知された処理をawaitで受け取る際はスレッド間の移動が発生します。

let result = try await someFuture.get() // 裏側で頻繁に行われているホップ

通常はDB待ちなど他の処理のほうが支配的なので気にしなくても良いのですが、その「気にしなくていい」はどの程度のレベルなのか、が気になっていました。

CPUブロッキングな処理を行うスレッド

EventLoopやDefault Global Executorのようなイベントループ処理において、CPUブロッキングな処理を行ってはいけません。同じイベントループに配属された後段の処理が詰まってしまいます。

CPUブロッキングな処理を行うために、SwiftNIOにはNIOThreadPoolというクラスが用意されています。専用に確保されたスレッドへ処理を逃がすことで、CPUブロッキングな処理をイベントループから見てNon-Blockingな処理に変換してくれます。

私はここで1つミスしてしまいました。NIOThreadPoolの中でロック待ちをしてしまったのです。NIOThreadPoolはスレッドを専用に確保しているとはいえ上限があり、空きスレッドがない場合は処理がキューに積まれます。これは「終了しない処理」を投げてしまうとデッドロックなどの問題が発生します。

// threadPool内でロック待ちをしていたらデッドロックしてしまった
return try await threadPool.task {
    let fl = FileLock(at: p)
    try fl.lock(type: .exclusive)
    ...
}

じゃあこのロック待ちはどこで行えばいいのかと悩みました。上限のないスレッドプールが思いつかなくて、毎回新しいスレッドを作るべきかとまで考えました。

let fl = FileLock(at: p)
let box = UncheckedBox(value: fl)
try await Task.detached(priority: .low) {
    try box.value.lock(type: .exclusive)
}.value

ところが、試しにTask.detachedで動かしてみたところうまくいきました。通常のTask.init<OSが考えたいい感じの並列数>までスレッドが用意されますが、.detachedの場合は無限に伸びていく?のでしょうか。前者はSwiftの実装の奥底まで確認しにいったのですが、後者は確認したことがなくて知らなかったです。
もしかしたら待ちプールが別になったことでたまたまデッドロックしなくなっただけかもしれないです。.detachedに詳しい方は教えてください。

ベンチマーク結果

初期スコア

言語 スコア
Go 7000くらい
Ruby 4000くらい
Swift 3200くらい

速いと評判のGo言語には勝てなくていいかと思いましたが、Rubyにも負けていました。これは残念です。Swiftは言語の仕組み的にはGoやRustと同じラインに立っているはずなので不思議でした。

おかしいと思って調べたところ、どうやら他言語と違ってSQL関連のログを全て吐いていたことが原因だったようです。他言語と同じようなログ流量になるよう調整したところ、以下のように速度が改善しました。

言語 スコア
Swift(ログ流量を他言語と揃える) 4700くらい

Rubyには勝ってますがそれでも差が小さいですね。
これをきっかけに、今使ってるログのstdout書き込み実装が遅いことにも気づきましたが、今回の問題は初期時点ではMySQLボトルネックになっていて、またSQLite側のボトルネックも大変なことになっており、言語間の速度差を考えてもしょうがないのでこれ以上は考えないようにします。

改善後のスコア

言語 スコア ブランチ
Swift(改善後) 37000くらい https://github.com/sidepelican/isucon12-qualify-swift/tree/tuning
Swift(改善後&全てEventLoopに書き換え) 37000くらい https://github.com/sidepelican/isucon12-qualify-swift/tree/tuning_nio

ほどほどにチューニングして遊びました。1台でも37000点くらいまで伸びたあたりで満足しました。
また、ホップによるオーバーヘッドが気になっていたのでEventLoopで全て実装を書き換えもしました。この書き換えによってスコアに差が出ませんでした。これは個人的に驚きで、リクエスト単位では確実に微小なオーバーヘッドがあるはずですが、サーバ全体で見れば完全に無視できるという結果になりました。

CPUプロファイル結果

途中CPUプロファイルも行っていました。この結果は、改善を積んで37000くらいスコアがでて、かつEventLoopには書き換えてない状態のプロファイルだっと思います。CPUボトルネックな状態だったかどうかは忘れてしまいました。すみません。

7割くらいの処理をsqliteが持っていってそうですね。シンボルは心の目でデマングルしてください。

気になったのはDecodableの処理ですね。existentialを多用するために細かいオーバーヘッドがたくさん発生していて、目に見えるレベルになっていることがわかります。Codableは便利なので使っていきたいですが、JSONSerializationより断然遅いです。

作ってみて

ベンチが動くまで実装するのに3日くらいかかったような気がします。本番みたいにガッツリ何時間も集中してたわけじゃないですが、たぶん8時間以上かかってます。
本番の制限時間内でゼロから作るのはなかなか至難な業だろうな〜と思いました。

Discussion