kateinoigakukunのブログ

思考垂れ流しObserver

ISUCON11予選でSwift移植チャレンジした

TR;DR

  • ISUCON11予選にSwiftで参加した
  • SwiftのConcurrency機能(async/await等 )の体験最高
  • 素振りが足りず地区予選敗退

チーム情報

移植の覚悟

ISUCONでは例年、いくつかの言語で対象Webアプリケーションの参考実装が提供されます。しかし、それを使わないといけない、というわけではなくレギュレーション上は他言語の使用が許可されています。

許可される事項には、例として以下のような作業が含まれる。

・ ...

・他の言語による再実装

ISUCON11 予選レギュレーション : ISUCON公式Blog

Swiftの実装は残念ながら提供されていませんが、せっかく参加するのであればチームの得意分野を活かしたい、ということで競技時間内にSwiftで再実装する事になりました。

採用技術

ツールチェイン

通常、iOSmacOS向けのSwiftアプリを開発する場合、デプロイ先のOSバージョンに同梱されているSwiftランタイムバージョンによって使えるSwiftの言語バージョンが制限されてしまいます。

が、今回はLinuxにデプロイするSwiftアプリを開発するため、特に制約はありません。最新の言語機能も使い放題です。

というわけで、Swiftツールチェインとして正式リリース前のSwift 5.5を採用しました。Swift 5.5には並行プログラミングの言語機能が追加されており、Non-blocking IOを使ったアプリを書くのにもってこいです。

今回はswift-5.5-DEVELOPMENT-SNAPSHOT-2021-07-30-aを使いました。

Webフレームワーク

現在アクティブにメンテナンスされているSwift製Webフレームワークはかなり少なくなっており、ほぼVapor一択な状況でした。 かなり重厚な作りになっていますが、必要な機能は一通り揃っています。

github.com

Vaporが依存しているNon-blocking IOを実現するライブラリSwiftNIOは、言語側のConcurrency機能との橋渡しをサポートしており、SwiftNIO内のFuture型を簡単にasyncメソッド呼び出しとして取り出すことができます。

github.com

また、VaporはSwiftNIOのConcurrencyサポートを利用して、Vapor自体のConcurrency APIサポートを進めており、 メインメンテナの@0xTimさんのPR上で開発が進行している状況です。今回はこのPRブランチを使用しました。

github.com

SQLライブラリ

Vaporは複数のSQLバックエンドをサポートするための抽象レイヤとしてFluent というORMを提供しています。しかし過去の経験から、抽象化されたDSLで思い通りのSQLを発行するのが非常に難しいことが分かっていたため、今回は採用を見送りました。

代わりに、バックエンドをMySQLのみに絞ったMySQLKitを採用しました。(FluentでMySQLバックエンドを使う場合もこれが使われる)

MySQLKitはlibmysqlclientに依存せず、SwiftNIOベースの非同期IOで実装されたMySQLNIOMySQLクライアントとして使っています。ちょっとうれしいですね。

MySQLKitは高度な機能は提供しませんが、行をSwiftの構造体にマップするできるので今回の用途では十分でした。

struct Response: Decodable {
  var character: String
}
let characters = try await pools.withConnection { conn in
  try await conn.sql()
    .raw("SELECT `character` FROM `isu` GROUP BY `character`")
    .all(decoding: Row.self).get()
}

github.com

最終的なPackage.swift

SwiftNIOがConcurrency APIにOSバージョンベースのavailabilityを付けちゃってるので、macOS 12未満のmacOS上で開発するために disable-availability-checking を付けているのがミソ

// swift-tools-version:5.3
import PackageDescription

let package = Package(
    name: "isucon11",
    platforms: [
       .macOS(.v10_15)
    ],
    dependencies: [
        .package(url: "https://github.com/vapor/vapor.git", .branch("async-await")),
        .package(url: "https://github.com/vapor/mysql-kit.git", from: "4.0.0"),
        .package(url: "https://github.com/Flight-School/AnyCodable", from: "0.6.0"),
        .package(url: "https://github.com/vapor/jwt-kit.git", from: "4.0.0"),
    ],
    targets: [
        .target(
            name: "App",
            dependencies: [
                .product(name: "Vapor", package: "vapor"),
                .product(name: "MySQLKit", package: "mysql-kit"),
                .product(name: "JWTKit", package: "jwt-kit"),
                .product(name: "AnyCodable", package: "AnyCodable")
            ],
            swiftSettings: [
                .unsafeFlags(["-cross-module-optimization"], .when(configuration: .release)),
                // Disable availability checking to use concurrency API on macOS for development purpose
                // SwiftNIO exposes concurrency API with availability for deployment environment,
                // but in our use case, the deployment target is Linux, and we only use macOS while development,
                // so it's always safe to disable the checking in this situation.
                .unsafeFlags(["-Xfrontend", "-disable-availability-checking"])
            ]
        ),
        .target(name: "Run", dependencies: [.target(name: "App")])
    ]
)

