借り初めのひみつきち

仮ブログです。

今週の MYOS

さいきんの MYOS 界隈のトピックです。

WASM 高速化

以前の大規模改修で数倍の高速化に成功した WASM ランタイムですが、まだいくつか課題と高速化の余地があります。

まず、実行時エラーの処理を改善しました。

以前のエラー処理は命令実行時に現在の命令を覚えておいて実際にエラーが発生した場合にエラーコードを投げて呼び出し元が覚えておいた命令と合成してエラー表示していました。 しかし、プログラムの実行時間の大半はエラーを起こさない命令を実行しているので、エラーに関わらず毎回命令を保存する処理は無駄が多く速度低下の原因のひとつにもなっていました。 そもそも呼び出し元がエラーの発生した場所を特定する仕組みだったので実際にはあまりうまく動かないケースがあり、速度低下のこともあって高速化の際にこの機能を無効にしてエラー発生場所が完全にわからない状態になっていました。

新しいエラー処理では、命令の実行時にエラーが発生した際にエラーコードと一緒に現在実行中の命令に関する情報も一緒に呼び出し元に返すことで、正常系の速度低下を引き起こさずに正確なエラー情報を通知できるようになりました。

残っている問題は、エラー時に表示される命令は実行時の命令なので WASM のバイトコードではなく事前検証で変換された中間言語になっていて、一部の命令は元のバイトコードと違う命令になっているので元のバイトコードに戻したいと考えています。

f:id:neriring16:20210526112517p:plain

また、配列アクセスを合理化しました。

Rust は配列アクセスで必ず境界チェックが行われるため、 C 言語で同等のプログラムを書いた場合に比べると遅くなることがあります。*1

WebAssembly インタプリタはほぼ全てのバイトコード命令がなんらかの配列にアクセスします。 一方、 WebAssembly のバイトコードは基本的に事前検証しないと実行できないため、ほとんどの配列インデックスは事前検証で安全性を担保することができます。*2

Rust は unsafe の命令で境界チェックを行わない配列アクセスができます。 unsafe 命令は基本的に使う側が安全性を確認することが必要ですが、実行時にしか安全性を検証できない一部の配列インデックスをのぞいて、事前検証で安全性を確認しているほとんどの配列アクセスは直接アクセスで高速化できます。

これらの改修で改修前よりベンチが 40% 程度高速化できたようです。

rustdoc

MYOS もだいぶ大きくなってきました。 カーネル内部の各モージュール、共通ライブラリ、サンプルアプリなど・・・ どこにどんなクラスがあってどんな使い方をすればいいのか見通しが悪くなってきました。

幸い、 rust には rustdoc という自動的にドキュメントを生成する機能があるのでそれを利用します。

良い子は cargo doc というコマンドでドキュメントを作ることができますが、 MYOS はレポジトリの構造が若干複雑なので色々オプションを指定してあげる必要があります。

ローカルで何度か調整してドキュメントを作ることができるようになったら、次はサーバーにアップロードしてドキュメントを共有できるようにします。 しかし、ドキュメントを生成してサーバーにアップロードする作業は手作業のままだと効率が悪いし最新のアップデートに追従できなくなります。

Github には Github Action というプッシュした場合などに自動的にスクリプトを実行する機能があるので設定します。

.github/workflows/ に以下のような YAML を設定します。*3

name: Rustdoc

on:
  push:
    branches: [ "test/doc" ]

env:
  CARGO_TERM_COLOR: always

jobs:
  rustdoc:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2

    - name: Install Rust toolchain
      uses: actions-rs/toolchain@v1
      with:
        toolchain: nightly
        profile: minimal
        override: true
        components: rustfmt, rust-src

    - name: Build Documentation
      run: |
        cd system
        cargo doc --no-deps --target x86_64-unknown-none.json -p kernel -p megstd -p bootprot -p megosabi

    - name: Deploy Docs
      uses: peaceiris/actions-gh-pages@v3
      with:
        github_token: ${{ secrets.GITHUB_TOKEN }}
        publish_branch: gh-pages
        publish_dir: ./system/target/x86_64-unknown-none/doc
        force_orphan: true

すると、 gh-pages というブランチに自動的にデプロイされて Github Pages の機能で HTML 公開されます。

meg-os.github.io

現在はドキュメントプッシュ用のブランチと開発のブランチが異なるので最新に追従しているわけではありませんが、 いずれブランチをもう少し整理して、マージするたびにドキュメントも自動で更新できるように考えています。

*1:コンパイル時に静的検証される場合と実行時にチェックされる場合があります

*2:WASMの仕様上、分岐命令はブロックのネストで表現されているので事前検証なしの実装は困難です。また、スタックの妥当性検証を事前検証しておくことで実行時のスタックチェックを省略できるなど事前検証した方がメリットがあります

