データ分析プロジェクトの品質をキープしつつ効率的な検証をサポートする一時ファイル群の管理

研究開発部の takahi_i です。本稿はデータ分析、 機械学習関係のプロジェクトで数多く生成される一時オブジェクトおよびそれらのオブジェクトを保持するファイル(一時ファイル)を管理する取り組みについて解説します。

本稿の前半はデータを分析するプロジェクトの一般的なフローと起こりがちな問題(コードの品質管理)について解説します。後半はプログラム上で生成されるオブジェクト群をファイルに自動でキャッシュを管理するツール(Hideout)を使って、コードを整理整頓しやすくする施策について紹介します。

データを分析するプロジェクトの一般的なフロー

まずデータを処理するプロジェクトや機械学習プロジェクトの典型的なフローについて考えてみます。まずは単純に機械学習器を取得した入力に対して適用するプロジェクト、次にもう少し複雑な事例、アプリケーションで利用するデータを生成するプロジェクトのフローについて見てゆきます。

単一の機械学習器を適用するプロジェクト

データ分析するプロジェクトの典型例として機械学習器を入力データに対して適用するプロジェクトを考えます。

学習用のデータのように静的に変化しないデータはレポジトリに同梱されることもありますが、Webサービスのデータが対象の場合、推論用のデータの多くは日々変化するため、データベース(DWH)から動的に取得します。

機械学習を適用するプロジェクトは取得したデータを多段に変換しながら出力となる判別結果を生成してゆきます。

以下の図は一般的な機械学習を利用したプロジェクト検証スクリプトのフローです。

f:id:takahi-i:20191015114010p:plain
単一の機械学習器を適用するプロジェクト

上記のプロジェクトでは必要なデータを取得、学習、推論(テスト)と連続して処理がおこなれています。各処理ごとにデータは変換され、生成されます(例えば学習後にはモデルオブジェクトが生成されます)。

たとえば、レシピに含まれるステップを本来のステップとそれ以外(コメントなど)に分類する バッチ処理(こちらを参照)はちょうどこのような内容になっています。このバッチでは一部のデータを切り出して処理しているのですが、それでも取得、学習、推論(テスト)ステージでかなりの時間がかかっています。

アプリケーション用データを生成するプロジェクト

さらに自然言語のような非定形データを扱う中でも複雑なタスク(対話や質問応答)ではDWHから取得した データを多くのオブジェクトを生成しつつ多段で加工してゆくことも一般的です(End-to-Endの学習で一度に処理してしまう場合もありますが)。

f:id:takahi-i:20191015114231p:plain
多段に処理をするプロジェクト

クックパッドの研究開発部においてこのような多段の処理を適用するプロジェクトに レシピのMRRへの変換や、 クックパッドのAlexaスキルが提供する調理補助用の質問応答 で利用されているKnowledge Baseの生成プロジェクトがあります。

これまで紹介した2つのタイプのプロジェクト(単一の機械学習器を適用するプロジェクト、アプリケーション用データを生成するプロジェクト)のどちらのプロジェクトタイプでも、まず一つ以上の入力データを抽出します。データは入力データ、必要なリソース(辞書)、機械学習器が出力したアノテーション結果などがあります。そしてその後のデータ変換、集約処理が多段に続きます。各処理では一時オブジェクトを入力として別のオブジェクトを生成します。

そしてこのオブジェクトの集約、加工、生成処理の実行はそれぞれ時間がかかります。このことがデータ分析プロジェクトを中長期メインテナンスする場合に品質上の問題を引き起こします。

データ処理スクリプトに対する修正要求とコード品質問題

データを分析、加工するプログラムでも普通のプログラムと同じように機能追加の要求にさらされ修正され続けます。 多くの場合、修正するべき箇所はプログラムの中の一部に過ぎませんが、修正が問題をはらんでいないかをチェックする にはE2Eテスト(機械学習の学習、Inferenceなど)を走らせます。また、自然言語を入力とするタスクの場合にはテスト しきれないことがどうしても発生するため、一部のデータを使ってプログラムを実行して変更が問題を発生していないかも確認したくなります。