過去問を解く

過去問としてISUCON 10の予選問題を解きました。素振りによってVaporやMySQLKitに慣れたり、Vaporのデフォルト設定の罠が把握できたのでかなり良かったです。

ただ、もう1年分くらい解いておけば本番でハマった問題も回避できたかなぁという気持ちです。過去問をやろう。

3人で13時間くらい使って移植した結果、他言語の初期実装と同程度のスコアを出すことには成功していました。

本番結果

練習の成果やasync/awaitの開発体験の良さもあり、4時間程度でおおよその実装が完了していましたが、その後のベンチマーカ互換性チェックのデバッグに手間取り、最終的にチェックを通過することができずスコアは0でした。

主なハマりポイントはDB内でのタイムゾーンの扱いでした。 MySQLNIOはDATETIME型のマッピングの際のタイムゾーン考慮はユーザの責務であるとし、ライブラリ内では常にGMTとして扱うため、9時間のズレにかなり悩まされました。

時間はむずかしい。

感想

かなり楽しかった。

  • 愛と気合で移植できる
  • 癖を把握する必要はあるがSwiftでもイケる
  • 他の言語実装を動かしながら、一部のリクエストだけSwift実装に流す、みたなことができていればスコア0は回避できたかも。
  • 来年再挑戦したい

2020年のオープンソース活動

2020年はOSS開発に多く時間を使ってきたなぁと思ったのと、GitHub Sponsorsからの振り込みがあったので、報告を兼ねて軽く今年何をしてきたか振り返ってみようと思います。

SwiftWasm

年間を通してずっと開発していたプロジェクトの一つです。このプロジェクト関連でコンパイラやリンカ、デバッガなどいろいろなものを作ってきました。 必要に迫られてLLVMICUなどの有名どころのプロジェクトにもコントリビューションする機会がありました。

ツールチェーンがおおよそ動くようになってからは、コントリビュータを増やすための発信をしたりディスカッションの場を用意したり、といった活動もしています。 最近は来るSwift 6に導入予定のasync/awaitのWasm上でのプロトタイプを実装しています。

進捗報告

つくったもの

GSoC

4月の時点でSwiftWasmはおおよそ動くようになっていたので、あとはバイナリサイズの縮小のための最適化をやりたいなと思っていたところ、運の良いことにちょうど良いプロジェクトがあったので応募してみました。

実際には5月から8月までGoogle Summer of Codeの学生としてSwiftのリンク時最適化を実装していました。詳しい話はSwiftコンパイラの勉強会「わいわいswiftc」で話したので興味がある方はスライドか動画をみてください。

実証実験としてある程度の成果はあったもののメインストリームにはまだ入っていません。2021年内には入れたいです。

f:id:kateinoigaku:20201231230651p:plain

IBLinter

かれこれ作ってから3年目になりました。SwiftUIが登場してお役御免になるかと思いきや、まだ暫くは使っていただけている様子で、ちょこちょこ新しい機能が追加されています。 今年面白かったのは@r_plusさんのPRで実装されたUIStackViewのbackgroundColor検知機能です。

Periphery

使われていないコードを検出してくれるツールなのですが、暫く開発が止まっており最新のXcodeで動かなくなっていたのでPRを投げていたところメンテナになりました。その後はSourceKitベースの解析をIndexStoreベースの解析に切り替えたり、Xcodeのアップデートに追従したりといったコントリビューションをしていました。最近オリジナル作者の @ileitchさんが開発を再開していますが、スター数の割にコントリビュータが少ないのが少々不安です。興味がある方はぜひ。

まとめ

ここに書いたほぼすべての活動は、インターン先であるメルペイの業務時間でコミュニティ活動として行われています。会社の支援がなければ学業と両立してここまでやってこれていないので本当に良い社だと思います。

また、ありがたいことにGitHub Sponsorsで支援してくださる方もいらっしゃり、活動のモチベーションの維持に大きくプラスになっています。いつもありがとうございます。

来年も継続して活動していけるよう頑張っていきます。

Swiftに搭載予定のC++相互運用機能

Swift Advent Calendar 2020 の1日目の記事です。

今年の頭頃からGoogleのSwift for Tensorflowチームが主体となって、SwiftとC++の相互運用機能の議論と実装が進んでいます。

まだ未実装な部分も多く今すぐに試せるわけではありませんが、マニフェストの内容や現在の実装状況から興味深い部分をピックアップして紹介したいと思います。

github.com

大まかな実装方針

Swiftは既にCとObjective-Cとの相互運用をサポートしています。ヘッダをClang Modulesとして読み込むことでSwiftから自然な形で外部言語のAPIを呼び出すことができます。

この機能はSwiftコンパイラにリンクされているlibclangToolingを通じて変換されたClangのASTをSwiftのASTに変換することで実現されています。主なコードはClangImporterモジュールに実装されています。

