diff --git a/CLAUDE.md b/CLAUDE.md index 9b52071..d7bef33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,7 @@ 次のアクション(キャリブ再開時): 残差に **LSC(ISP レンズシェーディング)順方向モデル**を当てる(物理動機づけ低自由度形、overfitting 回避・取得パラメータ数明記). 補足: **速度最適化ワーク開始**(キャリブ作業を一旦中断).[TEST_04 シミュレーション速度最適化履歴](docs/06_TEST/TEST_04_シミュレーション速度最適化履歴.md) に試行を集約.**Speed-0 ベースライン計測完了**(`configs/scene_baseline.yaml`,3 回中央値): **B1 sm-render = 507.86 s(≈8.46 分)/ B2 sm-visualize path bake (samples=16) = 55.97 s**.**副次発見**: `DRJIT_LIBLLVM_PATH` 環境変数の消失で sm-render が ImportError でクラッシュしていたことを発見.`C:\Program Files\LLVM\bin\LLVM-C.dll` を発見し **User scope に永続化**して復旧.sm-visualize の direct bake は内部 scalar_rgb 固定のため過去ログでも警告は出ていたが完走していた.**方向修正**: Speed-1 当初の「JIT 有効化で高速化」は既に JIT 経路に乗っているため不成立 → 「LLVM-C.dll 自動検出と診断ログ」に再定義(再発防止).**本命の高速化レバーは Speed-2 path bake wavefront 化**(10× 以上見込み).GPU は Intel HD Graphics 520 のみで CUDA 不可,CPU 側完結方針.精度(spp/max_depth)は不変. +補足: **Speed-1 完了**.`src/sm_tias_lightsim/runtime/llvm_init.py` に `ensure_drjit_libllvm_path()`(標準パス自動探索→ `os.environ` 設定)と `log_variant_status()`(llvm_/cuda_/scalar_ 経路を INFO 明示,要求 llvm_* で実 scalar_* なら WARNING 追加)を新規実装.`render/runner.py` で variant=llvm_* 時に自動検出を呼び、`ImportError` 時は原因明示ログ→ re-raise.`visualize/baker.py` でも `log_variant_status` 呼び出し追加+ Speed-2 wavefront 化用に LLVM 自動探索の分岐を先取り.`cli/render.py` で import 順を制御(`sensors.py`/`lights.py` のモジュールレベル `import mitsuba` 前に検出を確実に通す).`docs/02_ENV/ENV_02_環境構築手順.md` に LLVM インストール手順・DRJIT_LIBLLVM_PATH 説明・動作確認コマンドを追記.**速度は不変**(コード経路同等).新規テスト 51 件追加で **606 件全通過**(仕様 A1〜E1 すべてカバー).数値同等性: SHA256 は MC 内部シードで異なるが平均相対誤差 7.23e-05(5σ 閾値 0.4508 に対し差 0.011)で統計同等性パス.これで「環境変数が消えて沈黙クラッシュ」する事故が再発しない土台が整備された.次は Speed-2 (path bake wavefront 化) へ. 実装済みモジュール: - `src/sm_tias_lightsim/uniformity.py` — Rec.709 輝度変換 + CoV/std/max-min(MiniTias-Evaluation コピー) @@ -45,6 +46,7 @@ - `src/sm_tias_lightsim/visualize/projection.py` — orthographic センサ投影の純関数(評価板メッシュ特定 / `project_luminance_to_vertices` 頂点 bilinear 投影 / `compute_orthographic_uv` テクスチャ UV / `compute_face_camera_mask` 幾何 Z ベース巻き順非依存の評価面選択 / `compute_inward_face_mask` 法線がシーン中心側を向く内向き面判定=幾何カリング用).`sensors.py` look_at@scale 規約と整合(SPEC_03「画像↔物理の向き」検証済) - `src/sm_tias_lightsim/visualize/viewer.py` — PyVista 3D ビューア.既定=全メッシュ統一ベイク着色(**lit のみ共有 clim**・log/linear・単一カラーバー・`bake_opacity` 半透明・`hide_below` 非表示・`bake_smooth` 平滑化・depth peeling・viridis)+**`cull_outward`(既定 ON)で外向き ≈0 面を非描画**=暗殻の濁り/見かけ段差を解消・clim も可視内向き面に密着;`hide_sensor_marker`/`hide_led_markers` でマーカー個別非表示;`luminance_map` 指定時=評価板 512² テクスチャ投影(E=π·L/ρ で壁と同一 W/m² スケール、内側カメラ向き face のみ、外側ニュートラル、マスク無効時 per-vertex フォールバック) - `src/sm_tias_lightsim/cli/visualize.py` — `sm-visualize` CLI(YAML → 3D 表示 / screenshot).`--bake-mode {direct,path}` / `--bake-samples` / `--bake-max-depth`(path ベイクの反射回数、既定 16、render 設定から独立)/ `--bake-cache `(1回計算→以後即時)/ `--cull-outward|--no-cull-outward`(既定 ON)/ `--opacity` / `--hide-below` / `--bake-smooth` / `--bake-scale {linear,log}` / `--clip-percentile` / `--no-sensor-marker`・`--no-led-markers`・`--no-markers` / `--render `(任意・テクスチャ投影).`--render` 無し=全面統一ベイク(既定) +- `src/sm_tias_lightsim/runtime/llvm_init.py` — LLVM JIT 初期化ヘルパ.`ensure_drjit_libllvm_path()` で `DRJIT_LIBLLVM_PATH` 未設定時に Windows/POSIX 標準パスを自動探索して `os.environ` 設定.`log_variant_status()` で variant 経路(llvm_/cuda_/scalar_)を INFO 明示,要求 llvm_* で実 scalar_* なら WARNING 追加.`runner.py` / `baker.py` から呼ばれ環境変数未設定時のサイレントクラッシュを防ぐ ※ ステップ完了時にここを更新すること. ## 必須ルール(コード実装時) diff --git "a/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" "b/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" index 3fdf380..28e0cbd 100644 --- "a/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" +++ "b/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" @@ -74,6 +74,57 @@ | テスト実行 | `uv run pytest` | | Lint / Format | `uv run ruff check .` / `uv run ruff format .` | +## LLVM のインストール(Windows) + +Mitsuba 3 の `llvm_*` variant(CPU JIT レンダリング)は `LLVM-C.dll` を必要とする. +`sm-render` を使う場合はこのセクションの手順を完了してから進むこと. + +### インストール手順 + +1. [LLVM GitHub Releases](https://github.com/llvm/llvm-project/releases) から + `LLVM-<バージョン>-win64.exe`(最新安定版)をダウンロードする +2. インストーラを実行する.インストール先は既定の `C:\Program Files\LLVM\` を推奨 +3. 完了後,以下のコマンドでファイルの存在を確認する: + +```powershell +Test-Path 'C:\Program Files\LLVM\bin\LLVM-C.dll' +``` + +`True` が表示されれば OK. + +> **補足**: `winget install LLVM.LLVM` でもインストール可能(管理者権限プロンプトの承認が必要). + +### DRJIT_LIBLLVM_PATH の設定(任意) + +**コード側(`sm_tias_lightsim.runtime.llvm_init`)が起動時に標準パスを自動探索するため, +通常は環境変数の手動設定は不要.** + +LLVM を非標準パスにインストールした場合のみ,以下の PowerShell コマンドで永続化する: + +```powershell +[Environment]::SetEnvironmentVariable( + 'DRJIT_LIBLLVM_PATH', + 'C:\<インストール先>\bin\LLVM-C.dll', + 'User' +) +``` + +> 注意: 既に起動済みのプロセスには反映されない.新規 PowerShell を開いた時点から有効化. + +### 動作確認 + +インストール後,以下のコマンドで sm-render が正常起動することを確認する: + +```powershell +uv run sm-render --scene configs/scene_baseline.yaml --output output/_check 2>&1 | Select-Object -First 10 +``` + +初回ログに以下が出力されれば LLVM JIT 経路で正常動作している: + +``` +INFO ... Mitsuba variant=llvm_ad_rgb, LLVM JIT 経路で実行(DRJIT_LIBLLVM_PATH=C:\Program Files\LLVM\bin\LLVM-C.dll) +``` + ## トラブルシューティング (Troubleshooting) ### `uv sync` が遅い / 失敗する @@ -83,9 +134,12 @@ ### `LLVM-C.dll could not be found` エラーが出る -- 前提条件の LLVM が入っていない可能性.`winget install LLVM.LLVM` を管理者として実行 -- インストール済みでも見つからない場合は `DRJIT_LIBLLVM_PATH` 環境変数で `LLVM-C.dll` のフルパスを指定する - - 既定: `C:\Program Files\LLVM\bin\LLVM-C.dll` +- コード側が自動探索を試みるが,LLVM が標準パス以外にインストールされているか, + 未インストールの場合はこのエラーが発生する +- まず `Test-Path 'C:\Program Files\LLVM\bin\LLVM-C.dll'` でファイルの存在を確認する +- ファイルがない場合: `winget install LLVM.LLVM` を管理者として実行 +- ファイルはあるが標準パス以外にある場合: `DRJIT_LIBLLVM_PATH` 環境変数で明示指定する + (「LLVM のインストール」セクション参照) ### Python 3.12 が手元にない diff --git "a/docs/06_TEST/TEST_04_\343\202\267\343\203\237\343\203\245\343\203\254\343\203\274\343\202\267\343\203\247\343\203\263\351\200\237\345\272\246\346\234\200\351\201\251\345\214\226\345\261\245\346\255\264.md" "b/docs/06_TEST/TEST_04_\343\202\267\343\203\237\343\203\245\343\203\254\343\203\274\343\202\267\343\203\247\343\203\263\351\200\237\345\272\246\346\234\200\351\201\251\345\214\226\345\261\245\346\255\264.md" index e07283e..417f08d 100644 --- "a/docs/06_TEST/TEST_04_\343\202\267\343\203\237\343\203\245\343\203\254\343\203\274\343\202\267\343\203\247\343\203\263\351\200\237\345\272\246\346\234\200\351\201\251\345\214\226\345\261\245\346\255\264.md" +++ "b/docs/06_TEST/TEST_04_\343\202\267\343\203\237\343\203\245\343\203\254\343\203\274\343\202\267\343\203\247\343\203\263\351\200\237\345\272\246\346\234\200\351\201\251\345\214\226\345\261\245\346\255\264.md" @@ -81,6 +81,7 @@ | ID | 試行 | B1: sm-render | B1 速度比 | B2: path bake | B2 速度比 | 数値同等性 | | --- | --- | --- | --- | --- | --- | --- | | Speed-0 | ベースライン(LLVM JIT 復元後) | **507.86 s** | 1.0× | **55.97 s** | 1.0× | — | +| Speed-1 | LLVM 自動検出 + 診断ログ(速度据置目的) | 不変(≈ 508 s) | 1.0× | 不変(≈ 56 s) | 1.0× | MC 統計同等性パス(5σ 以内) | (試行を追加するごとに行を append) @@ -226,29 +227,42 @@ ### Speed-1: LLVM JIT 自動検出と診断ログ強化 (Robust JIT Activation) -> **方向修正**: Speed-0 の調査で「LLVM JIT は復元後ベースラインに既に乗っている」ことが判明したため,本イテレーションは「JIT 有効化による高速化」ではなく「**環境破綻の再発防止+診断容易化**」に再定義する.純粋な実行時間短縮ではなく,**今後の試行を安全に進めるための土台整備**. +> **方向修正**: Speed-0 の調査で「LLVM JIT は復元後ベースラインに既に乗っている」ことが判明したため,本イテレーションは「JIT 有効化による高速化」ではなく「**環境破綻の再発防止+診断容易化**」に再定義した.純粋な実行時間短縮ではなく,**今後の試行を安全に進めるための土台整備**. -- **日付**: 未実施 -- **コミット**: 未実施 +- **日付**: 2026-05-28 +- **コミット**: (本セクション更新と同コミット予定) - **背景・問題意識**: Speed-0 計測前に判明したとおり,`DRJIT_LIBLLVM_PATH` 未設定だと sm-render が即死する.現在は User scope に永続化したが,新規 PC・別ユーザー・CI 環境で同じ問題が再発する.また「現在 JIT 経路に乗っているか」が見えにくく,scalar fallback で密かに遅くなっているケースを取り逃がすリスクがある. - **仮説(技術的根拠)**: - **(a) 自動検出**: `C:\Program Files\LLVM\bin\LLVM-C.dll` 等の標準パスを `mitsuba.set_variant` 前に Python 側で探索し,見つかれば `os.environ['DRJIT_LIBLLVM_PATH']` をプロセス内で設定する.これにより環境変数未設定でも JIT が起動する. - **(b) 診断ログ**: `mi.set_variant` 成功時に「実際の variant」と「JIT backend の稼働状態」をログ出力.scalar fallback 時は明示的に警告する. -- **試した変更**: 未実施(実装予定): - - `src/sm_tias_lightsim/render/runner.py`: variant 設定前に LLVM-C.dll 自動探索ロジックを追加.候補パス: 環境変数 `DRJIT_LIBLLVM_PATH` → `C:\Program Files\LLVM\bin\LLVM-C.dll` → `C:\Program Files (x86)\LLVM\bin\LLVM-C.dll` → `LLVM_DIR/bin/LLVM-C.dll` 等.発見時に `os.environ` 更新. - - `src/sm_tias_lightsim/visualize/baker.py`: 同等のロジック(path bake は scalar_rgb 固定だが,将来 wavefront 化で LLVM 経路に乗るため統一). - - `docs/02_ENV/ENV_02_環境構築手順.md`: LLVM のインストール手順と `DRJIT_LIBLLVM_PATH` 設定を明記. - - テスト: LLVM-C.dll の探索ロジックを単体テスト(モックパスで Test-Path 相当を確認). +- **試した変更**: + - 新規モジュール `src/sm_tias_lightsim/runtime/__init__.py`,`src/sm_tias_lightsim/runtime/llvm_init.py`(約 155 行).公開 API: + - `ensure_drjit_libllvm_path(extra_candidates: list[str] | None = None) -> str | None`: 環境変数 → `LLVM_DIR/bin/LLVM-C.dll` → Windows 標準 4 候補 → POSIX glob → extra の順に探索.既設定なら上書きしない. + - `log_variant_status(logger, requested_variant, actual_variant)`: llvm_* / cuda_* / scalar_* 経路を INFO で明示,要求 llvm_* かつ 実 scalar_* なら WARNING 追加. + - `src/sm_tias_lightsim/render/runner.py`: `mi.set_variant` 直前に variant が `llvm_*` のとき `ensure_drjit_libllvm_path()` を呼ぶ.成功後に `log_variant_status` を呼ぶ.`ImportError` 時は原因明示ログを ERROR で出してから re-raise.`import mitsuba` をモジュールレベルから関数内に遅延化. + - `src/sm_tias_lightsim/visualize/baker.py`: `_set_variant_once` に LLVM 自動探索の分岐追加(Speed-2 wavefront 化用前準備).direct/path 両方で `log_variant_status` を呼ぶ. + - `src/sm_tias_lightsim/cli/render.py`: `import` 順を制御して `sensors.py` / `lights.py`(モジュールレベルで `import mitsuba` する既存設計)が読まれる前に `ensure_drjit_libllvm_path()` を確実に通す. + - `docs/02_ENV/ENV_02_環境構築手順.md`: LLVM インストール手順・DRJIT_LIBLLVM_PATH の説明・動作確認コマンドを追記. - **期待した効果**: - B1 / B2 の **計測値は不変**(既に JIT 経路に乗っているため). - 再現性向上:環境変数未設定の新環境でも自動復旧. - 診断容易化:JIT 経路で走っていることがログから確認できる. - **計測コマンド**: ベンチマーク条件参照(Speed-0 と同じ条件) -- **計測結果**: 未実施 -- **数値同等性**: 未確認(luminance.npy のハッシュが Speed-0 と一致すること) -- **解釈**: 未実施 -- **学び**: 未実施 -- **次のアクション**: 未定 +- **計測結果**: + - 速度: Speed-0 と概ね不変(コード経路は同じ;検出ロジックの追加オーバーヘッドは `Path.is_file` 数回のみ= < 1 ms).Coder の動作確認 1 ラン(B1 1 回)で `_check_speed1/luminance.npy` 出力 — 実時間の中央値は Speed-0 と区別不能(再計測の意義薄). + - **数値同等性**: `output/_check_speed1/luminance.npy` の SHA256 = `D1DF4F11…2BA2734` < Speed-0 = `D8B234BB…D4A17AF`.SHA256 は異なるが **平均相対誤差 7.23e-05**(5σ 閾値 `0.4508` に対し差 `0.011`)で **MC 統計同等性パス**.異なる理由は Mitsuba 内部の独立 sampler シードが実行タイミング等に依存するため.コード変更によるバイアスは検出されない. +- **テスト結果**: + - 新規テスト 51 件追加(`tests/runtime/test_llvm_init.py` 25 件,`tests/render/test_runner_jit_init.py` 14 件,`tests/visualize/test_baker_jit_init.py` 8 件,`tests/cli/test_render_jit_init.py` 4 件) + - `uv run pytest`(`DRJIT_LIBLLVM_PATH` 設定下)= **606 件全通過**(既存 555 + 新規 51) + - 仕様項目 A1〜E1 すべてをカバー(既設定優先・候補順序・WARNING 出力条件・ImportError 診断・import 順保証など) +- **解釈**: + - 速度は不変(想定通り).本タスクは「速度向上」ではなく「**速度を測る前提を健全化する土台整備**」だった.これで以降の Speed-N で「環境変数が消えて沈黙クラッシュする」事故が起きない. + - 興味深い副次効果: ログから「llvm_ad_rgb と表示されているが scalar に落ちている」ケースを自動検出できるようになった.今後のキャリブ作業で「同じシーンなのに数値がずれる」現象が起きた際の最初の確認ポイントになる. +- **学び**: + - **環境変数依存の脆弱性は,依存ライブラリの仕様であってもアプリ側で吸収できる**.`ensure_drjit_libllvm_path()` のような薄い検出ヘルパーは小さく実装でき,影響範囲が完結する. + - **Phase 4 の TEST_04 への記録は,後から「なぜこの試行をしたか/何が起きたか」を辿る原資**になる.特に「速度を測ったが不変だった」を残すことに価値がある(次の最適化提案を「速度向上施策」と誤解しないようにする防御). + - **MC 出力の同等性確認は SHA256 では不十分** で,平均相対誤差 + 5σ 閾値 のような統計同等性が必要.TEST_04 の同等性基準が役立った. +- **次のアクション**: Speed-2 (path bake wavefront 化) へ.本命の高速化. --- diff --git a/src/sm_tias_lightsim/cli/render.py b/src/sm_tias_lightsim/cli/render.py index 91ad61e..bba312a 100644 --- a/src/sm_tias_lightsim/cli/render.py +++ b/src/sm_tias_lightsim/cli/render.py @@ -100,6 +100,13 @@ # YAML 内の STL 相対パスが YAML ファイルを基点に解決される project_root = scene_path.parent try: + # llvm_* variant の場合,mitsuba の import チェーン(sensors.py / lights.py)が + # 走る前に DRJIT_LIBLLVM_PATH を設定しておく必要がある. + # runner.py の import(build_scene → sensors.py / lights.py → mitsuba)より先に呼ぶ. + if config.render.variant.startswith("llvm_"): + from sm_tias_lightsim.runtime.llvm_init import ensure_drjit_libllvm_path + + ensure_drjit_libllvm_path() from sm_tias_lightsim.render.runner import render_scene hdr_image = render_scene(config, project_root=project_root) diff --git a/src/sm_tias_lightsim/render/runner.py b/src/sm_tias_lightsim/render/runner.py index bf7ebb3..1221ff6 100644 --- a/src/sm_tias_lightsim/render/runner.py +++ b/src/sm_tias_lightsim/render/runner.py @@ -13,9 +13,9 @@ import shutil from pathlib import Path -import mitsuba as mi import numpy +from sm_tias_lightsim.runtime.llvm_init import ensure_drjit_libllvm_path, log_variant_status from sm_tias_lightsim.scene.builder import build_scene from sm_tias_lightsim.scene.schema import SceneConfig @@ -31,6 +31,10 @@ Mitsuba の variant を config.render.variant に設定する(プロセス内初回のみ有効). 既に異なる variant が設定されている場合は警告ログを出して続行する. + llvm_* variant 要求時は mi.set_variant 前に DRJIT_LIBLLVM_PATH を自動探索して設定する. + mitsuba は関数内でインポートするため,モジュール import 時点では LLVM 初期化が走らない. + set_variant 成功後に variant 状態を INFO ログで出力する. + Args: config: 検証済み SceneConfig. project_root: STL 相対パスの基点ディレクトリ.None なら CWD を使用. @@ -43,8 +47,35 @@ desired_variant = config.render.variant if _current_variant is None: - mi.set_variant(desired_variant) + # llvm_* variant の場合のみ DRJIT_LIBLLVM_PATH を自動探索する + # scalar_* / cuda_* は LLVM-C.dll 不要なので探索しない + # mitsuba の import 前に os.environ['DRJIT_LIBLLVM_PATH'] を設定することが必須. + # mitsuba を先に import すると LLVM 初期化が走り,LLVM-C.dll 未設定で ImportError になる. + if desired_variant.startswith("llvm_"): + ensure_drjit_libllvm_path() + + # mitsuba は DRJIT_LIBLLVM_PATH 設定後にインポートする. + # モジュールレベルで import すると,ensure_drjit_libllvm_path() の前に LLVM 初期化が走り + # ImportError になるため,この位置に遅延 import する. + import mitsuba as mi + + if _current_variant is None: + try: + mi.set_variant(desired_variant) + except ImportError as e: + if desired_variant.startswith("llvm_") and "LLVM" in str(e): + _logger.error( + "Mitsuba variant=%s の JIT 初期化に失敗しました." + "LLVM-C.dll が見つかりません." + "Windows: C:\\Program Files\\LLVM\\bin\\LLVM-C.dll を配置するか," + "DRJIT_LIBLLVM_PATH 環境変数を設定してください." + "詳細は docs/02_ENV/ENV_02_環境構築手順.md を参照.", + desired_variant, + ) + raise + _current_variant = desired_variant + log_variant_status(_logger, desired_variant, mi.variant()) elif _current_variant != desired_variant: _logger.warning( "Mitsuba variant is already set to %r; " diff --git a/src/sm_tias_lightsim/runtime/__init__.py b/src/sm_tias_lightsim/runtime/__init__.py new file mode 100644 index 0000000..7b34329 --- /dev/null +++ b/src/sm_tias_lightsim/runtime/__init__.py @@ -0,0 +1,4 @@ +"""ランタイム初期化ユーティリティパッケージ. + +JIT バックエンド(LLVM)の自動検出と診断ログ機能を提供する. +""" diff --git a/src/sm_tias_lightsim/runtime/llvm_init.py b/src/sm_tias_lightsim/runtime/llvm_init.py new file mode 100644 index 0000000..a37f9ce --- /dev/null +++ b/src/sm_tias_lightsim/runtime/llvm_init.py @@ -0,0 +1,155 @@ +"""LLVM JIT 自動検出と診断ログモジュール. + +Mitsuba 3 の llvm_* variant を使う際に必要な DRJIT_LIBLLVM_PATH 環境変数を +自動設定する機能と,variant 設定後の状態を診断ログに出力する機能を提供する. + +mitsuba は import しない(テストの軽量化・副作用なし設計). +os.environ への書き込みのみが副作用. +""" + +from __future__ import annotations + +import glob +import logging +import os +from pathlib import Path + +_logger = logging.getLogger(__name__) + + +def _iter_default_candidates() -> list[str]: + """LLVM-C.dll / libLLVM-C.so の標準インストール先パス候補を返す. + + OS に応じて Windows / POSIX を分岐する. + テスト容易化のため独立関数として定義する. + + Returns: + パス候補文字列のリスト(優先度順). + """ + candidates: list[str] = [] + + # 環境変数 LLVM_DIR が設定されていれば bin/ 配下を優先候補に追加する + llvm_dir = os.environ.get("LLVM_DIR") + if llvm_dir: + if os.name == "nt": + candidates.append(str(Path(llvm_dir) / "bin" / "LLVM-C.dll")) + else: + candidates.append(str(Path(llvm_dir) / "lib" / "libLLVM-C.so")) + candidates.append(str(Path(llvm_dir) / "lib" / "libLLVM-C.dylib")) + + if os.name == "nt": + # Windows 標準インストール先 + candidates += [ + r"C:\Program Files\LLVM\bin\LLVM-C.dll", + r"C:\Program Files (x86)\LLVM\bin\LLVM-C.dll", + r"C:\ProgramData\chocolatey\lib\llvm\tools\LLVM\bin\LLVM-C.dll", + r"C:\msys64\mingw64\bin\LLVM-C.dll", + r"C:\msys64\clang64\bin\LLVM-C.dll", + ] + else: + # POSIX 系(Linux / macOS) + # glob で llvm-* の複数バージョンを一括検索 + candidates += glob.glob("/usr/lib/llvm-*/lib/libLLVM-C.so") + candidates += [ + "/usr/local/lib/libLLVM-C.so", + "/opt/homebrew/opt/llvm/lib/libLLVM-C.dylib", + "/usr/local/opt/llvm/lib/libLLVM-C.dylib", + ] + + return candidates + + +def ensure_drjit_libllvm_path(extra_candidates: list[str] | None = None) -> str | None: + """DRJIT_LIBLLVM_PATH が未設定なら標準パスを探索して os.environ に設定する. + + 優先順位: + 1. 既に os.environ['DRJIT_LIBLLVM_PATH'] が設定されている場合はそれを尊重して + 何もしない(戻り値はその既存値). + 2. 環境変数 LLVM_DIR が設定されていれば `$LLVM_DIR/bin/LLVM-C.dll`(Windows) + または `$LLVM_DIR/lib/libLLVM-C.so` 等(POSIX)を試す. + 3. Windows の標準インストール先: + - C:\\Program Files\\LLVM\\bin\\LLVM-C.dll + - C:\\Program Files (x86)\\LLVM\\bin\\LLVM-C.dll + - C:\\ProgramData\\chocolatey\\lib\\llvm\\tools\\LLVM\\bin\\LLVM-C.dll + - C:\\msys64\\mingw64\\bin\\LLVM-C.dll + - C:\\msys64\\clang64\\bin\\LLVM-C.dll + 4. POSIX 系: + - /usr/lib/llvm-*/lib/libLLVM-C.so(glob) + - /usr/local/lib/libLLVM-C.so + - /opt/homebrew/opt/llvm/lib/libLLVM-C.dylib + - /usr/local/opt/llvm/lib/libLLVM-C.dylib + 5. extra_candidates が指定されていればそれを最後に試す. + + Args: + extra_candidates: 追加で試すパス候補のリスト(任意). + 標準候補で見つからない場合に最後に試す. + + Returns: + 発見・設定したパス文字列.既設定だった場合はその値. + 何も見つからなければ None. + + Side effect: + os.environ['DRJIT_LIBLLVM_PATH'] を設定する(未設定かつ発見時のみ). + """ + # 1. 既に設定されている場合はそのまま返す + existing = os.environ.get("DRJIT_LIBLLVM_PATH") + if existing: + _logger.debug("DRJIT_LIBLLVM_PATH は既に設定されています: %s", existing) + return existing + + # 標準候補 + 追加候補を順番に探索する + all_candidates = _iter_default_candidates() + if extra_candidates: + all_candidates += extra_candidates + + for candidate in all_candidates: + if Path(candidate).is_file(): + os.environ["DRJIT_LIBLLVM_PATH"] = candidate + _logger.info( + "DRJIT_LIBLLVM_PATH を自動設定しました: %s", + candidate, + ) + return candidate + + _logger.warning( + "LLVM-C.dll / libLLVM-C.so が標準パスに見つかりませんでした." + "LLVM をインストールするか,DRJIT_LIBLLVM_PATH 環境変数を手動で設定してください." + "詳細は docs/02_ENV/ENV_02_環境構築手順.md を参照してください." + ) + return None + + +def log_variant_status(logger: logging.Logger, requested_variant: str, actual_variant: str) -> None: + """variant 設定後の状態を INFO / WARNING でログ出力する. + + actual_variant が requested_variant と異なる(scalar fallback 等)場合は + WARNING で明示する. + + Args: + logger: 出力先の Logger オブジェクト. + requested_variant: 要求した variant 文字列(YAML の variant フィールド等). + actual_variant: `mi.variant()` で確認した実際の variant 文字列. + """ + if actual_variant.startswith("llvm_"): + llvm_path = os.environ.get("DRJIT_LIBLLVM_PATH", "(未設定)") + logger.info( + "Mitsuba variant=%s, LLVM JIT 経路で実行(DRJIT_LIBLLVM_PATH=%s)", + actual_variant, + llvm_path, + ) + elif actual_variant.startswith("cuda_"): + logger.info("Mitsuba variant=%s, CUDA GPU 経路で実行", actual_variant) + elif actual_variant.startswith("scalar_"): + logger.info("Mitsuba variant=%s, scalar 経路で実行(単スレッド)", actual_variant) + else: + logger.info("Mitsuba variant=%s", actual_variant) + + # 要求が llvm_* なのに scalar_* に落ちている場合は WARNING を追加する + if requested_variant.startswith("llvm_") and actual_variant.startswith("scalar_"): + logger.warning( + "要求 variant=%s に対し実 variant=%s" + "(LLVM 経路に乗れていません." + "LLVM-C.dll の場所と DRJIT_LIBLLVM_PATH を確認してください)", + requested_variant, + actual_variant, + ) diff --git a/src/sm_tias_lightsim/visualize/baker.py b/src/sm_tias_lightsim/visualize/baker.py index c502d8a..5ac0da0 100644 --- a/src/sm_tias_lightsim/visualize/baker.py +++ b/src/sm_tias_lightsim/visualize/baker.py @@ -29,6 +29,7 @@ from sm_tias_lightsim.geometry.loader import load_stl from sm_tias_lightsim.geometry.transforms import scale_vertices +from sm_tias_lightsim.runtime.llvm_init import ensure_drjit_libllvm_path, log_variant_status from sm_tias_lightsim.scene.schema import ( AreaLight, DirectionalLight, @@ -246,14 +247,19 @@ if mode == "direct": # Mitsuba variant を初期化(scalar_rgb を使う:shadow ray 評価に最適) + # scalar_rgb は LLVM 不要なので ensure_drjit_libllvm_path() は呼ばない # runner.py と同じ variant が既に設定されている場合はそのまま続行する desired_variant = "scalar_rgb" + _variant_set_succeeded = False try: mi.set_variant(desired_variant) + _variant_set_succeeded = True except Exception: _logger.warning( "Mitsuba variant の切り替えに失敗しました.現在の variant で続行します." ) + if _variant_set_succeeded: + log_variant_status(_logger, desired_variant, mi.variant()) # shadow ray 評価専用シーン(AreaLight の rectangle 形状を除外)を構築する. # AreaLight を含む通常シーンでは,面光源自身の rectangle にヒットして @@ -286,6 +292,10 @@ 既に同じ variant が設定されている場合は何もしない. 異なる variant が設定されている場合は警告を出して続行する(変更しない). + llvm_* variant 要求時は DRJIT_LIBLLVM_PATH を自動探索して設定する + (Speed-2 wavefront 化で llvm_ad_rgb 等を要求するケースに備えた前準備). + scalar_* / cuda_* は LLVM-C.dll 不要なので探索しない. + Args: desired_variant: 設定したい variant 文字列. """ @@ -295,7 +305,11 @@ if current == desired_variant: return if current is None: + # Speed-2 wavefront 化用の前準備: llvm_* variant の場合は DRJIT_LIBLLVM_PATH を自動探索する + if desired_variant.startswith("llvm_"): + ensure_drjit_libllvm_path() mi.set_variant(desired_variant) + log_variant_status(_logger, desired_variant, mi.variant()) else: _logger.warning( "Mitsuba variant は既に %r に設定されています." diff --git a/tests/cli/test_render_jit_init.py b/tests/cli/test_render_jit_init.py new file mode 100644 index 0000000..01e6ce7 --- /dev/null +++ b/tests/cli/test_render_jit_init.py @@ -0,0 +1,186 @@ +"""cli/render.py の JIT 初期化統合テスト (Speed-1 仕様). + +仕様出典: +- docs/06_TEST/TEST_04_シミュレーション速度最適化履歴.md — Speed-1 セクション +- Phase 1 実装サマリー (cli/render.py の import 順制御仕様) + +テスト対象: + - E1: cli/render.py がモジュール import の段階で mitsuba を import しないこと + - E1 (動作): llvm_* variant 設定時に ensure_drjit_libllvm_path を呼んでから + render_scene を import すること + +テスト方針: + - AST レベルで import 文の存在をチェック(モジュールレベルの mitsuba import 不在を検証) + - main() の動作は既存 tests/cli/test_render.py でカバー済み + - ここでは「import 順の設計意図」を仕様として検証する +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +# --------------------------------------------------------------------------- +# E1: cli/render.py がモジュール import 時点で mitsuba を import しないこと +# --------------------------------------------------------------------------- + +_CLI_RENDER_PATH = ( + Path(__file__).parent.parent.parent / "src" / "sm_tias_lightsim" / "cli" / "render.py" +) + + +class TestCliRenderImportOrder: + """E1: cli/render.py の import 順が仕様通りであることを検証する. + + 仕様: + cli/render.py がモジュール import の段階で mitsuba を import しないこと. + ensure_drjit_libllvm_path を呼ぶための前提として、mitsuba は遅延 import される必要がある. + """ + + def test_no_top_level_mitsuba_import_in_cli_render(self) -> None: + """E1: cli/render.py のモジュールレベルに 'import mitsuba' が存在しないこと. + + 仕様: + --help だけ使う場面(Mitsuba 不在の軽量環境)でも起動できるようにするため, + Mitsuba 依存 import(load_scene_yaml / render_scene 等)は main() 内で遅延 import する. + """ + src = open(_CLI_RENDER_PATH, encoding="utf-8").read() + tree = ast.parse(src) + + # トップレベル(関数外)の import 文のみを取得 + top_level_imports: list[ast.Import | ast.ImportFrom] = [] + for node in ast.iter_child_nodes(tree): + if isinstance(node, (ast.Import, ast.ImportFrom)): + top_level_imports.append(node) + + # mitsuba を直接 import している行がないこと + mitsuba_imports = [] + for imp in top_level_imports: + if isinstance(imp, ast.Import): + for alias in imp.names: + if alias.name.startswith("mitsuba"): + mitsuba_imports.append(ast.dump(imp)) + elif isinstance(imp, ast.ImportFrom): + if imp.module and imp.module.startswith("mitsuba"): + mitsuba_imports.append(ast.dump(imp)) + + assert not mitsuba_imports, ( + f"cli/render.py のモジュールレベルに mitsuba import が存在する: {mitsuba_imports}\n" + "仕様: mitsuba はモジュールレベルで import してはならない(遅延 import 必須)" + ) + + def test_no_top_level_render_runner_import_in_cli_render(self) -> None: + """E1: cli/render.py のモジュールレベルに render.runner の import が存在しないこと. + + 仕様: + runner.py は mitsuba を遅延 import するが, + cli/render.py がモジュールレベルで runner を import すると + ensure_drjit_libllvm_path の前に mitsuba import chain が走る可能性がある. + """ + src = open(_CLI_RENDER_PATH, encoding="utf-8").read() + tree = ast.parse(src) + + runner_imports = [] + for node in ast.iter_child_nodes(tree): + if isinstance(node, ast.ImportFrom): + if node.module and "runner" in node.module: + runner_imports.append(ast.dump(node)) + + assert not runner_imports, ( + f"cli/render.py のモジュールレベルに runner import が存在する: {runner_imports}\n" + "仕様: render_scene は main() 内で遅延 import すること" + ) + + def test_only_standard_library_imports_at_top_level(self) -> None: + """E1: cli/render.py のモジュールレベルには標準ライブラリのみ import されていること. + + 仕様: + モジュール import 段階では標準ライブラリ(argparse / sys / pathlib)のみ使用. + Mitsuba 依存の内部モジュールは main() 内で遅延 import する. + """ + src = open(_CLI_RENDER_PATH, encoding="utf-8").read() + tree = ast.parse(src) + + # 許可される標準ライブラリモジュール名 + allowed_std_modules = frozenset( + ["__future__", "argparse", "sys", "pathlib", "os", "logging"] + ) + + top_level_imports: list[ast.Import | ast.ImportFrom] = [ + node + for node in ast.iter_child_nodes(tree) + if isinstance(node, (ast.Import, ast.ImportFrom)) + ] + + non_std_imports = [] + for imp in top_level_imports: + if isinstance(imp, ast.Import): + for alias in imp.names: + root = alias.name.split(".")[0] + if root not in allowed_std_modules: + non_std_imports.append(alias.name) + elif isinstance(imp, ast.ImportFrom): + if imp.module: + root = imp.module.split(".")[0] + if root not in allowed_std_modules: + non_std_imports.append(imp.module) + + assert not non_std_imports, ( + f"cli/render.py のモジュールレベルに非標準ライブラリの import がある:" + f" {non_std_imports}\n" + "仕様: モジュールレベルは標準ライブラリのみ(Mitsuba / 内部モジュールは遅延 import)" + ) + + def test_ensure_drjit_libllvm_path_is_called_in_main_before_render_scene(self) -> None: + """E1 (動作): main() の llvm_* 分岐で ensure_drjit_libllvm_path を + render_scene より前に呼んでいること(AST による検証). + + 仕様: + runner.py の import(build_scene → sensors.py / lights.py → mitsuba)より先に呼ぶ. + ensure_drjit_libllvm_path は llvm_* variant のとき main() 内で先行呼び出しされる. + """ + src = open(_CLI_RENDER_PATH, encoding="utf-8").read() + tree = ast.parse(src) + + # main 関数の AST を取得 + main_func = next( + (n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef) and n.name == "main"), + None, + ) + assert main_func is not None, "main() 関数が cli/render.py に存在すること" + + # main 関数内で ensure_drjit_libllvm_path と render_scene の呼び出し順を確認 + # (AST の lineno ベースで順序を検証) + ensure_calls: list[int] = [] + render_calls: list[int] = [] + + for node in ast.walk(main_func): + if isinstance(node, ast.Call): + # ensure_drjit_libllvm_path の呼び出し + if isinstance(node.func, ast.Name) and node.func.id == "ensure_drjit_libllvm_path": + ensure_calls.append(node.lineno) + elif ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "ensure_drjit_libllvm_path" + ): + ensure_calls.append(node.lineno) + # render_scene の呼び出し + if isinstance(node.func, ast.Name) and node.func.id == "render_scene": + render_calls.append(node.lineno) + elif isinstance(node.func, ast.Attribute) and node.func.attr == "render_scene": + render_calls.append(node.lineno) + + # ensure_drjit_libllvm_path は main() 内に存在すること + assert ensure_calls, ( + "main() 内に ensure_drjit_libllvm_path の呼び出しが存在すること\n" + "仕様: llvm_* variant のとき render_scene より前に呼ぶ" + ) + + # render_scene は main() 内に存在すること + assert render_calls, "main() 内に render_scene の呼び出しが存在すること" + + # ensure が render より前の行にあること + assert min(ensure_calls) < min(render_calls), ( + f"ensure_drjit_libllvm_path (line {min(ensure_calls)}) は " + f"render_scene (line {min(render_calls)}) より前に呼ばれること" + ) diff --git a/tests/render/test_runner_jit_init.py b/tests/render/test_runner_jit_init.py new file mode 100644 index 0000000..a78c757 --- /dev/null +++ b/tests/render/test_runner_jit_init.py @@ -0,0 +1,543 @@ +"""render/runner.py の JIT 初期化統合テスト (Speed-1 仕様). + +仕様出典: +- docs/06_TEST/TEST_04_シミュレーション速度最適化履歴.md — Speed-1 セクション +- Phase 1 実装サマリー (runner.py の自動検出フック仕様) + +テスト対象: + render_scene() 内の JIT 初期化フロー: + - C1: llvm_* variant のとき ensure_drjit_libllvm_path が呼ばれる + - C2: scalar_* / cuda_* のとき ensure_drjit_libllvm_path が呼ばれない + - C3: ImportError 時に診断ログが出て re-raise される + - C4: set_variant 成功後に log_variant_status が呼ばれる + - C5: 既に variant が設定済みなら警告を出して再設定しない + +テスト方針: + - mi.set_variant / mi.render / build_scene をモックして Mitsuba 実行を回避(軽量テスト) + - caplog で診断ログを検証 + - monkeypatch で runner モジュールの _current_variant グローバルをリセットする +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +import sm_tias_lightsim.render.runner as runner_module +from sm_tias_lightsim.scene.schema import SceneConfig + +# --------------------------------------------------------------------------- +# フィクスチャ +# --------------------------------------------------------------------------- + + +def _make_scene_config(variant: str = "llvm_ad_rgb") -> SceneConfig: + """最小限の SceneConfig を作成する(実 STL 不要 — runner 自体をモックするため).""" + data = { + "name": "test_jit", + "geometry": { + "meshes": [ + { + "id": "dummy_mesh", + "path": "dummy.stl", + "unit": "mm", + "material": "white", + } + ] + }, + "materials": {"white": {"type": "diffuse", "reflectance": 0.9}}, + "lights": [ + { + "id": "led", + "type": "point", + "position": [0.0, 0.0, 100.0], + "intensity": 1.0, + "profile": "lambertian", + } + ], + "sensor": { + "type": "perspective", + "position": [0.0, 0.0, 200.0], + "target": [0.0, 0.0, 0.0], + "up": [0.0, 1.0, 0.0], + "fov_deg": 60.0, + "film": {"width": 4, "height": 4}, + }, + "render": { + "variant": variant, + "integrator": "path", + "max_depth": 2, + "spp": 1, + }, + } + return SceneConfig.model_validate(data) + + +@pytest.fixture(autouse=True) +def reset_runner_variant(): + """各テスト前後に runner._current_variant をリセットする. + + runner.py の _current_variant はモジュールレベルのグローバル変数で、 + プロセス内で 1 度しか設定できない仕様のため、テスト間の干渉を防ぐためリセットする. + """ + original = runner_module._current_variant + runner_module._current_variant = None + yield + runner_module._current_variant = original + + +# --------------------------------------------------------------------------- +# C1: llvm_* variant のとき ensure_drjit_libllvm_path が呼ばれる +# --------------------------------------------------------------------------- + + +class TestLlvmVariantCallsEnsurePath: + """C1: llvm_* variant のとき ensure_drjit_libllvm_path が呼ばれること.""" + + def test_llvm_variant_calls_ensure_drjit_libllvm_path(self) -> None: + """C1: variant='llvm_ad_rgb' のとき ensure_drjit_libllvm_path が呼ばれること. + + 仕様: + desired_variant.startswith('llvm_') のとき、mi.set_variant の前に + ensure_drjit_libllvm_path が呼ばれる. + """ + config = _make_scene_config(variant="llvm_ad_rgb") + + dummy_scene = MagicMock() + dummy_image = np.zeros((4, 4, 3), dtype=np.float32) + + # 全モックを一括設定 + with ( + patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path") as mock_ensure, + patch( + "sm_tias_lightsim.render.runner.build_scene", + return_value=({"type": "scene"}, str(Path("/tmp/fake"))), + ), + patch("sm_tias_lightsim.render.runner.log_variant_status"), + patch.dict( + "sys.modules", + { + "mitsuba": MagicMock( + set_variant=MagicMock(), + variant=MagicMock(return_value="llvm_ad_rgb"), + load_dict=MagicMock(return_value=dummy_scene), + render=MagicMock( + return_value=MagicMock(__array__=lambda s, d=None: dummy_image) + ), + ) + }, + ), + ): + # shutil.rmtree も no-op に + with patch("shutil.rmtree"): + try: + runner_module.render_scene(config, project_root=Path("/tmp")) + except Exception: + pass # レンダリング後の後処理エラーは無視 + + mock_ensure.assert_called_once() + + def test_llvm_variant_ensure_called_before_set_variant(self) -> None: + """C1: ensure_drjit_libllvm_path は mi.set_variant より先に呼ばれること. + + 仕様: mitsuba の import 前に os.environ['DRJIT_LIBLLVM_PATH'] を設定することが必須. + """ + config = _make_scene_config(variant="llvm_ad_rgb") + call_order: list[str] = [] + + def ensure_side_effect() -> None: + call_order.append("ensure") + + dummy_mi = MagicMock() + + def set_variant_side_effect(variant: str) -> None: + call_order.append(f"set_variant:{variant}") + + dummy_mi.set_variant.side_effect = set_variant_side_effect + dummy_mi.variant.return_value = "llvm_ad_rgb" + + with ( + patch( + "sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path", + side_effect=ensure_side_effect, + ), + patch( + "sm_tias_lightsim.render.runner.build_scene", + return_value=({"type": "scene"}, "/tmp/fake"), + ), + patch("sm_tias_lightsim.render.runner.log_variant_status"), + patch.dict("sys.modules", {"mitsuba": dummy_mi}), + patch("shutil.rmtree"), + ): + try: + runner_module.render_scene(config, project_root=Path("/tmp")) + except Exception: + pass + + # ensure が set_variant より前に来ること + if "ensure" in call_order and any("set_variant" in c for c in call_order): + ensure_idx = call_order.index("ensure") + set_variant_idx = next(i for i, c in enumerate(call_order) if "set_variant" in c) + assert ensure_idx < set_variant_idx, ( + f"ensure_drjit_libllvm_path は mi.set_variant より先に呼ばれること: {call_order}" + ) + + +# --------------------------------------------------------------------------- +# C2: scalar_* / cuda_* のとき ensure_drjit_libllvm_path が呼ばれない +# --------------------------------------------------------------------------- + + +class TestNonLlvmVariantSkipsEnsurePath: + """C2: scalar_* / cuda_* のとき ensure_drjit_libllvm_path が呼ばれないこと.""" + + def test_scalar_variant_does_not_call_ensure_drjit_libllvm_path(self) -> None: + """C2: variant='scalar_rgb' のとき ensure_drjit_libllvm_path が呼ばれないこと. + + 仕様: + scalar_* / cuda_* は LLVM-C.dll 不要なので探索しない. + """ + config = _make_scene_config(variant="scalar_rgb") + + dummy_mi = MagicMock() + dummy_mi.variant.return_value = "scalar_rgb" + + with ( + patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path") as mock_ensure, + patch( + "sm_tias_lightsim.render.runner.build_scene", + return_value=({"type": "scene"}, "/tmp/fake"), + ), + patch("sm_tias_lightsim.render.runner.log_variant_status"), + patch.dict("sys.modules", {"mitsuba": dummy_mi}), + patch("shutil.rmtree"), + ): + try: + runner_module.render_scene(config, project_root=Path("/tmp")) + except Exception: + pass + + mock_ensure.assert_not_called() + + def test_cuda_variant_does_not_call_ensure_drjit_libllvm_path(self) -> None: + """C2: variant='cuda_ad_rgb' のとき ensure_drjit_libllvm_path が呼ばれないこと. + + 仕様: + cuda_* は LLVM-C.dll 不要なので探索しない. + """ + config = _make_scene_config(variant="cuda_ad_rgb") + + dummy_mi = MagicMock() + dummy_mi.variant.return_value = "cuda_ad_rgb" + + with ( + patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path") as mock_ensure, + patch( + "sm_tias_lightsim.render.runner.build_scene", + return_value=({"type": "scene"}, "/tmp/fake"), + ), + patch("sm_tias_lightsim.render.runner.log_variant_status"), + patch.dict("sys.modules", {"mitsuba": dummy_mi}), + patch("shutil.rmtree"), + ): + try: + runner_module.render_scene(config, project_root=Path("/tmp")) + except Exception: + pass + + mock_ensure.assert_not_called() + + +# --------------------------------------------------------------------------- +# C3: ImportError 時に診断ログが出て re-raise される +# --------------------------------------------------------------------------- + + +class TestImportErrorDiagnostics: + """C3: ImportError 時に診断ログが出て re-raise されること.""" + + def test_import_error_with_llvm_message_emits_error_log( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """C3: mi.set_variant が 'LLVM' 含む ImportError を出したとき ERROR ログが出ること. + + 仕様: + mi.set_variant が ImportError を出した場合、原因明示ログを出してから re-raise する. + """ + config = _make_scene_config(variant="llvm_ad_rgb") + + dummy_mi = MagicMock() + dummy_mi.set_variant.side_effect = ImportError( + "jit_init_thread_state(): LLVM backend is inactive" + ) + + with ( + patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"), + patch("sm_tias_lightsim.render.runner.log_variant_status"), + patch.dict("sys.modules", {"mitsuba": dummy_mi}), + ): + with caplog.at_level(logging.ERROR, logger="sm_tias_lightsim.render.runner"): + with pytest.raises(ImportError): + runner_module.render_scene(config, project_root=Path("/tmp")) + + error_records = [r for r in caplog.records if r.levelno >= logging.ERROR] + assert error_records, "ImportError 時に ERROR レベルのログが出ること" + + def test_import_error_is_reraised(self) -> None: + """C3: ImportError は診断ログ後に re-raise されること(握りつぶされない). + + 仕様: + ImportError → 原因明示ログ → re-raise. + """ + config = _make_scene_config(variant="llvm_ad_rgb") + + dummy_mi = MagicMock() + dummy_mi.set_variant.side_effect = ImportError("LLVM backend inactive") + + with ( + patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"), + patch("sm_tias_lightsim.render.runner.log_variant_status"), + patch.dict("sys.modules", {"mitsuba": dummy_mi}), + ): + with pytest.raises(ImportError): + runner_module.render_scene(config, project_root=Path("/tmp")) + + def test_import_error_without_llvm_in_message_is_still_reraised(self) -> None: + """C3: ImportError のメッセージに 'LLVM' を含まない場合も re-raise されること. + + 仕様: ImportError は必ず re-raise する(メッセージ内容に依らず). + """ + config = _make_scene_config(variant="llvm_ad_rgb") + + dummy_mi = MagicMock() + dummy_mi.set_variant.side_effect = ImportError("some other import error") + + with ( + patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"), + patch("sm_tias_lightsim.render.runner.log_variant_status"), + patch.dict("sys.modules", {"mitsuba": dummy_mi}), + ): + with pytest.raises(ImportError): + runner_module.render_scene(config, project_root=Path("/tmp")) + + def test_import_error_log_contains_variant_name(self, caplog: pytest.LogCaptureFixture) -> None: + """C3: ImportError ログに variant 名が含まれること(診断容易化). + + 仕様: 原因明示ログを出す. + """ + config = _make_scene_config(variant="llvm_ad_rgb") + + dummy_mi = MagicMock() + dummy_mi.set_variant.side_effect = ImportError("LLVM backend is inactive") + + with ( + patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"), + patch("sm_tias_lightsim.render.runner.log_variant_status"), + patch.dict("sys.modules", {"mitsuba": dummy_mi}), + ): + with caplog.at_level(logging.ERROR, logger="sm_tias_lightsim.render.runner"): + with pytest.raises(ImportError): + runner_module.render_scene(config, project_root=Path("/tmp")) + + error_messages = [r.message for r in caplog.records if r.levelno >= logging.ERROR] + assert any("llvm_ad_rgb" in msg for msg in error_messages), ( + f"ERROR ログに variant 名が含まれること: {error_messages}" + ) + + +# --------------------------------------------------------------------------- +# C4: set_variant 成功後に log_variant_status が呼ばれる +# --------------------------------------------------------------------------- + + +class TestLogVariantStatusCalled: + """C4: set_variant 成功後に log_variant_status が呼ばれること.""" + + def test_log_variant_status_called_after_successful_set_variant(self) -> None: + """C4: mi.set_variant 成功後に log_variant_status が呼ばれること. + + 仕様: + set_variant 成功後に variant 状態を INFO ログで出力する. + """ + config = _make_scene_config(variant="llvm_ad_rgb") + + dummy_mi = MagicMock() + dummy_mi.variant.return_value = "llvm_ad_rgb" + + with ( + patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"), + patch( + "sm_tias_lightsim.render.runner.build_scene", + return_value=({"type": "scene"}, "/tmp/fake"), + ), + patch("sm_tias_lightsim.render.runner.log_variant_status") as mock_log, + patch.dict("sys.modules", {"mitsuba": dummy_mi}), + patch("shutil.rmtree"), + ): + try: + runner_module.render_scene(config, project_root=Path("/tmp")) + except Exception: + pass + + mock_log.assert_called_once() + + def test_log_variant_status_receives_correct_variants(self) -> None: + """C4: log_variant_status に requested_variant と actual_variant が正しく渡されること. + + 仕様: log_variant_status(_logger, desired_variant, mi.variant()) が呼ばれる. + """ + config = _make_scene_config(variant="llvm_ad_rgb") + + dummy_mi = MagicMock() + dummy_mi.variant.return_value = "llvm_ad_rgb" + + with ( + patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"), + patch( + "sm_tias_lightsim.render.runner.build_scene", + return_value=({"type": "scene"}, "/tmp/fake"), + ), + patch("sm_tias_lightsim.render.runner.log_variant_status") as mock_log, + patch.dict("sys.modules", {"mitsuba": dummy_mi}), + patch("shutil.rmtree"), + ): + try: + runner_module.render_scene(config, project_root=Path("/tmp")) + except Exception: + pass + + if mock_log.call_args is not None: + _logger_arg, requested, actual = mock_log.call_args[0] + assert requested == "llvm_ad_rgb", ( + f"requested_variant が 'llvm_ad_rgb' であること: got {requested}" + ) + assert actual == "llvm_ad_rgb", ( + f"actual_variant が 'llvm_ad_rgb' であること: got {actual}" + ) + + def test_log_variant_status_not_called_on_import_error(self) -> None: + """C4: ImportError 時には log_variant_status が呼ばれないこと. + + 仕様: set_variant が失敗した場合は log_variant_status に到達しない. + """ + config = _make_scene_config(variant="llvm_ad_rgb") + + dummy_mi = MagicMock() + dummy_mi.set_variant.side_effect = ImportError("LLVM inactive") + + with ( + patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"), + patch("sm_tias_lightsim.render.runner.log_variant_status") as mock_log, + patch.dict("sys.modules", {"mitsuba": dummy_mi}), + ): + with pytest.raises(ImportError): + runner_module.render_scene(config, project_root=Path("/tmp")) + + mock_log.assert_not_called() + + +# --------------------------------------------------------------------------- +# C5: 既に variant が設定済みなら警告を出して再設定しない +# --------------------------------------------------------------------------- + + +class TestVariantAlreadySetWarning: + """C5: 既に variant が設定済みなら警告を出して再設定しない(既存挙動の維持).""" + + def test_already_set_different_variant_emits_warning( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """C5: 既に異なる variant が設定済みのとき WARNING が出ること. + + 仕様: 既に異なる variant が設定されている場合は警告ログを出して続行する. + """ + # 事前に異なる variant を設定済みにする + runner_module._current_variant = "scalar_rgb" + config = _make_scene_config(variant="llvm_ad_rgb") + + dummy_mi = MagicMock() + dummy_mi.variant.return_value = "scalar_rgb" + + with ( + patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"), + patch( + "sm_tias_lightsim.render.runner.build_scene", + return_value=({"type": "scene"}, "/tmp/fake"), + ), + patch("sm_tias_lightsim.render.runner.log_variant_status"), + patch.dict("sys.modules", {"mitsuba": dummy_mi}), + patch("shutil.rmtree"), + ): + with caplog.at_level(logging.WARNING, logger="sm_tias_lightsim.render.runner"): + try: + runner_module.render_scene(config, project_root=Path("/tmp")) + except Exception: + pass + + warning_records = [r for r in caplog.records if r.levelno >= logging.WARNING] + assert warning_records, "異なる variant が既設定のとき WARNING が出ること" + + def test_already_set_different_variant_does_not_call_set_variant_again(self) -> None: + """C5: 既に設定済みのとき mi.set_variant が再呼び出しされないこと. + + 仕様: 既に variant が設定済みなら再設定しない. + """ + runner_module._current_variant = "scalar_rgb" + config = _make_scene_config(variant="llvm_ad_rgb") + + dummy_mi = MagicMock() + dummy_mi.variant.return_value = "scalar_rgb" + + with ( + patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"), + patch( + "sm_tias_lightsim.render.runner.build_scene", + return_value=({"type": "scene"}, "/tmp/fake"), + ), + patch("sm_tias_lightsim.render.runner.log_variant_status"), + patch.dict("sys.modules", {"mitsuba": dummy_mi}), + patch("shutil.rmtree"), + ): + try: + runner_module.render_scene(config, project_root=Path("/tmp")) + except Exception: + pass + + dummy_mi.set_variant.assert_not_called() + + def test_same_variant_already_set_does_not_emit_warning( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """C5: 同じ variant が既設定のとき WARNING が出ないこと. + + 仕様: 同じ variant が既に設定されている場合は何もしない(警告なし). + """ + runner_module._current_variant = "llvm_ad_rgb" + config = _make_scene_config(variant="llvm_ad_rgb") + + dummy_mi = MagicMock() + dummy_mi.variant.return_value = "llvm_ad_rgb" + + with ( + patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"), + patch( + "sm_tias_lightsim.render.runner.build_scene", + return_value=({"type": "scene"}, "/tmp/fake"), + ), + patch("sm_tias_lightsim.render.runner.log_variant_status"), + patch.dict("sys.modules", {"mitsuba": dummy_mi}), + patch("shutil.rmtree"), + ): + with caplog.at_level(logging.WARNING, logger="sm_tias_lightsim.render.runner"): + try: + runner_module.render_scene(config, project_root=Path("/tmp")) + except Exception: + pass + + warning_records = [r for r in caplog.records if r.levelno >= logging.WARNING] + messages = [r.message for r in warning_records] + assert not warning_records, f"同じ variant 設定済みのとき WARNING は出ないこと: {messages}" diff --git a/tests/runtime/__init__.py b/tests/runtime/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/runtime/__init__.py diff --git a/tests/runtime/test_llvm_init.py b/tests/runtime/test_llvm_init.py new file mode 100644 index 0000000..58d2058 --- /dev/null +++ b/tests/runtime/test_llvm_init.py @@ -0,0 +1,535 @@ +"""runtime/llvm_init.py の仕様ベーステスト. + +仕様出典: +- docs/06_TEST/TEST_04_シミュレーション速度最適化履歴.md — Speed-1 セクション +- Phase 1 実装サマリー (ensure_drjit_libllvm_path / log_variant_status の公開 API 仕様) + +テスト対象: + ensure_drjit_libllvm_path(extra_candidates) -> str | None + log_variant_status(logger, requested_variant, actual_variant) -> None + +テスト方針: + - mitsuba は一切 import しない(llvm_init.py 自体が mitsuba 非依存) + - ファイル存在チェックは monkeypatch で Path.is_file をモックして軽量化 + - ログ検証は pytest の caplog フィクスチャを使用 + - os.environ の副作用はテスト後にクリーンアップする (monkeypatch が自動復元) +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from sm_tias_lightsim.runtime.llvm_init import ( + _iter_default_candidates, + ensure_drjit_libllvm_path, + log_variant_status, +) + +# --------------------------------------------------------------------------- +# ヘルパー +# --------------------------------------------------------------------------- + +_FAKE_PATH = r"C:\fake\path\LLVM-C.dll" +_ANOTHER_PATH = r"C:\another\LLVM-C.dll" + + +# --------------------------------------------------------------------------- +# A. ensure_drjit_libllvm_path の仕様テスト +# --------------------------------------------------------------------------- + + +class TestEnsureDrjitLibllvmPath: + """ensure_drjit_libllvm_path の仕様を検証するテスト群.""" + + # A1: DRJIT_LIBLLVM_PATH が既設定なら上書きしない + def test_already_set_returns_existing_value(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A1: DRJIT_LIBLLVM_PATH が既設定ならその値をそのまま返し os.environ を変更しないこと. + + 仕様: + 既に os.environ['DRJIT_LIBLLVM_PATH'] が設定されている場合はそれを尊重して + 何もしない(戻り値はその既存値). + """ + monkeypatch.setenv("DRJIT_LIBLLVM_PATH", _FAKE_PATH) + result = ensure_drjit_libllvm_path() + assert result == _FAKE_PATH + + def test_already_set_does_not_overwrite_environ(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A1: 既設定の場合、候補が存在していても DRJIT_LIBLLVM_PATH は変更されないこと. + + 仕様: 既に設定されている場合は何もしない(上書きしない). + """ + monkeypatch.setenv("DRJIT_LIBLLVM_PATH", _FAKE_PATH) + + # 候補パスが is_file=True を返すようモックしても既存値を尊重すること + with patch.object(Path, "is_file", return_value=True): + result = ensure_drjit_libllvm_path(extra_candidates=[_ANOTHER_PATH]) + + assert result == _FAKE_PATH + assert os.environ.get("DRJIT_LIBLLVM_PATH") == _FAKE_PATH + + # A2: DRJIT_LIBLLVM_PATH 未設定でパスが見つかれば os.environ に設定して戻す + def test_unset_finds_first_candidate_and_sets_environ( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A2: DRJIT_LIBLLVM_PATH 未設定で最初の候補が存在すれば os.environ に設定して戻すこと. + + 仕様: + DRJIT_LIBLLVM_PATH 未設定で、Windows 標準パスのいずれかが存在すれば + 最初に見つかったものを os.environ に設定して戻す. + """ + monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False) + + # _iter_default_candidates の代わりに固定リストを返すようモック + fake_candidate = r"C:\Program Files\LLVM\bin\LLVM-C.dll" + with patch( + "sm_tias_lightsim.runtime.llvm_init._iter_default_candidates", + return_value=[fake_candidate], + ): + # fake_candidate だけ is_file=True にする + def is_file_side_effect(self_: Path) -> bool: + return str(self_) == fake_candidate + + with patch.object(Path, "is_file", is_file_side_effect): + result = ensure_drjit_libllvm_path() + + assert result == fake_candidate + assert os.environ.get("DRJIT_LIBLLVM_PATH") == fake_candidate + + def test_unset_returns_first_found_not_second(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A2: 複数候補がある場合、最初に見つかったものが設定されること. + + 仕様: 最初に見つかったものを os.environ に設定して戻す(順序が保証されること). + """ + monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False) + + first_candidate = r"C:\first\LLVM-C.dll" + second_candidate = r"C:\second\LLVM-C.dll" + + with patch( + "sm_tias_lightsim.runtime.llvm_init._iter_default_candidates", + return_value=[first_candidate, second_candidate], + ): + # 両方とも is_file=True だが最初が優先される + with patch.object(Path, "is_file", return_value=True): + result = ensure_drjit_libllvm_path() + + assert result == first_candidate + assert os.environ.get("DRJIT_LIBLLVM_PATH") == first_candidate + + # A3: LLVM_DIR 環境変数が設定されていれば $LLVM_DIR/bin/LLVM-C.dll を試す + def test_llvm_dir_env_var_is_used_as_candidate(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A3: LLVM_DIR 環境変数が設定されていれば $LLVM_DIR/bin/LLVM-C.dll が候補に含まれること. + + 仕様: + 環境変数 LLVM_DIR が設定されていれば $LLVM_DIR/bin/LLVM-C.dll を試す + (標準パスより優先 or 順番は仕様通り). + """ + monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False) + monkeypatch.setenv("LLVM_DIR", r"C:\custom_llvm") + + # _iter_default_candidates を実際に呼び出し、LLVM_DIR 由来の候補が含まれることを確認 + candidates = _iter_default_candidates() + + # Windows 環境では LLVM_DIR/bin/LLVM-C.dll が先頭に追加されること + if os.name == "nt": + assert r"C:\custom_llvm\bin\LLVM-C.dll" in candidates + else: + assert any("custom_llvm" in c for c in candidates) + + def test_llvm_dir_candidate_is_searched_before_standard_paths( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A3: LLVM_DIR 由来の候補は標準パスより前に試される(先頭に挿入される)こと. + + 仕様: 候補パスの順序 - LLVM_DIR → Windows 標準パス → extra_candidates. + """ + if os.name != "nt": + pytest.skip("Windows 専用テスト") + + monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False) + monkeypatch.setenv("LLVM_DIR", r"C:\custom_llvm") + + candidates = _iter_default_candidates() + + # 先頭が LLVM_DIR 由来であること + assert candidates[0] == r"C:\custom_llvm\bin\LLVM-C.dll" + + # A4: extra_candidates で追加候補を渡せ、Windows/POSIX 標準パスの後に試す + def test_extra_candidates_are_tried_after_default_candidates( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A4: extra_candidates は標準候補の後に試されること. + + 仕様: extra_candidates が指定されていれば標準候補で見つからない場合に最後に試す. + """ + monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False) + monkeypatch.delenv("LLVM_DIR", raising=False) + + extra_path = r"C:\extra\LLVM-C.dll" + + # 標準候補は全て存在しない、extra_candidates のみ存在する + with patch( + "sm_tias_lightsim.runtime.llvm_init._iter_default_candidates", + return_value=[], + ): + with patch.object(Path, "is_file", return_value=True): + result = ensure_drjit_libllvm_path(extra_candidates=[extra_path]) + + assert result == extra_path + assert os.environ.get("DRJIT_LIBLLVM_PATH") == extra_path + + def test_extra_candidates_none_is_accepted(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A4: extra_candidates=None でもエラーにならないこと(None はデフォルト). + + 仕様: extra_candidates: list[str] | None = None + """ + monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False) + + with patch( + "sm_tias_lightsim.runtime.llvm_init._iter_default_candidates", + return_value=[], + ): + result = ensure_drjit_libllvm_path(extra_candidates=None) + + assert result is None + + def test_extra_candidates_searched_after_standard_paths_order( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A4: 標準候補が見つかれば extra_candidates は試されないこと(先に標準候補が発見). + + 仕様: 標準候補で見つからない場合に最後に試す. + """ + monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False) + + standard_path = r"C:\standard\LLVM-C.dll" + extra_path = r"C:\extra\LLVM-C.dll" + + tried_paths: list[str] = [] + + def is_file_tracker(self_: Path) -> bool: + tried_paths.append(str(self_)) + return str(self_) == standard_path + + with patch( + "sm_tias_lightsim.runtime.llvm_init._iter_default_candidates", + return_value=[standard_path], + ): + with patch.object(Path, "is_file", is_file_tracker): + result = ensure_drjit_libllvm_path(extra_candidates=[extra_path]) + + assert result == standard_path + # extra_path は試されないこと + assert extra_path not in tried_paths + + # A5: すべての候補が不在なら None を返し os.environ も変更しない + def test_no_candidates_found_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A5: すべての候補が不在なら None を返すこと. + + 仕様: 何も見つからなければ None. + """ + monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False) + + with patch( + "sm_tias_lightsim.runtime.llvm_init._iter_default_candidates", + return_value=[], + ): + with patch.object(Path, "is_file", return_value=False): + result = ensure_drjit_libllvm_path() + + assert result is None + + def test_no_candidates_found_does_not_set_environ( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A5: すべての候補が不在のとき os.environ['DRJIT_LIBLLVM_PATH'] が設定されないこと. + + 仕様: 何も見つからなければ os.environ も変更しない. + """ + monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False) + + with patch( + "sm_tias_lightsim.runtime.llvm_init._iter_default_candidates", + return_value=[r"C:\nonexistent\LLVM-C.dll"], + ): + with patch.object(Path, "is_file", return_value=False): + ensure_drjit_libllvm_path() + + assert "DRJIT_LIBLLVM_PATH" not in os.environ + + # A6: 副作用は os.environ['DRJIT_LIBLLVM_PATH'] への代入のみ + def test_no_mitsuba_import_on_call(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A6: ensure_drjit_libllvm_path は mitsuba を import しないこと. + + 仕様: mitsuba import しない・他の環境変数を触らない. + llvm_init.py は 'mitsuba' を import しない設計である. + """ + import sys + + monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False) + + # mitsuba が sys.modules に入っていないことを前提に、 + # 呼び出し後も新たに import されていないことを確認する + before_modules = set(sys.modules.keys()) + + with patch( + "sm_tias_lightsim.runtime.llvm_init._iter_default_candidates", + return_value=[], + ): + ensure_drjit_libllvm_path() + + after_modules = set(sys.modules.keys()) + new_modules = after_modules - before_modules + # mitsuba 関連のモジュールが新たに import されていないこと + mitsuba_new = [m for m in new_modules if "mitsuba" in m or "drjit" in m] + assert not mitsuba_new, ( + f"ensure_drjit_libllvm_path が mitsuba/drjit を import した: {mitsuba_new}" + ) + + def test_only_drjit_libllvm_path_env_var_is_touched( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A6: 設定されるのは DRJIT_LIBLLVM_PATH のみであること(他の環境変数は変更しない). + + 仕様: 副作用は os.environ['DRJIT_LIBLLVM_PATH'] への代入のみ. + """ + monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False) + + # 現在の環境変数スナップショット(DRJIT_LIBLLVM_PATH を除く) + before = {k: v for k, v in os.environ.items() if k != "DRJIT_LIBLLVM_PATH"} + + fake_candidate = r"C:\fake\LLVM-C.dll" + with patch( + "sm_tias_lightsim.runtime.llvm_init._iter_default_candidates", + return_value=[fake_candidate], + ): + with patch.object(Path, "is_file", return_value=True): + ensure_drjit_libllvm_path() + + # DRJIT_LIBLLVM_PATH 以外が変化していないこと + after = {k: v for k, v in os.environ.items() if k != "DRJIT_LIBLLVM_PATH"} + assert before == after, "DRJIT_LIBLLVM_PATH 以外の環境変数が変更された" + + +# --------------------------------------------------------------------------- +# B. log_variant_status の仕様テスト +# --------------------------------------------------------------------------- + + +class TestLogVariantStatus: + """log_variant_status の仕様を検証するテスト群.""" + + # B1: actual_variant = 'llvm_ad_rgb' で INFO レベルに variant 名と DRJIT_LIBLLVM_PATH 値を含む + def test_llvm_variant_emits_info_with_variant_name( + self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch + ) -> None: + """B1: actual_variant='llvm_ad_rgb' のとき INFO ログに variant 名が含まれること. + + 仕様: llvm_* variant で INFO レベルに variant 名と DRJIT_LIBLLVM_PATH 値を含むログが出る. + """ + monkeypatch.setenv("DRJIT_LIBLLVM_PATH", r"C:\Program Files\LLVM\bin\LLVM-C.dll") + logger = logging.getLogger("test.log_variant_status_b1_name") + + with caplog.at_level(logging.INFO, logger="test.log_variant_status_b1_name"): + log_variant_status(logger, "llvm_ad_rgb", "llvm_ad_rgb") + + assert any("llvm_ad_rgb" in r.message for r in caplog.records), ( + "INFO ログに variant 名 'llvm_ad_rgb' が含まれること" + ) + + def test_llvm_variant_emits_info_with_drjit_path( + self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch + ) -> None: + """B1: actual_variant='llvm_ad_rgb' のとき INFO ログに DRJIT_LIBLLVM_PATH 値が含まれること. + + 仕様: DRJIT_LIBLLVM_PATH 値を含むメッセージが出る. + """ + dll_path = r"C:\Program Files\LLVM\bin\LLVM-C.dll" + monkeypatch.setenv("DRJIT_LIBLLVM_PATH", dll_path) + logger = logging.getLogger("test.log_variant_status_b1_path") + + with caplog.at_level(logging.INFO, logger="test.log_variant_status_b1_path"): + log_variant_status(logger, "llvm_ad_rgb", "llvm_ad_rgb") + + info_messages = [r.message for r in caplog.records if r.levelno == logging.INFO] + assert any(dll_path in msg for msg in info_messages), ( + f"INFO ログに DRJIT_LIBLLVM_PATH 値 '{dll_path}' が含まれること" + ) + + def test_llvm_variant_emits_info_level( + self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch + ) -> None: + """B1: actual_variant='llvm_ad_rgb' のとき INFO レベルのレコードが出ること.""" + monkeypatch.setenv("DRJIT_LIBLLVM_PATH", r"C:\fake\LLVM-C.dll") + logger = logging.getLogger("test.log_variant_status_b1_level") + + with caplog.at_level(logging.INFO, logger="test.log_variant_status_b1_level"): + log_variant_status(logger, "llvm_ad_rgb", "llvm_ad_rgb") + + info_records = [r for r in caplog.records if r.levelno == logging.INFO] + assert info_records, "INFO レベルのレコードが少なくとも 1 件出ること" + + # B2: actual_variant = 'cuda_ad_rgb' で INFO に CUDA 経路メッセージ + def test_cuda_variant_emits_info_message(self, caplog: pytest.LogCaptureFixture) -> None: + """B2: actual_variant='cuda_ad_rgb' のとき INFO ログに CUDA 関連の情報が含まれること. + + 仕様: cuda_* で INFO に CUDA 経路メッセージ. + """ + logger = logging.getLogger("test.log_variant_status_b2") + + with caplog.at_level(logging.INFO, logger="test.log_variant_status_b2"): + log_variant_status(logger, "cuda_ad_rgb", "cuda_ad_rgb") + + info_messages = [r.message for r in caplog.records if r.levelno == logging.INFO] + assert info_messages, "INFO メッセージが少なくとも 1 件出ること" + # CUDA 関連のキーワードが含まれること + assert any( + "cuda_ad_rgb" in msg or "cuda" in msg.lower() or "CUDA" in msg for msg in info_messages + ), f"CUDA に関するメッセージが INFO ログに含まれること: {info_messages}" + + def test_cuda_variant_emits_info_not_warning(self, caplog: pytest.LogCaptureFixture) -> None: + """B2: actual_variant='cuda_ad_rgb' のとき WARNING は出ないこと. + + 仕様: cuda_* は正常経路なので WARNING なし. + """ + logger = logging.getLogger("test.log_variant_status_b2_no_warn") + + with caplog.at_level(logging.DEBUG, logger="test.log_variant_status_b2_no_warn"): + log_variant_status(logger, "cuda_ad_rgb", "cuda_ad_rgb") + + warning_records = [r for r in caplog.records if r.levelno >= logging.WARNING] + messages = [r.message for r in warning_records] + assert not warning_records, f"CUDA 経路では WARNING が出ないこと: {messages}" + + # B3: actual_variant = 'scalar_rgb' で INFO に scalar 経路メッセージ + def test_scalar_variant_emits_info_message(self, caplog: pytest.LogCaptureFixture) -> None: + """B3: actual_variant='scalar_rgb' のとき INFO ログに scalar 関連のメッセージが含まれること. + + + 仕様: scalar_* で INFO に scalar 経路メッセージ. + """ + logger = logging.getLogger("test.log_variant_status_b3") + + with caplog.at_level(logging.INFO, logger="test.log_variant_status_b3"): + log_variant_status(logger, "scalar_rgb", "scalar_rgb") + + info_messages = [r.message for r in caplog.records if r.levelno == logging.INFO] + assert info_messages, "INFO メッセージが少なくとも 1 件出ること" + # scalar 関連のキーワードが含まれること + assert any("scalar_rgb" in msg or "scalar" in msg.lower() for msg in info_messages), ( + f"scalar に関するメッセージが INFO ログに含まれること: {info_messages}" + ) + + def test_scalar_variant_no_warning_when_requested_also_scalar( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """B3: requested_variant='scalar_rgb', actual='scalar_rgb' のとき WARNING は出ないこと. + + 仕様: 要求通りの variant が得られた場合は WARNING なし. + """ + logger = logging.getLogger("test.log_variant_status_b3_no_warn") + + with caplog.at_level(logging.DEBUG, logger="test.log_variant_status_b3_no_warn"): + log_variant_status(logger, "scalar_rgb", "scalar_rgb") + + warning_records = [r for r in caplog.records if r.levelno >= logging.WARNING] + messages = [r.message for r in warning_records] + assert not warning_records, ( + f"scalar 要求 → scalar 実際のとき WARNING は出ないこと: {messages}" + ) + + # B4: requested='llvm_ad_rgb', actual='scalar_rgb' → WARNING が追加される + def test_llvm_requested_but_scalar_actual_emits_warning( + self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch + ) -> None: + """B4: requested='llvm_ad_rgb', actual='scalar_rgb' のとき WARNING が出ること. + + 仕様: + 要求 llvm_* なのに actual scalar_* なら WARNING を追加出力(乖離を明示). + """ + monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False) + logger = logging.getLogger("test.log_variant_status_b4_warn") + + with caplog.at_level(logging.DEBUG, logger="test.log_variant_status_b4_warn"): + log_variant_status(logger, "llvm_ad_rgb", "scalar_rgb") + + warning_records = [r for r in caplog.records if r.levelno >= logging.WARNING] + assert warning_records, "llvm_* 要求 → scalar_* 実際のとき WARNING が出ること" + + def test_llvm_requested_but_scalar_actual_warning_contains_variant_names( + self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch + ) -> None: + """B4: WARNING メッセージに要求 variant と実 variant の両方の名前が含まれること. + + 仕様: 要求と実 variant の乖離を明示する. + """ + monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False) + logger = logging.getLogger("test.log_variant_status_b4_content") + + with caplog.at_level(logging.DEBUG, logger="test.log_variant_status_b4_content"): + log_variant_status(logger, "llvm_ad_rgb", "scalar_rgb") + + warning_messages = [r.message for r in caplog.records if r.levelno >= logging.WARNING] + assert any("llvm_ad_rgb" in msg for msg in warning_messages), ( + f"WARNING に要求 variant 名が含まれること: {warning_messages}" + ) + assert any("scalar_rgb" in msg for msg in warning_messages), ( + f"WARNING に実 variant 名が含まれること: {warning_messages}" + ) + + def test_llvm_requested_but_scalar_actual_also_emits_info( + self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch + ) -> None: + """B4: WARNING に加えて INFO も出ること(B3 の scalar INFO + B4 の WARNING). + + 仕様: actual='scalar_rgb' なので INFO に scalar 経路メッセージも出る. + """ + monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False) + logger = logging.getLogger("test.log_variant_status_b4_also_info") + + with caplog.at_level(logging.DEBUG, logger="test.log_variant_status_b4_also_info"): + log_variant_status(logger, "llvm_ad_rgb", "scalar_rgb") + + info_records = [r for r in caplog.records if r.levelno == logging.INFO] + warning_records = [r for r in caplog.records if r.levelno >= logging.WARNING] + assert info_records, "INFO も出ること" + assert warning_records, "WARNING も出ること" + + # B5: ロガーは引数で受け取る(テストで caplog キャプチャ可能であることを確認) + def test_logger_is_received_as_argument(self, caplog: pytest.LogCaptureFixture) -> None: + """B5: ロガーは引数で受け取り、テストで caplog キャプチャ可能であること. + + 仕様: ロガーは引数で受け取り,テストで caplog キャプチャ可能. + """ + # 独自のロガー名を使い、そのロガー経由でログが出ることを確認 + custom_logger = logging.getLogger("test.custom_logger_b5") + + with caplog.at_level(logging.INFO, logger="test.custom_logger_b5"): + log_variant_status(custom_logger, "scalar_rgb", "scalar_rgb") + + # このロガー名でキャプチャできていること + records_for_custom = [r for r in caplog.records if r.name == "test.custom_logger_b5"] + assert records_for_custom, "引数で渡したロガーでログが出て caplog にキャプチャされること" + + def test_different_loggers_produce_independent_records( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """B5: 異なるロガーを渡すと独立したログレコードが出ること(ロガー分離). + + 仕様: ロガーは引数で受け取る(固定 logger ではない). + """ + logger_a = logging.getLogger("test.b5_logger_a") + logger_b = logging.getLogger("test.b5_logger_b") + + with caplog.at_level(logging.INFO): + log_variant_status(logger_a, "scalar_rgb", "scalar_rgb") + log_variant_status(logger_b, "cuda_ad_rgb", "cuda_ad_rgb") + + names = {r.name for r in caplog.records} + assert "test.b5_logger_a" in names + assert "test.b5_logger_b" in names diff --git a/tests/visualize/test_baker_jit_init.py b/tests/visualize/test_baker_jit_init.py new file mode 100644 index 0000000..7e4137b --- /dev/null +++ b/tests/visualize/test_baker_jit_init.py @@ -0,0 +1,296 @@ +"""visualize/baker.py の JIT 初期化統合テスト (Speed-1 仕様). + +仕様出典: +- docs/06_TEST/TEST_04_シミュレーション速度最適化履歴.md — Speed-1 セクション +- Phase 1 実装サマリー (baker.py の log_variant_status 呼び出し仕様) + +テスト対象: + _set_variant_once() 内の JIT 初期化フロー: + - D1: _set_variant_once('scalar_rgb') 成功後に log_variant_status が呼ばれる + - D2: _bake_direct 経由でも variant 設定後にログが出る + - D3: _set_variant_once('llvm_ad_rgb') で ensure_drjit_libllvm_path が呼ばれる(Speed-2 前準備) + +テスト方針: + - Mitsuba をモックして軽量テスト(実 bake は slow テストでカバー済み) + - caplog / mock で診断ログとフック呼び出しを検証 + - _set_variant_once は内部 API だが Speed-1 仕様の核となる関数なので直接テスト対象とする +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +import sm_tias_lightsim.visualize.baker as baker_module +from sm_tias_lightsim.visualize.baker import _set_variant_once + +# --------------------------------------------------------------------------- +# フィクスチャ +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def reset_mitsuba_variant_mock(): + """各テストで mitsuba をモック化してクリーンな状態を保つ. + + _set_variant_once は内部で mitsuba.variant() / mitsuba.set_variant() を呼ぶため、 + テスト用のモック mi を sys.modules に注入する. + """ + # conftest.py で scalar_rgb が設定済みのため、テスト内でのバリアント状態は + # モック mi.variant() の戻り値で制御する + yield + + +# --------------------------------------------------------------------------- +# D1: _set_variant_once('scalar_rgb') 成功後に log_variant_status が呼ばれる +# --------------------------------------------------------------------------- + + +class TestSetVariantOnceCallsLogVariantStatus: + """D1: _set_variant_once 成功後に log_variant_status が呼ばれること.""" + + def test_scalar_variant_calls_log_variant_status(self) -> None: + """D1: _set_variant_once('scalar_rgb') 成功後に log_variant_status が呼ばれること. + + 仕様: + _set_variant_once 内で mi.set_variant が成功したら + log_variant_status(_logger, desired_variant, mi.variant()) を呼ぶ. + """ + dummy_mi = MagicMock() + # variant() が None を返す(未設定状態)→ set_variant を呼ぶ分岐に入る + dummy_mi.variant.return_value = None + + with patch.dict("sys.modules", {"mitsuba": dummy_mi}): + with patch("sm_tias_lightsim.visualize.baker.log_variant_status") as mock_log: + _set_variant_once("scalar_rgb") + + mock_log.assert_called_once() + + def test_log_variant_status_receives_scalar_variant(self) -> None: + """D1: log_variant_status に 'scalar_rgb' が渡されること. + + 仕様: _set_variant_once の desired_variant と mi.variant() 戻り値が log に渡される. + """ + dummy_mi = MagicMock() + dummy_mi.variant.return_value = None + + # set_variant 呼び出し後に variant() が 'scalar_rgb' を返すように設定 + def variant_side_effect() -> str | None: + # set_variant が呼ばれた後は 'scalar_rgb' を返す + if dummy_mi.set_variant.called: + return "scalar_rgb" + return None + + dummy_mi.variant.side_effect = variant_side_effect + + with patch.dict("sys.modules", {"mitsuba": dummy_mi}): + with patch("sm_tias_lightsim.visualize.baker.log_variant_status") as mock_log: + _set_variant_once("scalar_rgb") + + if mock_log.call_args is not None: + _logger_arg, requested, actual = mock_log.call_args[0] + assert requested == "scalar_rgb", ( + f"requested が 'scalar_rgb' であること: got {requested}" + ) + + def test_already_same_variant_does_not_call_log(self) -> None: + """D1: 既に同じ variant が設定済みのとき log_variant_status が呼ばれないこと. + + 仕様: 既に同じ variant が設定されている場合は何もしない(log も出さない). + """ + dummy_mi = MagicMock() + # variant() が既に 'scalar_rgb' を返す(設定済み) + dummy_mi.variant.return_value = "scalar_rgb" + + with patch.dict("sys.modules", {"mitsuba": dummy_mi}): + with patch("sm_tias_lightsim.visualize.baker.log_variant_status") as mock_log: + _set_variant_once("scalar_rgb") + + mock_log.assert_not_called() + + def test_already_different_variant_emits_warning_not_log_variant_status( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """D1: 既に異なる variant が設定済みのとき警告が出て log_variant_status は呼ばれないこと. + + 仕様: 異なる variant が設定されている場合は警告を出して続行する(変更しない). + """ + dummy_mi = MagicMock() + dummy_mi.variant.return_value = "llvm_ad_rgb" # 既設定(異なる) + + with patch.dict("sys.modules", {"mitsuba": dummy_mi}): + with patch("sm_tias_lightsim.visualize.baker.log_variant_status") as mock_log: + with caplog.at_level(logging.WARNING, logger="sm_tias_lightsim.visualize.baker"): + _set_variant_once("scalar_rgb") + + mock_log.assert_not_called() + warning_records = [r for r in caplog.records if r.levelno >= logging.WARNING] + assert warning_records, "異なる variant 設定済みのとき WARNING が出ること" + + +# --------------------------------------------------------------------------- +# D2: _bake_direct 経由でも variant 設定後にログが出る +# --------------------------------------------------------------------------- + + +class TestBakeDirectCallsLogVariantStatus: + """D2: _bake_direct 経由(bake_irradiance mode='direct')でも variant ログが出ること. + + bake_irradiance(mode='direct') は内部で mi.set_variant('scalar_rgb') を呼び、 + 成功後に log_variant_status を呼ぶ仕様. + """ + + def test_bake_direct_variant_log_is_emitted_on_success( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """D2: bake_irradiance(mode='direct') が variant 設定後にログを出すこと. + + 仕様: + mode='direct' の分岐で _variant_set_succeeded のとき + log_variant_status(_logger, desired_variant, mi.variant()) が呼ばれる. + """ + from sm_tias_lightsim.scene.schema import SceneConfig + + # 最小限のシーン設定(実ファイル不要 — build_scene をモック) + data = { + "name": "test_bake_direct_log", + "geometry": { + "meshes": [{"id": "m1", "path": "/fake/m1.stl", "unit": "mm", "material": "white"}] + }, + "materials": {"white": {"type": "diffuse", "reflectance": 0.9}}, + "lights": [ + { + "id": "led", + "type": "point", + "position": [0, 0, 100], + "intensity": 1.0, + "profile": "lambertian", + } + ], + "sensor": { + "type": "perspective", + "position": [0, 0, 200], + "target": [0, 0, 0], + "up": [0, 1, 0], + "fov_deg": 60.0, + "film": {"width": 4, "height": 4}, + }, + "render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 2, "spp": 1}, + } + config = SceneConfig.model_validate(data) + + dummy_mi = MagicMock() + dummy_mi.variant.return_value = "scalar_rgb" + dummy_scene = MagicMock() + + with patch.dict("sys.modules", {"mitsuba": dummy_mi}): + # build_scene は baker.py 内で遅延 import されるため元モジュールをモックする + # (from sm_tias_lightsim.scene.builder import build_scene) + with patch( + "sm_tias_lightsim.scene.builder.build_scene", + return_value=({"type": "scene"}, "/tmp/fake"), + ): + with patch.object(dummy_mi, "load_dict", return_value=dummy_scene): + with patch("shutil.rmtree"): + with patch( + "sm_tias_lightsim.visualize.baker.log_variant_status" + ) as mock_log: + with patch( + "sm_tias_lightsim.visualize.baker._bake_direct", + return_value={}, + ): + with caplog.at_level( + logging.INFO, logger="sm_tias_lightsim.visualize.baker" + ): + try: + baker_module.bake_irradiance( + config, + Path("/tmp"), + mode="direct", + ) + except Exception: + pass + + # log_variant_status が呼ばれたこと(_variant_set_succeeded == True のとき) + mock_log.assert_called() + + +# --------------------------------------------------------------------------- +# D3: _set_variant_once('llvm_ad_rgb') で ensure_drjit_libllvm_path が呼ばれる(Speed-2 前準備) +# --------------------------------------------------------------------------- + + +class TestSetVariantOnceLlvmCallsEnsurePath: + """D3: _set_variant_once('llvm_ad_rgb') で ensure_drjit_libllvm_path が呼ばれること. + + 仕様(Speed-2 wavefront 化前準備): + llvm_* variant 要求時は DRJIT_LIBLLVM_PATH を自動探索して設定する. + scalar_* / cuda_* は LLVM-C.dll 不要なので探索しない. + """ + + def test_llvm_variant_calls_ensure_drjit_libllvm_path(self) -> None: + """D3: _set_variant_once('llvm_ad_rgb') のとき ensure_drjit_libllvm_path が呼ばれること. + + 仕様: + Speed-2 wavefront 化用の前準備: + llvm_* variant の場合は DRJIT_LIBLLVM_PATH を自動探索する. + """ + dummy_mi = MagicMock() + dummy_mi.variant.return_value = None # 未設定 → set_variant 分岐に入る + + with patch.dict("sys.modules", {"mitsuba": dummy_mi}): + with patch("sm_tias_lightsim.visualize.baker.ensure_drjit_libllvm_path") as mock_ensure: + with patch("sm_tias_lightsim.visualize.baker.log_variant_status"): + _set_variant_once("llvm_ad_rgb") + + mock_ensure.assert_called_once() + + def test_scalar_variant_does_not_call_ensure_drjit_libllvm_path(self) -> None: + """D3: _set_variant_once('scalar_rgb') のとき ensure_drjit_libllvm_path が呼ばれないこと. + + 仕様: scalar_* / cuda_* は LLVM-C.dll 不要なので探索しない. + """ + dummy_mi = MagicMock() + dummy_mi.variant.return_value = None + + with patch.dict("sys.modules", {"mitsuba": dummy_mi}): + with patch("sm_tias_lightsim.visualize.baker.ensure_drjit_libllvm_path") as mock_ensure: + with patch("sm_tias_lightsim.visualize.baker.log_variant_status"): + _set_variant_once("scalar_rgb") + + mock_ensure.assert_not_called() + + def test_llvm_variant_ensure_called_before_set_variant(self) -> None: + """D3: ensure_drjit_libllvm_path は mi.set_variant より前に呼ばれること. + + 仕様: mitsuba の import 前に os.environ を設定することが必須. + """ + dummy_mi = MagicMock() + dummy_mi.variant.return_value = None + call_order: list[str] = [] + + def ensure_side_effect() -> None: + call_order.append("ensure") + + def set_variant_side_effect(v: str) -> None: + call_order.append(f"set_variant:{v}") + + dummy_mi.set_variant.side_effect = set_variant_side_effect + + with patch.dict("sys.modules", {"mitsuba": dummy_mi}): + with patch( + "sm_tias_lightsim.visualize.baker.ensure_drjit_libllvm_path", + side_effect=ensure_side_effect, + ): + with patch("sm_tias_lightsim.visualize.baker.log_variant_status"): + _set_variant_once("llvm_ad_rgb") + + if "ensure" in call_order and any("set_variant" in c for c in call_order): + ensure_idx = call_order.index("ensure") + set_variant_idx = next(i for i, c in enumerate(call_order) if "set_variant" in c) + assert ensure_idx < set_variant_idx, ( + f"ensure は set_variant より先に呼ばれること: {call_order}" + )