このような検証目的の実行時に扱うデータの規模は大規模ではなくても、各ステージごとの処理に十秒から数分かかってしまいます。

結果、微細な修正をした後にコードに問題がないかを確認するだけでも結構な時間を消費してしまいます。 実行に時間がかかりコードを修正をするコストが大きくなるため、コードを修正するサイクルが大きく(試行できる回数が少なく)なり、コードを整理するハードルが高くなってしまいます。このような状況ではプログラムの検証実行や、テストに時間がかかりすぎるためコードの品質をキープしづらくなります。

不十分な解決方法

検証時の実行に時間がかかってしまう問題に対するする解決策として、以下のような対処方法が考えられます。

一時ファイルの手動追加

データの取得、生成時間を省略するために、データベースから切り出した入力データや機械学習器が出力したモデルファイルのような一時データを保持するファイル(一時ファイル)をレポジトリや、ローカルディレクトリに同梱しているプロジェクトを見かけます。データ分析プロジェクトの一時ファイルは、モデル、データベースから抽出した辞書リソース、前処理済みの入力データなどがあります。

たとえば以下のプロジェクトでは入力ファイルに前処理を適用したファイル(preprocessed_input1.txt)と、機械学習器が生成したモデルファイル(validation_model1.dat)をレポジトリに同梱しています。

.
├── Makefile
├── README.md
├── config
│   ├── __init__.py
│   └── env.py
├── data
│   ├── dictionary.dic
│   ├── preprocessed
│   │   ├── preprocessed_input1.txt
│   │   └── preprocessed_input2.txt
│   ├── models
│   │   ├── validation_model1.dat
│   │   └── validation_model2.dat
...

もちろんこれらの一時ファイル群は本来はレポジトリに含まれるべきではありません。それでも、こういった一時ファイルを利用することでプログラムの動作検証の速度を向上できます。

しかし、このように安易に加工済み入力ファイルやモデルファイルをレポジトリに同梱してしまうと問題が発生します。

問題の一つは加工済み入力データの生成方法がコードから分離してしまい、プロジェクトが進むにつれデータの加工方法と乖離してしまう点です。 入力データも含め、プログラムで扱うデータやオブジェクトはコード修正とともに変化してゆきます。 たとえばモデルファイルのような生成されたデータを一時ファイルから読み出して検証的に実行している場合、コードの修正によってデータがファイルから読み出しているものから変化し本来は実行時に問題が発生していることがあります。残念ながら一時ファイルを利用して検証実行している場合、このような問題に気がつくのは難しいです。というのも修正した部分(たとえば検証用に切り出した小規模データでの学習処理)はキャッシュファイルを使うと実行されず、テストやローカル環境での実行は中途半端にうまく動作してしまうのです。

さらにテストがレポジトリに添付されたモデルファイルを利用してしまっている場合には、CI環境でもコードの修正にともなうバグを検知できません。 このような状況で問題が発覚したときにはコミットすでにがかなり積まれてしまい、問題箇所を同定するのが難しくなっていることがあります。

もう一つの問題は、レポジトリを中長期メインテナンスすると発生します。本来は一時的な目的でVCSレポジトリに追加されたはずの中間オブジェクトを保持するファイル群は、役割を全うした後も消されることなく(消し忘れ)レポジトリにとどまり続けることがあります。このような消し忘れファイル群はプロジェクトの開発時には問題にならないのですが、時間経過(半年、一年)を経るとエンジニアは生成方法を記憶していないため特に引き継ぎ時に大きな問題になります。

キャッシュ処理の追加

必要な一時ファイルを活用するためのもう少しましな解決方法に、関数へのキャッシュ処理の追加があります。 たとえばMRRの生成プロジェクトでは、入力、中間データ(それぞれが数十〜数百MBのデータ)をファイルにキャッシュをすることで、 検証時の実行速度を向上して開発速度を高めました。以下はMRRの生成プロジェクトで利用されているメソッドの一部です。