C++との相互運用も同様のアーキテクチャで実装が進んでいます。

API設計の指針

Ideally, users working in Swift should not feel any difference between native Swift APIs and imported C++ APIs.

https://github.com/apple/swift/blob/main/docs/CppInteroperabilityManifesto.md#goals

SwiftとC++からインポートされたAPIに違いを感じさせないような自然なやり取りを目指しているようです。

また、Rustのrust-bindgenのようなユーザーライブラリとして提供するのではなく、コンパイラに結合された形で提供する選択をすることで、エディタ連携やツーリングの面で滑らかな体験を実現したい、という意図が感じられます。

さらに、"Interop must not be a burden for API vendors."と掲げており、JNICLIFのようなグルーレイヤーをできるだけ作らないことを目指しているようです。

関数シグネチャの変換

関数のインポートは比較的直感的な変換になりそうです。

// C++側定義
void increment(int *value);

// Swift側から見たとき
func increment(_ value: inout Int)

// Swiftのユースケース
func useIncrement() {
  var i = 0
  increment(&i)
}

C++側のポインタ型はnullポインタを許容しますが、Swift側のinoutは決してnullにになることはなく、呼び出し側のSwiftの方が制約が強いため、安全に呼び出すことができます。

ただ、Swift から C++の関数を呼び出す場合は問題になりませんが、逆の場合、つまりC++からSwiftの関数を呼び出す場合問題になることがありそうです。例えば、次のようにC++側のvirtualメソッドをSwift側で実装することを考えてみましょう。

// C++ header.

class Incrementer {
public:
  virtual void increment(int *value) = 0;
};

// Swift side (compiler synthesized)
protocol Incrementer {
  func increment(_ value: inout Int)
}

// Swift side
struct MyIncrementer: Incrementer {
  func increment(_ value: inout Int) {
  }
}

この場合、仮想関数テーブルを経由してSwiftのincrementメソッドをC++から呼び出されることがありますが、C++のポインタ型はnullポインタを許容してしまうため、呼び出されたincrementはSwiftとしてありえない状態になります。マニフェストではこの問題に対して明確な対応は書かれていませんが、妥当な線として inout Int または UnsafePointer<Int>として変換できるようにする、という案が考えられます。

他にもいろいろなバリエーションがありますが、基本的にSwift側の制約の方が強いため、Swift → C++の呼び出しであれば安全に呼び出せそうです。

ムーブセマンティクス

提案自体は長らくOwnershipManifestにあるのですが、Swiftにはムーブセマンティクスが未だ実装されていません。そのため、RAIIパターンでよく使われるmove-onlyなC++の型を綺麗にSwiftの世界にマッピングできません。

moveonly型がSwiftでサポートされるまでの対応として、UnsafePointerに特別な制約を課し、コピーできないようにしつつ、 moveInitialize メソッドでムーブする、という方法が挙げられています。

// C++ header.

// `File` is a move-only C++ class.
class File {
private:
  int file_descriptor_;
public:
  File(std::string_view filename);
  File(const File &) = delete;
  File(File &&) = default;
  ~File();

  File& operator=(const File &) = delete;
  File& operator=(File &&) = default;

  std::string ReadAll();
};

// Swift side (compiler synthesized)
struct File {
  public init(_ filename: std.string_view)
  public func ReadAll() -> std.string
}

// Swift side
func useFile(_ f: UnsafeMutablePointer<File>) {
  // var f2 = f.pointee // compile-time error: can only call a method on 'pointee'.
  print(f.pointee.ReadAll()) // OK

  // Move `f` to a different memory location.
  var f2 = UnsafeMutablePointer<File>.allocate(capacity: 1)
  f2.moveInitialize(from: f, count: 1)
  // `f` is left uninitialized now.

  print(f2.pointee.ReadAll()) // OK
  f2.deallocate() // OK
  // The file is closed now.
}

Swiftにムーブセマンティクスが完全に実装されると次のような形になりそうです。

// Swift side (compiler synthesized)
moveonly struct File {
  public init(_ filename: std.string_view)
  public deinit

  public func ReadAll() -> std.string
}

// Swift side
func useOneFile(_ f: File) {
  print(f.ReadAll()) // OK

  // Move `f` to a different memory location.
  var f2 = move(f)

  // print(f.ReadAll()) // compile-time error: can't access `f`, its value was moved

  print(f2.ReadAll()) // OK
  endScope(f2) // OK
  // The file is closed now.
}

StructとClassのインポート

C++のstructとclassは基本的に同じ機能を提供するためclassについてのみ話します。

C++のclassはSwift的に見ると基本的に値型と同じ振る舞いをするのでSwiftのstructとしてインポートするのが妥当だろうとされています。

ただ、このインポートでは表現できないC++の機能がいくつかあります。

例えば、C++のclassは継承をサポートしていますが、Swiftのstructは継承ができず値型同士のサブタイプ関係は存在しません。そのため、C++のclass型を値レベルで自然にキャストすることはできません。