*3:他のプロジェクトに転用する場合はこれをそのままコピペしてもうまく動かないので各自調整してください

GPD MicroPC の内蔵キーボードを使う方法

エンジニア向けに人気らしい GPD MicroPC ですが、実はいくつかの OS で内臓キーボードが使えない問題があります。 いくつかの OS には自作 OS も含まれます。

※ リビジョンによってハードウェア詳細が異なる個体があるようなので、この記事が全ての個体に当てはまる保証はできません。

結局、内蔵キーボードは何の規格なの?

答え: PS/2 です。

出た当時は色々言われていましたが、 windows を起動してデバイスマネージャを眺めてみると「PS/2 キーボード」というデバイスが存在します。 そして、 UEFI コンソール上でポート I/O を行うコマンドでテストするとポート 0x60 (PS/2 データポート) からキーコードっぽい値が取れます。

実際に自作 OS で試してみると、 PS/2 コントローラーらしいデバイスが存在していていくつかのコマンドにはちゃんと応答を返しています。

しかし、キーボードをいくら押し込んでもキーのデータは取得できません。

UEFI で直接 PS/2 ポートを叩くプログラムを作ってみるとそれっぽいデータが返ってきますが、 途中で ExitBootServices を呼び出すとそれ以降はやはりデータが返ってきません。

一方、 USB スタックを実装して調べてみると謎の HID デバイスは存在するものの、キーボードらしき応答は返ってきません。

一体何が起こってるんでしょうか?

Linux の事情

インターネットを検索してみると Linux をインストールした人の記事をいくつか見つけることができます。

Linux もバージョンによっては内蔵キーボードがうまく扱えないことがわかります。 一方で、バッテリ管理を有効にするとキーボードが使えるらしいという報告もあります。

バッテリ管理ということは ACPI が怪しいですね。

ACPI、DSDT、AML / ASL

ACPI には AML というバイトコードエンコードされた DSDT というコンピューターの全てのデバイス構成を管理するための巨大なデータベースがあります。

流石にそのまま見てもよくわからないので ASL というテキスト形式に変換して GPD MicroPC の DSDT の一部を見てみましょう。