def get_ingredient_id_map(cache_file_path):
    if os.path.exists(cache_file_path) and not self.force:
        with open(cache_file_path, mode='rb') as f:
            return pickle.load(f)

    ingredient_id_map = _get_ingredient_map_impl()

    if not os.path.exists(cache_file_path) or not self.force:
        with open(cache_file_path, mode='wb') as f:
            pickle.dump(ingredient_id_map, f)
    return ingredient_id_map

この関数は、キャッシュファイルがあればロードしたものを返し、なければ生成したうえで、(キャッシュ)ファイルに保存します。 この関数を使うことでローカル環境で(2回目の実行以降)オブジェクトを生成するコストは低減できます。

大きめのオブジェクトを生成する関数にキャッシュファイルを生成する処理を付与することでテストや検証目的に実行していたプログラムの実行時間が、数分から10秒程度に減少できました。これによりコードを積極的に整理できるようになりました。 またCIで簡単な学習➡推論をすることで、学習プロセスに問題ないかを常時クリーン環境でテストし続けられるという 利点があります。

しかしこのやり方にも問題があります。数多く存在する中間オブジェクトごとに上記のようなキャッシュする処理をつけるのは面倒ですし 、処理内容とは関係のない内容で関数を埋めてしまうのにも抵抗があります。また、多くの機能追加の要請で必要な修正は コードの一部のコンポーネントに限られます。そのため一部のキャッシュだけは効かせたくない場合がありますが、 各変換処理にキャッシング処理をベタ書きした状態では対応が難しいです。

こういった問題を解決するため、最近はオブジェクトのファイルへのキャッシュ処理を自動化する Hideout という簡素なツールを作って利用しています。

Hideout: データ分析プロジェクト用、ファイルキャッシュ

Hideoutはオブジェクトを生成するタイミングでキャッシュファイルもあわせて生成するツールです。 実行時に環境変数で指定するキャッシュ設定がオンになっていてかつ生成されたキャッシュファイルが存在すれば、 キャッシュファイルをロードしてオブジェクトを返し、なければ指定された生成用関数を呼び出します。

基本的な使い方

たとえば以下の generate_large_object 関数はオブジェクトを生成するのに時間がかかります(人工的なサンプルですが)。

def generate_large_object(times):
    sleep(1000)
    return map(lambda x: x*2, range(times))

この関数から生成されるオブジェクトを Hideout でキャッシュするには以下のように記述します。

large_object = hideout.resume_or_generate(
    label="large_object",
    func=generate_large_object,
    func_args={"times": 10}
)

funcにはオブジェクトを生成する関数、func_argにはfuncを実行するのに必要な引数を辞書として渡します。

HideoutはデフォルトではキャッシュがOffになっています。そのため、 デフォルトではキャッシュはされず単にfuncに指定された関数を実行してオブジェクトを生成します。

キャシュをOnにしてオブジェクトを使いまわしたい場合には環境変数、HIDEOUT_ENABLE_CACHETrueを設定します。 ローカルで検証しているときにはコマンドを実行するターミナルで環境変数を指定します。

使用例

クックパッドのAlexaスキルで使用している質問応答用のKnowledge Baseを生成するプロジェクトではHideoutを利用して 生成される中間オブジェクトの一部をキャッシュしています。

該当レポジトリは Cookiecutter Docker Science テンプレートで生成されているので、 Makefileをワークフローの管理に使用しています。MakefileにはKnowledge Base生成用のターゲットを登録してあります。 ローカルにおける検証では以下のようにキャッシュをOnにして実行しています。

$ make generate BATCH_SIZE=500 HIDEOUT_ENABLE_CACHE=True 

テストではモデルファイルを使ったE2Eのケースも含まれていますが、同じように make コマンドに HIDEOUT_ENABLE_CACHE=True を指定した上で実行すると数秒で終わります。