しかし、そもそも値型同士のサブタイプ関係にはObject slicingの問題があり、ミスが起こりやすいとされています。そのため、安全な操作でないことを示すためにUnsafePointer経由で表現することになりそうです。

// C++ header.
class Base {
    int value1;
};

class Derived : public Base {
    int value2;
};

// Swift side (compiler synthesized)
struct Base {
  var value1: Int32
}

struct Derived {
  var value1: Int32
  var value2: Int32
}

// Swift side
var derived: Derived = ...
var base: Base = UnsafeRawPointer(&derived).load(as: Base.self)

値レベルでのキャストはunsafeな操作ですが、ポインタ同士のキャストはどうでしょうか?

例えば UnsafePointer<Derived>UnsafePointer<Base> の変換はSwiftとしてはサブタイプ関係がないので違法ですが、実体であるC++としては合法なキャストです。

これを実装するプランとして

  1. as 演算子を拡張してC++型のUnsafePointerのキャストを特別扱いする
  2. キャスト用のメソッドをC++の型に生成する
  3. C++型のキャストのためのcompiler intrinsicsな関数を提供する

が挙げられています。

1番のプランは、すでにキャストロジックが複雑になりすぎていることから見送られそうです。

2番のプランはC++からインポートした型に以下のよなAPIを自動生成する、というものです。

// Swift side (compiler synthesized)
public struct Derived {
  @returnsInnerPointer
  public var asBase: UnsafePointer<Base> { get }

  public static func downcast(from basePointer: UnsafePointer<Base>) -> UnsafePointer<Derived>?
}

public struct Base {
  public static func upcast(from basePointer: UnsafePointer<Derived>) -> UnsafePointer<Base>
}

// Usage example.

func useBasePtr(_ basePtr: UnsafePointer<Base>) { ... }
func useDerivedPtr(_ derivedPtr: UnsafePointer<Derived>) { ... }

func testDowncast() {
  var d = Derived()
  useBasePtr(d.asBase)

  // or:
  useBasePtr(Base.upcast(from: &d))
}

func testUpcast(_ basePtr: UnsafePointer<Base>) {
  if let derivedPtr: UnsafePointer<Derived> = Derived.downcast(from: basePtr) {
    useDerivedPtr(derivedPtr)
  }
}

3番目のプランは以下のような特別なキャスト用の関数を提供するという方法です。2番目のプランではキャストメソッドの自動生成時にキャスト可能な型のペアを制限する形になっていますが、このプランでは型チェック時にC++側の定義を元にSourceとDestinationに派生関係があるか否かを調べるようです。

public func cxxUpcast<Source, Destination>(
  _ source: UnsafePointer<Source>,
  to type: Destination.Type
) -> UnsafePointer<Destination>

public func cxxDowncast<Source, Destination>(
  _ source: UnsafePointer<Source>,
  to type: Destination.Type
) -> UnsafePointer<Destination>

public func cxxDynamicDowncast<Source, Destination>(
  _ source: UnsafePointer<Source>,
  to type: Destination.Type
) -> UnsafePointer<Destination>?

テンプレート関数のインポート

C++のtypenameテンプレートをSwiftのジェネリック関数としてインポートする機能が提案されており、実際に部分的に実装されています。

// C++ header.
template<typename T>
void functionTemplate(T t) { ... }

// Swift side (compiler synthesized)
func functionTemplate<T>(_ t: T)

単純な変換に見えるかもしれませんが、C++のテンプレートとSwiftのジェネリック関数には実行モデルに大きな違いがあります。

C++のテンプレートはコンパイル時に静的に型パラメータが決定され展開されますが、対してSwiftのジェネリック関数は型パラメータは実行時に差し替えることが可能です。スペシャライズはあくまで最適化の1つであり、C++のように常に展開されるわけではありません。

つまり、Swiftネイティブなジェネリック関数はpublicな関数としてモジュール外部から型パラメータを注入して使うことができますが、C++由来のジェネリック関数は必ずモジュール内でスペシャライズされる必要があります。

これらを区別するために @_must_specialize アトリビュートを導入し、C++由来のジェネリック関数に付ける提案がされています。

// C++ header.
template<typename T>
void functionTemplate(T t) { ... }

// Swift side (compiler synthesized)
@_must_specialize
func functionTemplate<T>(_ t: T)

テンプレートの展開はClangImporterが型パラメータをSwift型からC++型に変換した上で、Clangの意味解析モジュールでテンプレートをインスタンス化することで実装されています。

例外

-fno-exceptions オプションが付いていない場合、全てのC++関数が例外を投げる可能性があるため、素直にSwiftとして表現するとthrowsだらけになってしまいます。C++にはnoexcept というキーワードもありますが、マニフェストによると慣用的なC++ではないそうです。nothrowが付いてないとしても実態は例外を投げない、といったことが多いので、デフォルトをthrowsにするのは使い勝手が悪そうです。

