弊社iOSチームにおける、Xcodeのビルド速度改善の取り組みの一端について紹介したいとおもいます。
おいしい健康のiOSアプリでは、機能開発を重ねていく過程で、コードベース量に比例してビルド時間が長くなり、数行の変更で1分近くビルド時間を要するなど、開発効率に難がある状態が続いていました。
ビルド時間は、ビルドキャッシュがほぼない状態でのクリーンビルドか、一定のビルドキャッシュの効いたインクリメンタルビルドかによって大きく異なります。
日々の開発への影響度が大きいのはインクリメンタルビルドであるため、これを計測・可視化し、開発生産性の観点で問題の大きさを定量的に把握したいと考え、チーム内のビルド時間に関する簡易的な統計データを収集することにしました。今回はその計測と可視化のアプローチについてご紹介します。
最終的な成果物となるXcodeビルド時間を可視化したダッシュボードは以下のようなものです。
チームにおけるビルド速度改善の必要性を判断するための指標の1つとして活用しています。
内容としては、クリーンビルドとインクリメンタルビルドそれぞれに要する時間、日毎の総ビルド実行回数・総ビルド時間などを可視化したものになります。
ダッシュボードはLookerを使用しており、簡易的なシステム構成としては以下のようなものです。
Xcodeで、あるスキームに対するビルドが行われると、.xcactivitylog
形式のビルドログ情報を含むファイルが~/Library/Developer/Xcode/DerivedData/
下に出力されます。
これを解析し、フォーマット化したビルドメトリクスを、Amazon S3にアップロードするスクリプトがビルド毎に実行されます。(Run Script Phase)
最終的にLookerで可視化を行う上で、S3にアップロードされたビルドログをBigQueryにロードする必要があるため、日毎に指定のBigQueryへ同期するためのデータ転送設定をスケジューリングします。Xcodeが出力したビルドログをS3にアップロードするまでのより詳細な流れとしては以下になります。
- 1.) ビルドを開始
- 2.) ビルド後、Post Build Scriptとしてlauncherスクリプトを実行
- 3.) XcodeがxcactivityログをDeviredDataへ出力
- 4.,5.) xcactivityログを解析・フォーマット化し、S3へスキーマJSONをアップロード
ポイントとしては、2.)のビルド後のRus Script Phaseで実行するlauncherスクリプトで、Xcodeによるxcactivityログ出力をバックグラウンドで待ち受ける処理を挟んだ上で、本体となるスクリプトを実行するようにしています。 launcherスクリプトの内容は以下のようなものです。
#!/bin/sh executable=$1 shift; $executable "$@" <&- >&- 2>&- &
Xcodeによるビルドログ出力はRun Script Phase後に行われる為、launcherスクリプトを挟まずに本体スクリプトを実行すると、直近のビルドログを得られなくなってしまいます。 これを回避するために、不要な入出力を遮断した上で、バックグラウンドで指定コマンドを実行する形で本体スクリプトの実行をトリガします。 弊社iOSチームでは、XcodeGenでXcodeプロジェクト管理を行なっており、具体的なRun Script Phase設定内容を表すproject.ymlは以下のようなものになります。
... targets: xxx: ... postBuildScripts: - name: Upload xclogparser result JSON to S3 script: | cd "$PROJECT_DIR" && \ S3_BUCKET=s3://xcbuildmetrics.example.com \ scripts/post_build_script_launcher.sh \ # 先述のlauncherスクリプト python3 scripts/xcbuildmetrics_uploader.py # s3にビルドログをuploadする本体スクリプト
DerivedDataに出力された.xcactivitylog
を解析・フォーマット化し、スキーマJSONをS3へアップロードする本体スクリプトは以下のようなPythonスクリプトです。
import inspect import json import os import pathlib import subprocess import tempfile import time import typing as t from copy import deepcopy from dataclasses import asdict, dataclass from datetime import datetime from enum import Enum JSON = t.Dict[str, t.Any] @dataclass class BuildStep: startTimestamp: float compilationDuration: float schema: str fetchedFromCache: bool subSteps: list[BuildStep] machineName: str type: str schema: str def __post_init__(self): self.subSteps = [BuildStep.from_dict(x) for x in self.subSteps] @classmethod def from_dict(cls, data: JSON) -> BuildStep: attrs = inspect.signature(cls).parameters return cls(**{k: v for k, v in data.items() if k in attrs}) def flatten(self) -> list[BuildStep]: steps: list[BuildStep] = [] no_sub_steps = deepcopy(self) no_sub_steps.subSteps = [] steps.append(no_sub_steps) for subStep in self.subSteps: steps.extend(self._flattenSubStep(subStep)) return steps def _flattenSubStep(self, subStep: BuildStep) -> list[BuildStep]: details: list[BuildStep] = [] no_sub_steps = deepcopy(subStep) no_sub_steps.subSteps = [] details.append(no_sub_steps) for detail in subStep.subSteps: no_sub_steps = deepcopy(detail) no_sub_steps.subSteps = [] details.append(no_sub_steps) if detail.subSteps: details.extend(detail.subSteps) return details class BuildCategory(Enum): NOOP = "noop" CLEAN = "clean" INCREMENTAL = "incremental" @dataclass class BQRecord: start_time: float compilation_duration: float machine_name: str build_category: str scheme: str def tweak_parse_result(data: JSON) -> JSON: """ To prevent schema inconsistencies, unify the data types of the 'duration' like fields with float. """ for k in data: if isinstance(data[k], list): for d in data[k]: if isinstance(d, dict): tweak_parse_result(d) elif isinstance(data[k], dict): tweak_parse_result(data[k]) elif "duration" in k.lower(): data[k] = float(data[k]) return data def get_xclogparse_json() -> JSON: """ Generate a JSON of XCLogParser's parse result """ parse_output = subprocess.check_output(["xclogparser", "parse", "--workspace", "xxx.xcworkspace", "--reporter", "json"]) parse_result_json = json.loads(parse_output.decode("utf-8")) tweak_parse_result(parse_result_json) return parse_result_json def parse_build_category(build_step: BuildStep) -> BuildCategory: """ Determine whether the build is clean or incremental ref. https://github.com/spotify/XCMetrics/blob/main/Sources/XCMetricsBackendLib/UploadMetrics/LogProcessing/LogParser.swift """ steps = build_step.flatten() detail_steps = [s for s in steps if s.type == "detail"] n_fetched_from_cache = len([s for s in detail_steps if s.fetchedFromCache]) if n_fetched_from_cache == len(detail_steps): return BuildCategory.NOOP elif n_fetched_from_cache / len(detail_steps) > 0.5: return BuildCategory.INCREMENTAL else: return BuildCategory.CLEAN def gen_bq_record(parse_result_json: JSON) -> BQRecord: """ Generate data corresponding to BigQuery table record """ build_step = BuildStep.from_dict(parse_result_json) build_category = parse_build_category(build_step) return BQRecord( build_step.startTimestamp, build_step.compilationDuration, build_step.machineName, build_category.value, build_step.schema, ) def upload_to_s3(json_path: pathlib.Path): """ Upload the build metrics data to AWS S3 """ subprocess.run( [ "aws", "s3", "cp", str(json_path.absolute()), os.environ["S3_BUCKET"], ] ) def main(): # Since Xcode will take a while to finish writing the log to the file, # ensuring build log file is created by waiting for a seconds time.sleep(3) os.environ["PATH"] += os.pathsep + "/opt/homebrew/bin" if not os.getenv("S3_BUCKET"): return dir_path = os.path.dirname(os.path.realpath(__file__)) prj_root_dir = os.path.join(dir_path, "..") os.chdir(prj_root_dir) with tempfile.TemporaryDirectory() as tmpdir: suffix = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") fpath = pathlib.Path(tmpdir) / f"xclogparser_{suffix}.json" with open(fpath, "w") as tmpf: parsed_json = get_xclogparse_json() bq_record = gen_bq_record(parsed_json) tmpf.write(json.dumps(asdict(bq_record))) upload_to_s3(fpath) if __name__ == "__main__": main()
かいつまんでポイントを解説していくと、.xcactivitylogの解析にはXCLogParserを使用しています。 XCLogParserの解析結果をもとに、BigQueryテーブルへロードするための独自のスキーマJSONを生成します。 最終的に生成されるスキーマJSONは以下のようなものです。
{ "start_time": 1734076111.189341, "compilation_duration": 148.36851501464844, "machine_name": "johndoe", "build_category": "clean", "scheme": "development", "cache_hit_ratio": 0.3643167972149695 }
"build_category"は(clean|incremental|noop)いずれかの値をとります。クリーンビルドかインクリメンタルビルドか、あるいはほぼコンパイル不要なビルドかを判定するフィールドで、これはXCLogParserの解析結果から直接得ることはできず、独自に判定しています。以下がスクリプトの抜粋になります。
def parse_build_category(build_step: BuildStep) -> BuildCategory: """ Determine whether the build is clean or incremental ref. https://github.com/spotify/XCMetrics/blob/main/Sources/XCMetricsBackendLib/UploadMetrics/LogProcessing/LogParser.swift """ steps = build_step.flatten() detail_steps = [s for s in steps if s.type == "detail"] n_fetched_from_cache = len([s for s in detail_steps if s.fetchedFromCache]) if n_fetched_from_cache == len(detail_steps): return BuildCategory.NOOP elif n_fetched_from_cache / len(detail_steps) > 0.5: return BuildCategory.INCREMENTAL else: return BuildCategory.CLEAN
コードコメント内にあるように、この辺りはXCMetricsの実装を参考にしています。
XCLogParserによる解析結果は、複数のビルドステップから構成され、各ビルドステップ毎にビルドキャッシュがどれだけキャッシュヒットしているかを表す情報を含んでいます。
これを下に、「指定スキームをビルドする上で、依存するビルドターゲットの内、半数以上がキャッシュヒットしているかどうか」をもってクリーンビルドかインクリメンタルビルドかを判定するようにしています。
先ほどみてきたS3へアップロードするスキーマJSONに対応するBigQueryのテーブルスキーマは以下のようなものです。
このテーブルに対し、最終的にLookerで可視化したい内容をビューとして定義します。以下は一例です。
-- インクリメンタルビルド時間(sec)の7日間平均 WITH incremental_builds AS ( SELECT DATE(start_time) AS date, compilation_duration FROM `<gcp_prj_id>.<bq_dataset>.<bq_table>` WHERE build_category = 'incremental' ), daily_average AS ( SELECT date, AVG(compilation_duration) AS daily_avg_duration FROM incremental_builds GROUP BY date ), moving_average AS ( SELECT date, daily_avg_duration, AVG(daily_avg_duration) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS moving_avg_7d FROM daily_average ) SELECT date, moving_avg_7d AS avg_compilation_duration_7d FROM moving_average ORDER BY date desc ;
-- 日毎のビルド実行回数・総ビルド時間 WITH daily_build_compilations AS ( SELECT FORMAT_TIMESTAMP("%Y-%m-%d", start_time, "Asia/Tokyo") AS `date` , compilation_duration FROM `<gcp_prj_id>.<bq_dataset>.<bq_table>` WHERE compilation_duration > 0 ) SELECT PARSE_DATETIME("%Y-%m-%d", date) AS `date` , COUNT(*) AS num_builds , SUM(compilation_duration) AS total_compilation_duration FROM daily_build_compilations GROUP BY `date` ORDER BY `date`
おわりに
弊社iOSチームにおけるXcodeのビルド時間可視化の取り組みについて紹介させていただきました。 比較的ニッチなテーマだったかもしれませんが、今回の記事内容が少しでも誰かのお役に立てれば幸いです。
ところで、冒頭ご紹介したダッシュボードの中で、2024/08頃から平均インクリメンタルビルド時間が削減されている様子が伺えるかとおもいます。 この辺りのビルド時間短縮の具体的な取り組み内容についても、別の機会でご紹介できればとおもいます。