$ make test HIDEOUT_ENABLE_CACHE=True

Hideoutにおいてキャッシュ設定はデフォルトではOffになっているので、CIやプロダクション環境で誤ってキャッシュファイルが生成されることはありません。 そのため修正時にはPull Requestをこまめに作り細かくコミットをプッシュすると、キャッシュが効いていない環境でテストが走るため、思わぬ不具合に気づけて便利です。

ステージごとにキャッシュOffを指定

本稿の前半で紹介したようにデータ処理をするプロジェクトには複数のデータソースを複数のステージで多段に加工するものがあります。

f:id:takahi-i:20191015114414p:plain
複数のステージから成るプロジェクト

上記の図ではデータを抽出した後「Preliminary1」や「Preliminary2」、「Transform」というステージがあります。 このような少々の複雑さをもつプロジェクトであっても機能拡張依頼が来たとき、多くの場合には修正する箇所は ソースコードの一部でしかありません。

このようなとき修正が必要な一部のステージだけキャッシュファイルをロードする処理をオフにしたいことがあります。この目的のために HideoutはHIDEOUT_SKIP_STAGESという環境変数を提供しています。たとえばキャッシュした ファイルを利用して実行したいが、Preliminary2Transform ステージだけはキャッシュを Offにしたい場合が考えられます。 このような場合、make build HIDEOUT_ENABLE_CACHE=True HIDEOUT_SKIP_STAGES=Preliminary1,Transform と キャッシュをしないステージを指示します。

Hideoutにおいて指定するステージ名はhideout.resume_or_generatelabelオプションで付与します。

large_object = hideout.resume_or_generate(
    label="Preliminary1",
    func=generate_preliminary_object,
    func_args={"times": 10}
)

今後

Hideoutを利用するユーザはプログラム中の関数ではなく、キャッシュファイルを生成している部分に適用する部分に処理を追加します。これはキャッシュする部分を追いやすくするためで、インターフェース名もHideoutが利用される箇所がわかりやすくなるように長く(resume_or_generate)なっています。 ただ最近、同僚から「デコレータでやったほうがシンプルなんでは」というコメントを頂きました。そこで今後デコレータのインターフェースも提供してみたいと考えています。

まとめ

本稿はデータ分析をするプロジェクトにおける一時オブジェクトを保存したファイル(一時ファイル)の扱いについて解説しました。一時ファイルはデータ分析の結果を高速に検証するのに便利ですが、安易に レポジトリに追加するとプロジェクトの保守性が下がります。

また、必要な入力データや中間オブジェクトをファイルにキャッシュする処理を追加するのも コストがかかります。そこで本稿の後半では、このようなプロジェクトを保守するのに役立つキャッシュツール、Hideoutについて紹介し、一時ファイルを利用しつつもレポジトリをクリーンに保つ方法について解説しました。

退職処理を可能な限り自動化する

技術部 SRE グループの id:itkq です。2019 夏アニメで一番好きな作品は Re:ステージ!ドリームデイズ♪ です。この記事では SRE が運用している退職処理の自動化について説明します。

退職処理とは

入社後に業務のための様々なアカウントを作成するのと反対に、退職時にはそれらのアカウントを無効化する必要があります。これを退職処理と呼んでいます。SRE が管轄している典型的な例では、SSO に対応していない SaaS のログインアカウント・AWS の IAM User・データベースの個人ログインユーザなどが該当します。これらのアカウントは社員によって要否が異なったり必要な権限が異なるため、入社時に一括で用意せず必要に応じて申請してもらう形をとっています。一方で退職時にはそれらのアカウントをすべて無効化する必要があります。 退職処理は繰り返され、自動化の余地のあるタスクです。また、SRE 以外でも退職処理を行うチームがあることは分かっていたため、退職処理の自動化のための共通の仕組みを考えることにしました。自動化のための第一歩として、退職のイベントを扱いやすい形で発生させる必要があります。