Device (PS2K)
{
    Name (_HID, EisaId ("PNP0303") /* IBM Enhanced Keyboard (101/102-key, PS/2 Mouse) */)  // _HID: Hardware ID
    Name (_UID, Zero)  // _UID: Unique ID
    Name (LDN, 0x06)
    Name (_CID, EisaId ("PNP030B"))  // _CID: Compatible ID

...

Scope (_SB.PCI0)
{
    Device (PS2K)
    {
        Name (_HID, "MSFT0001")  // _HID: Hardware ID
        Name (_CID, EisaId ("PNP0303") /* IBM Enhanced Keyboard (101/102-key, PS/2 Mouse) */)  // _CID: Compatible ID

PS2K というデバイスを見つけることができ、これは PS/2 接続のキーボードということがわかります。

PS2K デバイスの ASL をさらに眺めていると、以下のように我々のよく知っている PS/2 キーボードのリソース情報が記述されています。

IO (Decode16,
    0x0060,             // Range Minimum
    0x0060,             // Range Maximum
    0x00,               // Alignment
    0x01,               // Length
    )
IO (Decode16,
    0x0064,             // Range Minimum
    0x0064,             // Range Maximum
    0x00,               // Alignment
    0x01,               // Length
    )
IRQNoFlags ()
    {1}

PS/2 キーボードの存在は確からしいことがわかりました。 問題は、なぜ ExitBootServices を呼び出すと、キーボードが反応なくなるのでしょうか?

ACPI Embedded Controller

ACPI には Embedded Controller (EC) という、 ACPI に関するコマンドやイベントのやりとりを行うコントローラーがあります。 EC は PS/2 コントローラーによく似ていて、一部の機種では PS/2 コントローラーと兼用している例もあるようです。

f:id:neriring16:20210523145757p:plain

コントローラーチップの謎

分解記事を見るとコントローラーチップらしき型番 (IT8987E) を見つけることができるのでそれを元に検索すると、 キーボードマトリックスPS/2 キーボードとして使えるコントローラーと Embedded Controller を内蔵しているブロック図を見つけることができます。 しかし、他に手がかりになりそうな情報を見つけることはできませんでした。

あえてこのようなチップを内蔵しているということは、おそらく PS/2 接続キーボードなんでしょう。

チップ本体の情報は見つかりませんが、似ている別の型番のチップでは EC のコマンドでキーボードなどの有効化を制御することができます。

つまり、 EC に何らかのコマンドを送ると内蔵キーボードの ON/OFF が制御できるのではないか、ということが予想できます。

全ての材料は揃った

ここまでの情報を整理すると、 ACPI を有効化する過程で EC になんらかのコマンドを送ればキーボードが使えるようになると予想できます。

あとは AML/ASL からそれらしい情報を探してくるだけです。

ここが一番大変ですけどね

おぼろげながら浮かんできた 0x11 という数値

ここまでは比較的早い段階で辿り着くことができましたが、 AML/ASL はしらみつぶしに探すには巨大すぎるし、名前が4文字制限でよく分からない略語がいくつも出てきて何をやってるかさっぱり分からなくなるので、進捗はほとんどありませんでした。

そして、当初の目的も忘れてきたある日ふと ASL を眺めていたところ、 EmbeddedControl に KBCD という怪しいフィールドを見つけました。

OperationRegion (ECF2, EmbeddedControl, Zero, 0xFF)
Field (ECF2, ByteAcc, Lock, Preserve)
{
    XXX0,   8, 
    XXX1,   8, 
    XXX2,   8, 
    Offset (0x11), 
    KBCD,   8, 

いかにもキーボード関係ありそうな怪しい名前のこのフィールドを ASL から検索してみると、バッテリー関連のメソッドで 0 を書き込んでいました。 なお、別のところでは 3 を書き込むケースもありました。

というわけで、実機で試してみました。

f:id:neriring16:20210523142033j:plain

キーボードが動きました。

ヤッタネ

実装の仕方

EC は PS/2 コントローラーによく似ています。 GPD MicroPC の場合はポート番号 0x62 がデータレジスタ、ポート番号 0x66 がコマンド・ステータスレジスタとなり、レジスタにデータを書き込む際はステータスレジスタIBF フラグの値を調べます。

f:id:neriring16:20210523145933p:plain

EC は 256 バイトの空間を持っていて、書き込む際は 0x81 Write Embedded Controller (WR_EC) をコマンドレジスタに書き込んだあと、アドレスと書き込むデータをデータレジスタに書き込みます。

f:id:neriring16:20210523150013p:plain

WR_EC は以下のようなコードになります。

unsafe fn wr_ec(addr: u8, data: u8) {
    Self::ec_wait_for_ibf();
    asm!("out 0x66, al", in("al") 0x81u8);
    Self::ec_wait_for_ibf();
    asm!("out 0x62, al", in("al") addr);
    Self::ec_wait_for_ibf();
    asm!("out 0x62, al", in("al") data);
}

unsafe fn ec_wait_for_ibf() {
    let mut al: u8;
    loop {
        asm!("in al, 0x66", out("al") al);
        if (al & 0x02) == 0 {
            break;
        }
        asm!("pause");
    }
}

件の問題では EmbeddedControl 0x11 に 0 を書き込みたいので、

Self::wr_ec(0x11, 0x00);

のように呼び出せばいいです。

しかし、このままだと GPD MicroPC 以外では何が起きるかわかりません。

EC が 62/66 にない機種ではステータス待ちでおそらくハングアップしますし、 EC があっても内容は機種依存なのでデバイスに予期せぬダメージを与えてしまうかもしれません。 というわけで、 SMBIOS で機種判別をして「GPD MicroPC」の時だけ処理するようにします。*1

let device = System::current_device();
match device.manufacturer_name() {
    Some("GPD") => {
        match device.model_name() {
            Some("MicroPC") => {
                Self::wr_ec(0x11, 0x00);
            }
            _ => (),
        }
    }
    _ => (),
}

これで GPD MicroPC の時だけキーボード有効化の EC コマンドを書き込みするようにできました。

本来は ACPI をちゃんと実装すればバッテリ関連のメソッドで自動的に実行されます。 現在の実装はあくまでワークアラウンドです。 そもそもキーボードが勝手に無効化されてしまうのはファームウェアのバグだと思うのでいずれ修正されるかもしれません。

いかがだったでしょうか?

自分の所有しているデバイスでキーボードがちゃんと扱えないのは悲しいものです。 意外と簡単な修正でキーボードが使えることがわかって嬉しいですね。

*1:実は多くの中華端末では SMBIOS の内容が適当すぎて機種が判別できません。手元にある他の GPD も判別できませんでした。 MicroPC だけはちゃんと判別できてよかったです

今週の MYOS 🎉

今週、というには間が空いてしまった最近の MYOS/TOE 界隈のトピックです。

MAYSTORM

直訳すると5月の風になります。

MYOS が最初のコミットから1年経ったので、1周年を記念して新しい名前を付けて仮プロジェクトから正式なプロジェクトに昇格しました🎉

github.com

ページング

TOEMMU のない CPU で動作するのが目標なのでページングを現状利用していないし今後も利用予定はありません。

一方 MYOS は x64 CPU で動作するのでページングを完全に OFF にすることができず、ブートローダーが設定した Identity Mapping (物理アドレスと仮想アドレスが一致する割り当て方法) 領域をそのまま使って、カーネルとしてはページングを見て見ぬ振りしていました。

今まではこのやり方でも特に大きな問題は起きていませんでしたが、 MMIOバイスの扱いで問題が発生しました。 ブートローダーは MMIOバイスの存在を知らないので、あらかじめページを割り当てておくことができません。 というわけで、カーネル起動後にデバイスドライバの指示でページ割り当てる機能を実装しました。

そもそも、実は APIC や HPET も MMIOバイスなのでページ割り当てが必要でしたが、これまでは本来カーネルが設定するべきページ設定をブートローダーの中で固定アドレスで勝手に設定していました。 今回の変更でブートローダーの固定配置を削除してカーネル側で ACPI の情報に基づいて動的に設定するように修正しました。

また、今まではカーネル以外は Identity Mapping 前提で物理メモリに配置して動いていましたが、メモリマネージャにページマネージャが仲介して仮想アドレスを返すようにしたので物理アドレスを直接扱わないようになりました。 カーネルの配置はブートローダーがカーネル専用の領域をマッピングしていたので今まで通りです。

一連の変更によって SMP 初期化以降のカーネルは Identity Mapping 領域にアクセスせずにページマネージャが割り当てた新しいページテーブルで動くようになりました。 ただし、残っている Identity Mapping 領域をそのまま切り離してしまうと haribote-os アプリが 32bit のために 4GB 以下のアドレスにしか配置できず、メモリアクセスできなくて動作しなくなってしまいます。この部分はまだ調整中です。

SMP のページング

SMP 起動後のページングはページテーブル操作中に他のプロセッサがページテーブルの書き換えを行うと競合して予期せぬ動作をする可能性があるため排他制御が必要になります。 また、ページテーブル変更があったことを他のプロセッサは知らずにそのまま実行を続けてしまうので、ページテーブル操作が終わったら TLB フラッシュを全てのプロセッサに通知するためにプロセッサ間通信する必要があります。

もしも単純にそのままページテーブルをロックしてしまうと、あるプロセッサでページテーブル変更後の通知の応答を待ってる間に他のプロセッサでページテーブル操作のロック待ちが発生した場合、お互いを待ち続けてデッドロックする可能性があります。

これに対する解決策として、ページ操作専用のスレッドを作って全てのページ操作を非同期にする改修に着手しています。 なお、現在は SMP 起動後にページング操作をするシチュエーションが存在しないのでこの機能は未完成です。

カーネルの ELF 対応

PC でよく使われるメジャーな実行ファイル形式は大きく分けると PE と ELF の2種類存在します。 UEFI は PE を採用していますが、現代的なほとんどの OS は ELF を採用しています。

MYOS は当初 UEFI アプリとして開発を始めたため PE を採用し、ブートローダーとカーネルの分離後もそのまま PE を使っていました。 しかし、 PE だと制限も多く扱いづらくなってきたので ELF への移行を検討していました。

まずは試しに単純に ELF をサポートしているターゲットに変えてビルドしなおしたところ、今まで見たことないエラーが出てきました。

その中のひとつに SSE が無効化されていてビルトインライブラリがビルドできないというエラーがありました。

現在 MYOS では SSE を無効にしています。 SSE は汎用レジスタとは別のレジスタセットを使用するので割り込みやコンテキストスイッチのコストが上昇しますが、 SSE は x86 アーキテクチャに後から追加された機能なので使わなくても大半のプログラムは記述可能です。

アプリケーションレベルでは SSE の機能を利用できた方が便利なこともあると思うので、将来アプリケーションで利用することがあれば SSE に対応する予定があります。 一方、カーネルでは利用するメリットよりも全てのスレッドがコンテキストスイッチのたびに毎回 SSE レジスタの退避処理をするコスト上昇の方がデメリットだと考えています。 そのため、カーネル内で不用意に SSE にアクセスする命令を実行しないようにコンパイラオプションで SSE を無効にしています。

Rust では OS ごとにターゲットが存在していて、 PE 形式から ELF 形式に移行するということは今まで使っていた UEFI のターゲット (x86_64-unknown-uefi) をやめて ELF をサポートするターゲットに変更することになります。 しかし、 ELF に対応するために一番無難そうだった Linux ターゲット (x86_64-unknown-linux-gnu) のビルトインライブラリの中には先述のように SSE を利用するものが含まれていてこのままではビルドできませんでした。

そこで他に使えそうなターゲットを探していたところ、 x86_64-unknown-none というターゲット設定のテンプレートを見つけたので、これをカスタマイズして無事に ELF に移行することができました🎉

PE から ELF に移行すると ABI も変更されますが、あらかじめ ABI が違うことはわかっていて色々準備していたので大きな混乱もなく無事に移行できました。

ちなみに TOE は最初から linux ターゲットで ELF 形式を採用していましたが、ロードサイズ制限緩和のためにビルドスクリプトで独自形式に変換していました。 MYOS では TOE ほどメモリ要求が厳しくないのでこのまま ELF のまま利用する予定です。

今週の MYOS

さいきんの MYOS/TOE 界隈のトピックです。

WebAssembly 高速化 (fused op)

中間コード変換とスタック操作合理化によって以前に比べると結構速くなりました。 この2つの改良によって実現可能になった新しい高速化手法があります。

WebAssembly は所謂スタックマシンなので個別の命令はスタックに push/pop するだけの単純な動作を行う命令が多く、実際のコードでは定型の組み合わせがたくさん出現します。

例えばローカル変数 i1 を加算する処理は以下のようなバイトコードの組み合わせになり、 WebAssembly では似たようなバイトコードの並びがたくさん出現します。

  local.get $i    ;; ローカル変数 $i の値をスタックに push
  i32.const 1     ;: 定数 1 をスタックに push
  i32.add         ;; スタックから2つの値を pop して加算した結果をスタックに push
  local.set $i    ;; スタックの値を pop してローカル変数 $i にセット

特に i32.const の直後に i32.add が続くようなスタックに push した値をすぐに pop して加工する組み合わせはひとまとめに処理した方が無駄なメモリアクセスが減って動作効率が向上します。

インタプリタ実装では仕様書で定義されているバイトコードを1つずつ読み込んで実行することしかできませんでしたが、現在のインタプリタは一旦中間命令に変換して実行しているのでインタプリタの動作に必要な新しい中間命令を追加することができます。 そこで、いくつかの定型句に関しては統合してひとつの中間命令 (fused op) に変換し、まとめて実行することで効率化しました。

これによってベンチ上の数値もだいぶ上昇しました。 同様に頻度の高い定型句を統合していけば全体的な動作速度が向上していくと思います。

スレッドのメモリリーク

メモリ管理にはいくつかのレイヤーがありますが、ここでは主に所有権とライフタイムの話になります。

Rust では GC を使わずコンパイラが所有権とライフタイムの管理をするため参照に厳しい制限があることで有名です。 しかし、コンパイラがいくら頑張っても対応できないシチュエーションに対応するため、 RcArc というリファレンスカウントによるスマートポインタもサポートしています。

リファレンスカウント方式のメモリ管理では参照するたびに参照カウンタを1つ増やして参照を保持している全員が所有権を共有し、参照を破棄する際に参照カウンタを1つ減らして0になったら全ての参照が破棄されてオブジェクトのライフタイムが終了して解放されます。 動作が単純で循環参照以外のほとんどのユースケースをカバーできるため多くの場面で同様の手法が使われています。

MYOS ではスレッドのデータを他のスレッドから参照中にスレッドが終了してデータが迷子にならないように Arc を使用しています。

今まで終了処理をあまり真面目に考えていませんでしたが、以前からスレッドの終了時に解放処理がうまく動いていない雰囲気は察していました。 気になって調べてみると、スレッド終了しても誰かがスレッドデータを参照してカウントが0にならないため解放されていませんでした。

自分で自分を消す処理というのは実現不可能なので、 MYOS のスケジューラーは最終的にスレッドステータスの終了フラグを立ててコンテキストスイッチを実行して、コンテキストスイッチ後の後片付け処理の中で終了済みスレッドのデータを解放してもらいます。 この時点で自分で自分の参照を保持していてもスレッドが永久に再開されないので参照も永久に解放されずスレッドのメモリリークが発生します。

コンテキストスイッチをするためにはスレッドのコンテキストデータにアクセスするためにスレッドデータの参照が必要です。 通常の場合はコンテキストスイッチが終わると参照が解放されますが、スレッドが終了するときは先述のように自分自身のスレッドデータへの参照を保持したまま最期のコンテキストスイッチするので解放されません。

また、スレッドからランタイムのインスタンスを取得する Scheduler::current_personality() でも Personality の参照を保持している間はスレッドを間接的に参照して保持しているので、この中でスレッドを終了するとスレッドが解放されません。

そもそも、現在実行中のスレッド自身のスレッドデータがもしも解放されてしまったら現在のスレッドを参照する様々な機能が動作しなくなるし、次回のコンテキストスイッチも実行できずにシステムがクラッシュしてしまうので、スケジューラー内のスレッドプールがスレッドを保持していて絶対に解放されることがありません。 つまり、現在実行中のスレッドが自分自身への参照で参照カウントする必要性がありません。

ということで参照カウントしないようにスレッドの参照を取得する機能を追加しました。 これによってスレッド終了時に Drop が呼び出されるようになって単純なアプリではスレッドのメモリリークは解消されたと思います。

しかし、複雑なアプリではまだメモリリークがありました。 アプリをロードだけして実際には実行しないような実験をしてもリークすることがあったので、色々な場面に細かいメモリリークが潜んでいるようでさらに調査が必要です。

また、メモリリークとはちょっと違いますが、現在のページアロケーターはメモリを解放してもフリーリストに戻す処理が未実装なので、メモリリークを起こさないアプリでも何度も起動しているとそのうちフリーエリアが枯渇する問題があります。この辺もそのうち改良していきたいです。

メモリリーク先生の戦いはこれからだ!

MYOS のウィンドウのつくりかた

MYOS/TOE のアプリでウィンドウを作るところを少し詳しく見てみましょう。

※ 執筆時点のバージョンの TOE をもとに記述しています。まだ API 安定化してないのでバージョンごとに細かい違いがあったり MYOS と TOE の間でも微妙に違うことがあります。

アプリから WindowBuilder

まずはアプリの中で WindowBuilder のメソッドでウィンドウの情報を設定して最後に build() を呼び出すと Windowインスタンスが作られます。

    let window = WindowBuilder::new()
        .size(Size::new(200, 200))
        .bg_color(WindowColor::BLACK)
        .build("bball");

WindowBuilder の中身

myoslib::window::WindowBuilder はウィンドウの作成に必要なパラメータをまとめたビルダーパターンと呼ばれる構造体のひとつで、現在は以下のような内容になっています。

pub struct WindowBuilder {
    size: Size,
    bg_color: WindowColor,
    flag: u32,
}

ウィンドウを作るにはいろいろな情報が必要なので、 myoslib::window::WindowBuilder.build() の中で self にまとめた情報をもとに os_new_window2() を呼び出してウィンドウハンドルを取得し、ウィンドウハンドルから Windowインスタンスを作成します。

pub fn build(self, title: &str) -> Window {
    let handle = WindowHandle(os_new_window2(
        title,
        self.size.width() as usize,
        self.size.height() as usize,
        self.bg_color.0 as usize,
        self.flag as usize,
    ));
    Window { handle }
}

システムコール

myoslib::syscall::os_new_window2() の中ではパラメータの型を調整して svc6() を呼び出します。

pub fn os_new_window2(
    title: &str,
    width: usize,
    height: usize,
    bg_color: usize,
    flag: usize,
) -> usize {
    unsafe {
        svc6(
            Function::NewWindow,
            title.as_ptr() as usize,
            title.len(),
            width,
            height,
            bg_color,
            flag,
        )
    }
}

svc6myoslib::syscall の中で定義された外部関数で、 WebAssembly の外の世界の関数を呼び出すことを意味します。

#[link(wasm_import_module = "megos-canary")]
extern "C" {
    pub fn svc0(_: Function) -> usize;
    (中略)
    pub fn svc6(_: Function, _: usize, _: usize, _: usize, _: usize, _: usize, _: usize) -> usize;
}

myosabi::svc::Function::NewWindowenum6 番と定義されているので svc6 の最初の引数はコンパイル結果のバイナリでは数値 6 になります。

pub enum Function {
    (中略)
    NewWindow = 6,
    (中略)
}

壮大な茶番

実はここまでのほとんどの処理はリリースビルドでは Rust の優れた最適化によって消滅します。(デバッグビルドなら残るかもしれません) 以下のように svc6 に直接引数を渡して呼び出すようにコンパイルされて、 WindowBuilder の構造体をゴニョゴニョするくだりは通常のアプリではビルド結果に残りません。

 0000ac: 41 06                      | i32.const 6
 0000ae: 41 80 80 82 80 00          | i32.const 32768
 0000b4: 41 05                      | i32.const 5
 0000b6: 41 c8 01                   | i32.const 200
 0000b9: 41 c8 01                   | i32.const 200
 0000bc: 41 00                      | i32.const 0
 0000be: 41 00                      | i32.const 0
 0000c0: 10 80 80 80 80 00          | call 0 <_ZN7myoslib7syscall4svc617h2ba79744244edc14E>

svc6 は WebAssembly の外の世界にあるので、最終的に svc6 を呼び出す部分だけが残ります。

WebAssembly の外へ

svc6 は WebAssembly のインスタンスを作るときに kernel::rt::megos::arle::ArleBinaryLoader::load() にわたすリゾルバの中で kernel::rt::megos::arle::ArleRuntime::syscall にダイナミックリンクされます。 svc0svc6 の実体は実は全て同じです。

fn load(&mut self, blob: &[u8]) -> Result<(), ()> {
    self.loader
        .load(blob, |mod_name, name, _type_ref| match mod_name {
            ArleRuntime::MOD_NAME => match name {
                "svc0" | "svc1" | "svc2" | "svc3" | "svc4" | "svc5" | "svc6" => {
                    Ok(ArleRuntime::syscall)
                }
                _ => Err(WasmDecodeError::DynamicLinkError),
            },
            _ => Err(WasmDecodeError::DynamicLinkError),
        })
        .map_err(|_| ())
}

ArleRuntime::syscall

kernel::rt::megos::arle::ArleRuntime::syscall は第1引数として WebAssembly モジュールのインスタンス、第2引数として実際に関数呼び出しで渡された引数の配列スライスを受け取ります。

fn syscall(_: &WasmModule, params: &[WasmValue]) -> Result<WasmValue, WasmRuntimeError> {
    Scheduler::current_personality(|personality| match personality.context() {
        PersonalityContext::Arlequin(rt) => rt.dispatch_syscall(&params),
        _ => unreachable!(),
    })
    .unwrap()
}

実際の API 実装ではネイティブのハンドルとアプリ固有のハンドルを仲介したりスレッドに紐付いたデータを管理するためにコンテキスト情報が欲しいので、 kernel::task::scheduler::Scheduler::current_personality() を呼び出して kernel::rt::megos::arle::ArleRuntime のスレッドに紐付いたランタイムインスタンスを取得します。 MYOS/TOE でスレッドにランタイムのインスタンスを紐付ける仕組みを Personality と言います。 ランタイムのインスタンスが取得できたら kernel::rt::megos::arle::ArleRuntime::dispatch_syscall() を呼び出します。

また、ここでやりとりしている WasmValue というのは以下のような enum で WebAssembly のプリミティブの値を型情報と一緒にラッピングしたものです。*1 WasmValue の配列スライスを受け渡すことで実際の関数の引数がどんな型でも同様に扱うことができます。

pub enum WasmValue {
    Empty,
    I32(i32),
    I64(i64),
    F32(f32),
    F64(f64),
}

ArleRuntime::dispatch_syscall

kernel::rt::megos::arle::ArleRuntime::dispatch_syscall() の中では svc6 を呼び出した際の引数のリストをデコードして最初の値を myosabi::svc::Function 型に復元し、 match 文で一致する機能番号を探します。

fn dispatch_syscall(&mut self, params: &[WasmValue]) -> Result<WasmValue, WasmRuntimeError> {
    let mut params = ParamsDecoder::new(params);
    let memory = self.module.memory(0).ok_or(WasmRuntimeError::OutOfMemory)?;
    let func_no = params.get_u32().and_then(|v| {
        svc::Function::try_from(v).map_err(|_| WasmRuntimeError::InvalidParameter)
    })?;

    match func_no {

myosabi::svc::Function::NewWindow が一致したので、残りの引数からネイティブのウィンドウビルダーを呼び出してネイティブのウィンドウハンドルを取得し、アプリ固有のハンドルに変換して最終的に WasmValue::I32 でラッピングしてアプリに返します。

        (中略)
        svc::Function::NewWindow => {
            let title = params.get_string(memory).unwrap_or("");
            let size = params.get_size()?;
        (中略)
            let window = WindowBuilder::new(title)
                .size(size)
                .build();
        (中略)
            self.windows.insert(handle, window);
            return Ok(WasmValue::I32(handle as i32));
        }

ここまで実行してアプリケーションがウィンドウを作成してハンドルを受け取ることができました。

*1:WebAssembly MVP には i32 i64 f32 f64 の数値型と空の5種類のプリミティブ型しか存在しません

wasm ランタイムの高速化

現在の自作 wasm ランタイムはインタプリタ実行しています。

インタプリタJIT

VMエミュレーターの実装方法は大きく分けると2種類あります。 ある程度一気にターゲットの機械語に変換してしまう JIT 方式と、1命令ずつ読み込んで動作を再現するインタプリタ方式です。

インタプリタJIT に比べると明らかに実行速度が劣りますが、いくつかの合理的なメリットもあります。

  • 動作が単純で比較的簡単に実装できる
  • フットプリントが小さい
  • CPUアーキテクチャの依存が少ない

もちろん簡単に実装できるのでこの方法を選択しましたが、結果的に M1 Mac に買い替えても開発効率が落ちなかったし、 MYOS から TOE への移植も比較的スムーズにできたのでトータルではプラスだったと思います。 *1

愚直なインタプリタ

さて、そんなインタプリタ方式の自作 wasm ランタイムですがやっぱり遅いです。 そして明確に遅い理由があります。

wasm はスタックベースのバイトコードを採用しています。 例えば add 命令の動作は仕様書には以下のように定義されています。(add は2項演算命令なので binop に属します。)

f:id:neriring16:20210403235446p:plain

簡単に説明するとパラメータをスタックから pop してきて演算結果をスタックに push しなさいと書かれています。

この動作をほぼそのまま愚直に実装していたのが元の実装になります。 毎回愚直にスタックの pop と push をやってるので遅いのは当たり前です。

さらに問題があるのが分岐命令で、 wasm の分岐命令は block 系の命令でネストされたブロックに対する分岐として動作します。

f:id:neriring16:20210404002516p:plain f:id:neriring16:20210404002525p:plain

ブロックのネストを管理するためにスタックが必要になり、スタックレベルの違うところへ分岐するときはスタックレベルの調整 (unwind) も必要になります。 また、 block 命令に対する分岐はブロックの終端、 loop 命令に対する分岐はブロックの先頭に分岐するなど対象のブロックによって動作が異なり、事前検証しないとブロックの終端位置がわからないので分岐が実装できません。

これらの処理をするため分岐命令は複雑でした。 分岐命令の実装のために事前検証が必須だったのでスタックの調査も同時に行うようにしていました。

そこでひとつ気付きがあります。

事前検証の段階でスタックレベルとブロックのネスト構造がわかるので毎回愚直に操作しなくてもいいのでは?

新しい実装

というわけで、インタプリタをほぼ全面的に書き直して事前検証の段階で必要な情報をまとめた固定長の中間表現に変換してしまうことにしました。

事前検証で各命令のスタックレベルが確定するので value stack は単なる配列として利用し、 block stack は対象ブロックが確定するので廃止して分岐命令は単純に実行位置を変更するだけの命令に簡略化しました。 また、 wasm のバイトコードは LEB128 という可変長エンコードされていてパラメータの取り出しが面倒なところもあったのでそれも固定長に変換しました。

switch 文などの表現に使われる br_table は元のバイトコードに分岐先のテーブルが含まれていて非常に長い命令になるので、テーブルだけ分離して別の場所に確保するようにしました。

ある程度動くようになったので試しにいくつかアプリを実行してみたところ、ベンチの数値が2〜3倍速くなりました!

もう少し速くなりそうと思っていたので現実は厳しいようです。

次のステップ

もう少し高速化の余地はあって、実行速度とデバッグ効率を両立するために全ての命令のエラー処理を見直す必要があります。

また、以前の実装では MVP 仕様の float/double 系以外の全ての命令をサポートしていましたが、一連の変更でサンプルアプリで使われていない一部の命令が原理的に実装困難になったので上手い実装方法を模索中です。

そして、高速化を決意させるきっかけになったサンプルアプリが正しく動作しなくなったので現在調査中です。

*1:逆に言うと現在の開発環境では JIT のテスト実行できないので将来 JIT 実装する時は工夫する必要があります...

Wasm on Wasm

ついに、 WebAssembly の自作エミュレータの上で動く自作 OS の上で自作 WebAssembly ランタイムが動くようになりました 🎉

つまり Wasm on Wasm ですね٩( 'ω' )و

以下に試験的に体験版設置してるので起動してみることができます。

meg-os.org

f:id:neriring16:20210329214942p:plain

もちろん PC98 や FM TOWNS でもなんとなく動きます。

f:id:neriring16:20210329214729p:plain

f:id:neriring16:20210329223029p:plain

基本的には MYOS で実装したものを若干調整して TOE に移植しただけです。最初に移植したときは動くアプリと動かないアプリがあって、調査しようとしたら wasm ランタイムを外部ライブラリに分離して no_std の関係で今まで使えた println! が使えなくなって原因の特定に若干手間取りました。 最終的には、内部で使ってる独自スタッククラスが原因でした。

独自スタックが必要な理由は、 wasm では関数1つ呼び出すたびに複数のスタックが必要になるので個別に確保するのは効率が悪いです。 特に value stack と block stack は事前検証で必要なサイズがわかっています。 *1 そこで、いったん大きめに確保した後で独自クラスの slice として分配してスタックを作っていましたが、環境によってサイズの違う型の計算が間違っていてスタックが重複して破壊されていたようです。 この部分は unsafe な操作を含むので Rust コンパイラくんでも検出できなかったようです

ともあれ、無事に動くようになったのでそろそろ TOE も最初のマイルストーンに到達しそうです。

Wasm on Wasm って一度やってみたかったネタなので、このためだけに3ヶ月くらいがんばりましたヽ(•̀ω•́ )ゝ✧

*1:wasm では分岐命令がネストしたブロックに対して相対的に行われるので、ブロックの構造を事前検証しないと分岐先や value stack の調整ができません