そこで提案されているのが throws! というキーワードをSwift側に追加するプランです。 throws! はdo-catchで例外をキャッチすることも可能だが、キャッチしないことも可能、を示します。例外が投げられたがキャッチされなかった場合、プログラムは終了するようです。

これについては現在Pitchが出ています。

標準ライブラリ型のブリッジ

Objective-Cとの相互運用ではNSArrayやNSDictionaryなどのNSプレフィックスの付いたObjective-Cの型とSwiftの型の変換がサポートされています。このブリッジングはSwift側の型がObjective-Cの型の所有権を奪うことでほぼノーコストで行われています。

しかし、同じような仕組みを提供するためにはABIの変更が必要となってしまうようです。

まとめ

Interoperabilityの仕様には細かい決めの問題が大量にあって言語デザインのセンスが問われそうだなと思いました。

今回紹介した機能は実装が予定されている機能の一部だけなので、興味がある方は実装やManifestを見ると面白いと思います。

iOSDC 2020に参加/登壇 しました

kateinoigakukunです。4回目のiOSDCに参加しました。3年連続スピーカーとして参加です。

発表内容

プロポーザル

fortee.jp

スライド

デモ

ということで、去年iOSDCでも発表したWebAssemblyネタの最新版をお届けしました。いかがでしたでしょうか。ここ1年間の成果発表のような形になったと思います。40分枠にすればよかったと少々後悔してます 😅

登壇中にDiscordのパブリックビューイングチャンネルでオーディエンスとわいわいできたのは事前収録ならではの新鮮な体験でした。Ask the Speakerでは沢山の方に興味を持ってもらえたようでとても嬉しかったです。早速SwiftWasmを試してくださった方も 👀

github.com

他のスピーカーの発表

最近はコンパイラ周りしか触っていなかったので色々な分野の話を聞けて刺激になりました。まだまだ知らないことや理解が浅いものがあるので勉強していきたいです。

今年もこうして参加できて本当に良かったです。

IndexStoreを使ってSwiftコードを静的解析する

Swiftで静的解析ツールを作るとなると、基本的にSwiftSyntaxを使うことが多いと思います。 ただ、SwiftSyntaxで得られる情報はコンパイラ内部のパイプラインの序盤で生成されるため、「ある変数がどこから参照されているか」といった意味解析後に解る情報は使えません。

そこで、Xcodeがコードジャンプやリネームなどに使っている(だろう)Index-While-Buildingという仕組みを紹介します。

Index-While-Buildingとはその名の通りコンパイラコンパイル中に生成する中間情報をIDEのコードインデックスに利用する、という仕組みです。 swiftcclangには-index-store-pathというオプションがありディレクトリを指定することで、そこにインデックスデータが出力されます。

この仕組で生成されたデータは、libIndexStoreを使って読み取ることができます。 libIndexStoreAppleがフォークするLLVMプロジェクトの一部で、Xcodeのツールチェーンに同梱されています。

詳しい話はこちらのドキュメントを参照してください。

docs.google.com

libIndexStoreの仕組みを使えば、SwiftSyntaxの構文レベルでの解析では実現できなかった、よりリッチな解析が可能になります。

libIndexStoreを使った例には以下のようなものがあります。

しかし、いざlibIndexStoreを使ってツールを作ろうとすると、Swift向けlibIndexStoreクライアントが無いことに気が付きます。上で紹介したツール達はそれぞれ内部でlibIndexStoreとのバインディングを自前実装しています。 indexstore-dbは一応IndexStoreクライアントとして使うこともできますが、単にIndexStoreを使うためにはtoo muchかつsourcekit-lspから使われる前提でAPIが公開されているため制約が多いです。

そこで、(比較的)libIndexStoreのインターフェースをそのままSwiftから使うためのクライアントライブラリを書きました。

github.com

そして、そのライブラリを使って実装したのがswift-renamerです。雑に紹介するとXcodeのリネーム機能をプログラマブルに制御するようなライブラリです。

github.com

こんな感じのコードを書くと

import SwiftRenamer

let renamer = SwiftRenamer(storePath: indexStorePath)
let replacements = try system.replacements(where: { (occ) -> String? in
    occ.symbol.usr == "s:16IntegrationTests9ViewModelC4nameSSSgvp" ? "nickname" : nil
})

for (filePath, replacements) in replacements {
    let rewriter = try SourceRewriter(content: String(contentsOfFile: filePath))
    replacements.forEach(rewriter.replace)
    let newContent = rewriter.apply()
    newContent.write(toFile: filePath, atomically: true, encoding: .utf8)
}

こんな感じでリネームできます。これは「ある変数がどこから参照されているか」という情報をIndexStoreが持っているのでサクッと実装できます。

 class User {
-    var name: String?
+    var nickname: String?
 }

 class ViewController: UIViewController {

    let user = User()
    override func viewDidLoad() {
        super.viewDidLoad()

-        user.name = "Initial Name"
+        user.nickname = "Initial Name"
    }
}