退職イベントを発生させる

クックパッドでは、ヘルプデスク1が社内 IT のアカウントを管理しており、退職時はヘルプデスクが退職者の Active Directory (以降 AD と略記) アカウントを無効化します。入社時のアカウントの作成は人事システム経由で自動化されている一方で、退職時は退職者によってタイミングなどが複雑であることがしばしばあるため、「人間による無効化」によって退職処理を開始させるようにしています。 AD とは別に、SSH や GitHub Enterprise ログインなどに使う目的で OpenLDAP によるアカウント管理も運用しています。これまでヘルプデスクと SRE でそれぞれ AD, OpenLDAP を運用してきましたが、手を取り合って AD に一本化する計画を進めています。移行期間中は AD と OpenLDAP 間で齟齬が起きることが予想されたため、AD と OpenLDAP 間で属性を同期し、パスワード変更時は AD と OpenLDAP 間で同時に変更させることで、将来の統合を楽にする目的の pasuwado というシステム (id:sora_h 作) が稼働しています。 pasuwado は AD と OpenLDAP 間の属性の同期をバッチ処理で行っています。AD のアカウントを無効化情報を pasuwado に管理させることは、本来の責務から外れすぎず、同じようなバッチ処理で実装できる見込みがあったたため、AD アカウントの無効化タイミングを保存しつつ退職イベントを扱いやすい形で発生させる機能を pasuwado に組み込みました。実運用では、誤って AD アカウントを無効化してしまうヒューマンエラーや、アカウントの無効化やリソースの削除を遅らせたい要求があることを考慮する必要があります。そこで「無効化が発見された直後」、「無効化を発見してから 3 日後」、… のようにいくつかのタイミングでイベントを発生させ、受け取る側では都合のいいようにフィルタする設計にしました。SRE では「無効化を発見してから 3 日後」のタイミングで退職が確定したとみなすようにしています。このイベントは pub/sub メッセージングサービスである Amazon SNS に送信します。次に示すのは SNS に送信されるメッセージの例です。detected_at は pasuwado のバッチが AD アカウントのサスペンドを発見した時刻、elapsed はサスペンドを発見してから経過したおおよその秒数です。

 {
    "name": "mana-shikimiya",
    "detected_at": "2019-04-23T00:00:00+09:00",
    "elapsed": 0
 }

退職イベントを受け取り自動処理する

退職イベントが送信される SNS を購読することで、自動化する退職処理のトリガーにすることができます。SNS を使うことで購読方法は選択肢の中から好きなものを選ぶことができ、また自動化処理同士を独立させられます。実際に自動化されている処理について例を挙げながら説明します。

例1: GitHub に issue を立て Slack に通知する

AD アカウント無効化直後のイベントを受け取ると、退職処理をまとめる GitHub リポジトリに issue を立てるジョブを実装しました。この issue は他の退職処理の結果をコメントしていき、スタックする用途のものです。また、誤って AD アカウントを無効化してしまったことに気づきやすいように Slack への通知も同時に行っています。 このジョブは barbeque2 ジョブとして実装しています。barbeque は ECS を基盤とする非同期ジョブ実行環境で、ジョブのリトライなどの管理を任せながら、SNS を購読し ECS で動作するジョブの実装を少ない手間で行うことができます。次の図は pasuwado から barbeque ジョブまでの動作フローです。

f:id:itkq:20191009220409p:plain
pasuwado から barbeque ジョブ実行までの動作フロー

例2: IAM User を削除する

AWS の IAM User は miam という IaC ツールで Git 管理しています。master ブランチが更新されると CI が走り AWS 上のリソースが変更されます。 無効化から 3 日後のイベントを受け取ると退職が確定したとみなし、IAM を管理するリポジトリをクローンし、退職者の IAM User を発見したらそれを削除する Pull Request を出しマージするジョブを同様に barbeque ジョブとして実装しました。この Pull Request は、例1 で述べた issue に紐づけています。