IndexStoreにどんな情報が入っているのか気になった方は、swift-indexstoreをクローンしてindex-dump-tool コマンドにXcodeが生成するIndexStoreディレクトリを渡すと中身が見えて楽しいと思います。

$ git clone https://github.com/kateinoigakukun/swift-indexstore
$ cd swift-indexstore
$ swift run index-dump-tool print-record --index-store-path ~/Library/Developer/Xcode/DerivedData/YourProject-XXXX/Index/DataStore 

これを使って便利なツールを作ってくれ〜

WebAssembly向けのデバッガを開発しました

こんにちは。私はSwift言語のWebAssembly向けコンパイルをサポートするプロジェクトを進めている一人です。

その開発を効率的に進めていくためにWebAssembly向けのデバッガwasminspectを開発しました。

Swift for WebAssemblyの進捗

この間フォーラムに詳しい進捗報告をしました。 概要としては

  • 約90%のテストケースが通るようになった
  • まだ一部の実行時の言語仕様が動かない
    • 実行時キャストなど

という感じです。

これをデバッグする必要が出てきたこと、既存のデバッグ手法では太刀打ちできないことから今回のデバッガを開発することにしました。

既存のデバッグ手法

既存のデバッグ手法としては以下のようなものが挙げられます。

  1. WebAssembly以外のアーキテクチャデバッグ
  2. printデバッグ
  3. Chrome DevTools
  4. LLDB + wasmtime

WebAssembly以外のアーキテクチャデバッグ

大抵の場合、自分が元の言語で書いたロジックが間違っているので、わざわざWebAssembly向けバイナリでデバッグする必要はありません。RustでWebAssembly開発をしている場合でもRustをx86_64向けにコンパイルしてLLDBでデバッグすればよいのです。

これは手慣れた手法でデバッグでき、LLDBやGDBなどデバッグする環境が十分に整っていることが大きなメリットです。

一方、実際に動かすバイナリをデバッグできるわけではないので、コンパイラが吐くコードが信用できないときには使えません。あくまでデバッグできるのは手元のマシンのアーキテクチャ向けに吐かれたバイナリであり、WebAssemblyバイナリではありません。今回はWebAssembly向けコンパイルのパイプラインをデバッグしたかったため、この手法は不採用でした。

printデバッグ

古典的なデバッグ手法ですが、デバッガが無い環境では非常に有用な手法です。printデバッグの最大のメリットはWebAssemblyバイナリ自体をデバッグできることです。

しかし、printのためのコードが入り込むため、結局デバッグ対象のバイナリ自身をデバッグできるわけではないです。デバッグ用のコードを入れたらスタックが破壊される、ということがザラに起きるのでこれは致命的です。

また、あくまで中間状態をロギングできるだけなので、プロセスの制御ができません。例えばブレークポイントを貼って任意のタイミングでプロセスを止めたり、ステップ実行しながら条件分岐の進み方を確認することはできません。

Chrome DevTools

最新のChromeではDevToolsのWebAssembly対応が進んできています。最近はDWARFの解釈もできるようになったようです。実際にブラウザ上でデバッグできるのは非常に大きなメリットです。また将来的にWebAssemblyをWeb開発で普及させていくためには必須の機能だと思います。

ただ、現状フレーム上の変数やメモリ空間をダンプできなかったり、機能不足感が否めないです。

OSSとして開発されているので開発に参加することも考えましたが、プロジェクトが非常に大きいこと、v8とのインテグレーション作業に時間がかかりそうなことから、見送りました。

developers.google.com

LLDB + wasmtime

wasmtimeはMozillaが開発しているWebAssemblyのJITコンパイラです。

github.com

LLDBやGDBにはJITコンパイルされたネイティブコードとデバッグ情報からデバッグする機能があります。

Debugging JIT-ed Code With GDB — LLVM 10 documentation

wasmtimeはWASMに埋め込まれたDWARFをJITコンパイル後のアーキテクチャ向けに変換して、LLDBと通信することでソースレベルデバッグを実現しています。

実際に自分でデバッガを作り始めるまではこの手法でデバッグしていました。 ソースレベルのステップ実行やフレームの変数ダンプなどはそこそこ動いていました。 何よりもLLDBの基盤に乗ることで、既存のデバッガ資産をそのまま使えるというのが大きいです。

しかし、この手法でデバッグできるのはあくまでJITコンパイルされたネイティブコードであり、WebAssemblyではありません。なのでWebAssembly命令レベルでのステップ実行などはできないです。また、WebAssemblyをネイティブコードに変換する際に一部の情報が落ちてしまう問題もありました。