その他

上で挙げた例以外に、SRE が持つ稼働中の自動化ジョブは以下のものがあります。

  • MySQL 個人ログインユーザの削除
  • GitHub Enterprise アカウントのサスペンド
  • PagerDuty のユーザーをチームから削除
  • Amazon SNS Topic の個人 email subscription の削除
  • github.com のアカウントを cookpad organization から削除

また、他のチームの利用事例として、DWH チームによる Redshift の個人ログインユーザの削除・個人スキーマの削除があります。

自動化による SRE の退職処理運用の変化

退職処理の自動化によって、運用がどう変わったかについて説明します。

自動化以前

週次で SRE のうち一人がアサインされ、スプレッドシートに記録された退職者のアカウント情報を元に手動で処理していました。具体的には、SRE が管理するサービスのアカウントやリソースに該当アカウントがないかチェックし、見つかればそれを削除したり無効化してスプレッドシートに作業内容を記録していました。SRE 管轄のアカウントはそれなりに量があるため、場合によっては面倒な作業でした。

自動化以後

退職処理を伴う退職者が存在したかどうか週ベースで自動チェックし、存在した場合は SRE のうち一人がアサインされます。アサインされる issue には、退職処理済みのアカウントに対応する、例1 で述べた issue が紐付けられています。この issue には、自動処理の結果と自動化しきれず手動で行うべき処理が書かれており、これを手動で行ったら issue をクローズし、紐付けられたすべての issue をクローズしたら作業完了です。 元々あったスプレッドシートの運用は、作業内容を誰がいつ行ったかを記録する目的のものだったため、それが issue 上で行われるようになった現在では不要となりました。また、自動化できない処理とは例えば、SaaS のアカウントを無効化したいが無効化を行う API が無いなどです。いい感じの API が生えてくれることを願っています。

まとめ

SRE で運用している退職処理自動化の仕組みについて説明しました。汎用的な仕組みとして設計したため、この仕組みを活用して退職処理を自動化している SRE 以外のチームもあります。 繰り返される自動化可能なタスクを可能な限り自動化していくことにより、本質的な作業にかける時間を増やすことができます。この仕組みを導入してから 1 年以上が経過しており、自動化を実装する時間と比較しても退職処理にかける時間をだいぶ省けている実感があります。退職処理が面倒だと感じている方はこの記事で述べたような自動化の仕組みを検討してみてはいかがでしょうか。


  1. 通称。正式にはコーポレートエンジニアリング部サービスデスク・インフラグループ

  2. https://techlife.cookpad.com/entry/2016/09/09/235007 を参照

Ruby中間表現のバイナリ出力を改善する

Ruby 開発チームに4週間インターン生として参加いたしました、永山 (GitHub: NagayamaRyoga) です。 私は「Ruby中間表現のバイナリ出力の改善」という課題に取り組み、Railsアプリケーションのコンパイルキャッシュのサイズを70%以上削減することに成功しました。以下ではこの課題の概要とその成果について述べたいと思います。

InstructionSequenceの概要

まず、RubyVM 内で実行される命令の中間表現、InstructionSequence (以下 ISeq と省略) について簡単に説明します。

通常の Ruby プログラムは、以下のような手順で実行されます。

  1. ソースコードを構文解析し、抽象構文木を作る。
  2. 抽象構文木をコンパイルして、ISeq を作る。
  3. RubyVM (YARV) で ISeq を解釈し、実行する。

ISeq は、このように RubyVM で解釈される命令列に関する情報を含んだ一種の中間表現です。

ISeq に関する API は RubyVM::InstructionSequence としてその一部が外部に公開されているため、Ruby プログラムからも (ごく簡単な操作に限ってですが) 取り扱うことが可能です。

# 文字列をコンパイルして ISeq を得る
iseq = RubyVM::InstructionSequence.compile("p 42")

# 得られた ISeq を RubyVM で実行する
iseq.eval
# => 42

# ISeq に含まれている命令列を出力する
puts iseq.disasm
# => == disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,4)> (catch: FALSE)
#    0000 putself                                                          (   1)[Li]
#    0001 putobject                    42
#    0003 opt_send_without_block       <callinfo!mid:p, argc:1, FCALL|ARGS_SIMPLE>, <callcache>
#    0006 leave

また、#to_binary メソッドを呼び出すことで ISeq をバイナリデータにシリアライズすることができます。

bin = iseq.to_binary
p bin
# => "YARB\x02\x00\x00\x00\a\x00\x00\x00D......"

もちろん、シリアライズされたバイナリデータから ISeq に戻すことも可能です。

iseq2 = RubyVM::InstructionSequence.load_from_binary(bin)
iseq2.eval
# => 42

コンパイルキャッシュとBootsnap

では、上の機能がどのように活用できるのかについて説明したいと思います。

先程も述べた通り、Ruby プログラムは実行されるたびにスクリプトファイルの構文解析が行われます。

  1. ソースコードを構文解析し、抽象構文木を作る。
  2. 抽象構文木をコンパイルして、ISeq を作る。
  3. RubyVM (YARV) で ISeq を解釈し、実行する。

しかし、スクリプトファイルが変更されていなけば、コンパイル結果として得られる ISeq が実行ごとに変化するようなことはありません。 同じ ISeq が得られるにも関わらず、構文解析やコンパイルが行われるのは冗長です。

特に、短時間に何回も実行されるようなプログラムや、多数のスクリプトファイルで構成される巨大なアプリケーションではコンパイル結果 (ISeq) をバイナリデータとしてキャッシュしておくとその起動速度を向上できるかもしれません。

Rails5.2以降ではデフォルトでプロジェクトにインストールされる Bootsnap という gem は、前項で説明した #to_binary メソッドを使って、スクリプトファイルのコンパイル結果を自動的に ./tmp/ 以下のディレクトリにキャッシュしてくれます。 Bootsnapはこの他にもautoloadしたファイルのパスなどをキャッシュすることでRailsプロジェクトの起動時間を50%〜70%程度縮めることに成功しています。 例えば、$ rails new によって生成されただけの空のRailsプロジェクトでは、Bootsnapによって起動時間が約65%短くなるのを確認できました。

課題

さて、この #to_binary ですが、その出力にはかなりの無駄があります。

iseq = RubyVM::InstructionSequence.compile("p 42")
p iseq.to_binary.length
# => 580

p 42というごく小さいコードから生成されたバイナリにも関わらず、その出力は 580byte という大きさになってしまいました (出力のサイズは環境によって異なります)。

当然、より大きいコードからは大きいバイナリが生成されます。 さきほどの空Railsプロジェクトであれば、Bootsnapが1632個の.rbファイルをキャッシュしており、そのキャッシュファイルの合計サイズは 32MB ほどになりました。

というわけで、本課題の目的はこの #to_binary の出力するバイナリのサイズを小さくすることです。 #to_binary の出力が小さくなると単純にストレージや転送時間の節約になるほか、Bootsnapがコンパイルキャッシュにアクセスする際のディスクアクセスが少なくなるため、Railsアプリケーションの起動時間が短くなることが期待されます。

方法

#to_binary の実装の大部分は Rubycompile.c に書かれています。

今回のインターンシップではこの実装を読みつつ、部分部分を書き換えていくことで徐々に出力のサイズを小さくしていきました。

特にバイナリサイズの削減に寄与した変更は主に以下の2つです。

1. 不要な構造体フィールドの出力の削除

従来の実装では ISeq の情報を格納した構造体の、本来出力する必要のないものや、常に同じ定数が出力されているフィールドなどが存在していました。 コードを読み解いて、それらを出力に含めないようにすることでバイナリのサイズを削減しました。

2. 整数値の符号化方法を変更