例えばWebAssemblyには関数呼び出し時にシグネチャの一致をチェックして、一致しない場合trapする仕様があります。デバッグの必要がない場合、不一致の時abortするだけで十分ですが、デバッグ時には実際にどの型を比較したのか、などの追加の情報が欲しいです。これを実現するためにはデバッグ時のみネイティブコードにデバッグ情報を乗せる、などの作業が必要になります。しかし、それぞれの例外をハンドリングするためにデバッグ情報を伝播するのは非常に大変でした。

hacks.mozilla.org

本当に欲しかったデバッガの要件

コンパイラ開発において本当に欲しかったデバッガの要件は以下のとおりです。

  • 実行時にどのwasm命令を実行しているか把握できる
  • プロセスの制御ができる
  • メモリダンプ
  • DWARFを解釈できる

しかしこれを満たすようなデバッガが見つからなかったため、自作する結論に至りました。

wasminspect

https://raw.githubusercontent.com/kateinoigakukun/wasminspect/master/assets/demo.gif

github.com

GIFでだいたいの雰囲気は伝わると思います。

冬休みの一週間強でデバッガ用のWebAssemblyランタイムを書いて、春休みの最初の一週間でデバッガフロントエンドを書いて、やっとまともに使えるようになりました。今ではバリバリコンパイラ開発で活躍しています。

実装

基本的にLLDBのコードを参考にしながら実装しました。大きな違いはLLDBがOSのシステムコールを使ってプロセスを制御するのに対して、wasminspectでは内蔵したランタイムを直接制御する部分です。

ランタイムはJITコンパイルもAOTコンパイルもせず、愚直にインタプリタとして実装しました。これはWebAssembly命令レベルでのステップ実行を実現するためです。また、wasmtimeとLLDBの組み合わせのときに発生したデバッグ情報の伝播も必要なくなりました。

ランタイムの開発は公式のドキュメントテストケースが充実していて非常にスムーズに進みました。

DWARF for WebAssembly

正直DWARF対応だけで何本かブログが書けそうなくらいしんどかったです。基本的に仕様を読みながらLLDBのコードを参考にして実装するだけでしたが、単純に量が多かったです。

dwarfstd.org

WebAssemblyに固有の話としてはDWARF上のスタックとLLVMが吐くWebAssemblyのスタックが一致しない、ということがありました。 LLVMが吐くWebAssemblyでは線形メモリ上に擬似スタックのような空間を確保してそこを伸び縮みさせてフレーム変数を管理しています。(WebAssemblyの仕様で定義されているスタックとは別) rspに対応する値がグローバルに保持されていて、x86_64向けの命令のようにプロローグとエピローグが擬似スタック上で実行されます。

DWARFが示すスタックはこの擬似スタックにほぼ対応しているので、LLVMの実装に依存することでローカル変数のダンプができるようになりました。

github.com

将来的にはWebAssembly向けの位置情報がDWARFの仕様に追加されるんじゃないかなぁと予想しています。既にドラフトの文書があったりします。

yurydelendik.github.io

自作デバッガの功績

今回作ったデバッガは既にいくつかのSwiftのバグを修正するのに大きく貢献しています。特にメモリダンプ機能は非常に便利で、正常に動くx86_64向けバイナリと比較することで破壊されたメモリの位置を特定できます。

まとめ

一応一通り動くデバッガができましたが、基本的に僕自身がSwift for WASMの開発をするための道具として開発しているだけなので、あまり他の環境で使われることを考慮していません。試していないですが、多分EmscriptenではDWARFの解釈がうまく行かないと思います。

ドキュメントを軽く書いたのでもし興味があれば触ってみてください。

0からデバッガを作る機会はなかなか無いので楽しかったです。これからも「無いなら作ればいい」の精神でやっていきたいと思います。

XcodeGenとCocoaPodsをいい感じに使う

背景

XcodeGenはXcodeのプロジェクトファイルをYAMLから生成するためのツールです。生のプロジェクトファイルは記述が冗長であったり、とても人間が手で管理できるものではないため、YAMLで管理しようというのがモチベーションです。 github.com

iOS/macOSアプリの開発においてライブラリ管理のデファクトスタンダードとなっているCocoaPodsというソフトウェアがあります。これはライブラリをビルドするためのプロジェクトファイル Pods.xcodeproj を生成し、メインのプロジェクトファイルと統合することでアプリにライブラリをインストールします。

さて、この2つのツールを組み合わせて使おうとすると、

  1. XcodeGenでプロジェクトファイルを生成
  2. pod install でプロジェクトファイルと Pods.xcpdeprojを統合

という2ステップの流れになります。しかし、pod install はキャッシュ機構があるとはいえ、ブランチをチェックアウトする度に実行するのはヘビーです。

そこでCocoaPodsとプロジェクトファイルの統合を事前に手動で行う方法を紹介します。この方法では Podfile.lockPods/Manifest.lockに差分がない場合においてはpod installを実装せずに済むため、プロジェクトファイルの生成時間を大幅に削減できます。

CocoaPodsの内部実装に依存する形になるのでCocoaPodsの変更に追従していく必要がありますが、やることは単純なので大まかな統合の処理は変わらないはずです。

詳細

実はCocoaPodsがプロジェクトファイルに行う処理はそこまで多くありません。

実際にpod install を行ったときに発生するxcodeprojへのdiffがこちらです。

gist.github.com

  1. ビルドをトリガーするためのダミーターゲット(Pods_XXX.framework)Dependenciesに追加
  2. CocoaPodsが生成したxcconfigをプロジェクトのベースconfigに設定(他のxcconfigが設定されていない場合のみ)
  3. EmbedするDynamic FrameworkをアプリにコピーするBuild Scriptを注入( [CP] Embed Pods Frameworks
  4. ライブラリが使うリソースをアプリにコピーするBuild Scriptを注入 ( [CP] Copy Pods Resources
  5. pod install が実行されていない場合にビルドをfailさせる Build Scriptを注入( [CP] Check Pods Manifest.lock

だけです。ライブラリのビルドをトリガーする部分はXcodeのビルドシステムに乗っているため、特に考えることはないです。 それぞれ順番に何をするか説明します。

0. (下準備)

CocoaPodsがプロジェクトファイルに手を加えないようにPodfileで integrate_targets: falseを指定します。

install! 'cocoapods', integrate_targets: false
target 'XcodeGenWithPods' do
  use_frameworks!
  pod "Alamofire"
end

あと、生成されたPods.xcodeprojとメインのプロジェクトファイルを束ねるxcworkspaceを作っておきます。通常はCocoaPodsが生成しますが自動統合を切ったため自分で用意する必要があります。

1. ビルドをトリガーするためのダミーターゲット(Pods_XXX.framework)Dependenciesに追加

CocoaPodsのビルドシステムは自前で色々やっているので基本的にXcodeのビルドシステムに依存していません。ただ、そのままではXcodeのビルドボタンを押した時に何もビルドされないので、最初のビルドを発火させるためダミーターゲットを用意しています。 これを依存ターゲットとしてプロジェクトファイルに追加する必要があります。プロジェクトファイルを跨いだフレームワークの参照なので、project.ymlにimplicitオプションを付ける必要があります。

    dependencies:
      - framework: "Pods-${target_name}.framework"
        implicit: true
        embed: false

2. CocoaPodsが生成したxcconfigをプロジェクトのベースconfigに設定

これはCocoaPodsを使いつつxcconfigを使うときと同じテクニックです。CocoaPodsはアプリのビルド時に正しくヘッダを探したり、ライブラリをリンクするために様々なフラグをxcconfig経由で渡しています。そのため、XcodeGenからプロジェクトファイルを生成する際にはそれを指定する必要があります。

targets:
  "XcodeGenWithPods":
    configFiles:
      Debug: Pods/Target Support Files/Pods-${target_name}/Pods-${target_name}.debug.xcconfig
      Release: Pods/Target Support Files/Pods-${target_name}/Pods-${target_name}.release.xcconfig

3, 4, 5. Build Scriptを注入

Dynamicなフレームワークとしてライブラリをビルドした場合、実行時に.frameworkファイルを参照するので成果物をアプリにコピーします。 また、Staticなlibraryとしてビルドするとリソースをアプリに直接コピーする必要があるので、その処理を発火させます。

これら2つのシェルスクリプトはCocoaPodsが必要に応じて生成するので、存在しなければ実行する必要はありません。

    preBuildScripts:
      - name: "[CP] Check Pods Manifest.lock"
        path: /bin/sh
        script: |
          diff "${PODS_PODFILE_DIR_PATH}/Podfile.lock" "${PODS_ROOT}/Manifest.lock" > /dev/null
          if [ $? != 0 ] ; then
              # print error to STDERR
              echo "error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation." >&2
              exit 1
          fi
          # This output is used by Xcode 'outputs' to avoid re-running this script phase.
          echo "SUCCESS" > "${SCRIPT_OUTPUT_FILE_0}"
        inputFiles:
          - "${PODS_PODFILE_DIR_PATH}/Podfile.lock"
          - "${PODS_ROOT}/Manifest.lock"
        outputFiles:
          - "$(DERIVED_FILE_DIR)/Pods-${target_name}-checkManifestLockResult.txt"
    postCompileScripts:
      - name: "[CP] Embed Pods Frameworks"
        path: /bin/sh
        script: '"${PODS_ROOT}/Target Support Files/Pods-${target_name}/Pods-${target_name}-frameworks.sh"'
      - name: "[CP] Copy Pods Resources"
        path: /bin/sh
        script: '"${PODS_ROOT}/Target Support Files/Pods-${target_name}/Pods-${target_name}-resources.sh"'

サンプルプロジェクト

github.com

targetTemplatesというXcodeGenの素晴らしい機能を使って汎用的なproject.ymlにしているので、これをコピペするだけで大抵の環境では動くはずです。

Staticライブラリにしてる場合やabstract targetを使っている場合、多分動かないので適宜変更してください。