また、出力に含まれていたあらゆる整数値はほぼすべてが固定長で符号化され、4byteや8byteのデータ長で出力されていました。 しかし、出力される整数値はその出現頻度に大きな偏りがあり、多くが 01 などの少ないbit数で表現できる値です。 そこで、UTF-8を参考に可変長な整数の符号化方法を考え、導入することにしました。

0x0000000000000000 - 0x000000000000007f: 1byte | XXXXXXX1 |
0x0000000000000080 - 0x0000000000003fff: 2byte | XXXXXX10 | XXXXXXXX |
0x0000000000004000 - 0x00000000001fffff: 3byte | XXXXX100 | XXXXXXXX | XXXXXXXX |
0x0000000000020000 - 0x000000000fffffff: 4byte | XXXX1000 | XXXXXXXX | XXXXXXXX | XXXXXXXX |
...
0x0001000000000000 - 0x00ffffffffffffff: 8byte | 10000000 | XXXXXXXX | XXXXXXXX | XXXXXXXX | XXXXXXXX | XXXXXXXX | XXXXXXXX | XXXXXXXX |
0x0100000000000000 - 0xffffffffffffffff: 9byte | 00000000 | XXXXXXXX | XXXXXXXX | XXXXXXXX | XXXXXXXX | XXXXXXXX | XXXXXXXX | XXXXXXXX | XXXXXXXX |

この方法では、7bitで十分に表現できる値は1byteに、14bitで表現できる値は2byteに、というように符号化する整数の大きさによって必要なバイト長を変化させています。

UTF-8では1byte目の上位bitを使って後続のバイト数を表しているのに対して、この符号化方法では下位bitの連続する0bitの個数でバイト数を表現しています。 このような形式を採用した理由は、x86_64などの命令セットではbsftzcntといった命令を用いることで後続のバイト数が1命令で数えられるためです。

評価

これらの変更によって、バイナリの読み込み速度を損なうことなく #to_binary の出力のサイズを平均して 70%から75% 程度小さくすることに成功しました。 上記の空Railsプロジェクトでは、キャッシュファイルのサイズは合計 9.4MB (元の約30%) になりました。

f:id:NagayamaRyoga:20190926141101p:plain

その他の詳細なデータに関しては以下のチケットにまとめてあります。

https://bugs.ruby-lang.org/issues/16163

苦労した点

以下、今回の課題に取り組むにあたって苦労した点です。

プロジェクトの規模が大きいこと

Rubyは20年以上も継続して開発が続けられているプロジェクトであり、1万に近い個数のファイルによって構成されています。 特にC言語で記述されたソースコードの中には1ファイルが1万行を超えているものもあり、 (今回の実装に関連する部分はそのごくごく一部とは言え)処理の流れを把握するのが大変でした。

インターン期間の最初の1日は、ソースコードを読みながらバイナリデータを手でデコードし、おおよその処理の流れとデータ構造を理解していきました。

マルチプラットフォームなソフトウェアであること

Rubyは様々なOS、CPU、etcで実行される可能性のあるプログラムです。 そのため、どのような環境であっても正しく動作をするようにプログラムを書く必要がありますが、 C言語はその言語仕様の詳細 (例えば整数型のサイズと表現可能な数値の範囲、式の評価方法、評価結果など) の一部を"処理系定義"としています。

"処理系定義"の動作はコンパイラや環境によって異なる可能性があるため、 ある環境では動作をするが別の環境では動作しない、というようなことが起こらないように常に意識をする必要がありました。

まとめ

バイナリの読み込み速度を損なうことなく、そのサイズを70%以上も削減することに成功しました。 2019年12月リリース予定のRuby 2.7にこれらの変更が取り込まれ *1、実際のRailsアプリケーション上で動作するようになります。

世界的に有名なOSSに対して1ヶ月という短期間で貢献できたことは非常に貴重な経験になりました。 この場を借りて、メンターである笹田さんと遠藤さんに御礼を申し上げます。