これは「Haskell Advent Calendar 2025」18日目の記事です。(期日に間に合っていなくてすみません。)
目的と困っていた課題、および解説の動機
本記事の目的は、Cabalの Setup.hs の仕組みを解説し、C/C++の外部ライブラリに依存するHaskellプロジェクトのビルドプロセスを改善する方法を共有することです。
私が開発に関わっている Hasktorch というパッケージでは、バックエンドに libtorch という外部ライブラリを使用しています。libtorchはPyTorchの基盤となるC++ライブラリですが、Haskellからの利用においてビルド周りに多くの課題を抱えていました。
主な課題は以下の通りです。
-
パッケージ管理の難しさ:
- OS標準のパッケージマネージャ(apt/brew/nix等)では、適切なバージョンのライブラリをインストールすることが困難です。
- libtorchは後方互換性が保証されていないため、Hasktorch側と厳密に一致するバージョンが必要です。
-
環境ごとの差異(配置、種類、リンカーオプション):
- GPU(CUDA)の有無でリンクすべきファイル名が異なる。
- macOS特有のリンカーオプションが必要になる。
- Nixのsandbox環境と通常の環境で、参照するファイルパスを動的に切り替える必要がある。
-
セットアップの複雑さと問い合わせの多さ:
- 環境差異が大きいため、READMEに手順を書いてもセットアップミスが頻発します。
- リンクやパス設定はローレベルな知識が必要なため、ユーザーにその重要性が伝わりにくいという問題がありました。
- 例:
LD_LIBRARY_PATHやDYLD_LIBRARY_PATHの設定漏れ。 - 例: 外部ライブラリのABI(C++11互換など)の不一致。
今回、Cabalのビルドプロセス内で環境を自動検出し、適切なライブラリを配置・リンク設定することで、これらの課題を解決しました。
本稿では、Cabalの Setup.hs やビルドの仕組みを概説した上で、具体的な解決手法について解説します。
CabalのSetup.hsについて
CabalのSetup.hs は、Cabalビルドシステムへのローレベルなインターフェースを提供します。通常 .cabal ファイルに記述する宣言的な設定だけでなく、Haskellコードを用いてビルドの各ステージにおける挙動を細かく制御(読み出し、書き換え、フック)することが可能です。
主なステージには以下のようなものがあります。
- configure: ビルド前の設定段階。コンパイラのチェックや外部ライブラリの検知・セットアップに利用します。
- build: コンパイルとリンクを行います。
- copy: ビルド成果物を所定の場所にコピーします。
- register: ビルドしたライブラリをパッケージデータベースに登録します。
- unregister: システムから登録を解除します。
- clean: ビルド成果物を削除します。
- test: テストスイートを実行します。
- haddock: ドキュメントを生成します。
- hscolour: ドキュメント生成時にソースコードの色付けを行います。
- bench: ベンチマークを実行します。
Distribution.Simple モジュールを使うことで、これらのステージに対して「実行前のフック」「実行時の挙動変更」「実行後のフック」を Setup.hs に記述できます。
解決策:Setup.hsでの自動セットアップの実装
Hasktorchでは、前述の「環境依存の激しさ」と「セットアップの手間」を解消するために、Setup.hs の confHook(configureステージのフック)を利用して、libtorchのダウンロードからパスの設定までを全自動化しました。
具体的に行ったアプローチは以下の3ステップです。
-
confHookのオーバーライド: 設定(Configure)段階で処理に割り込む。 - 環境検知とダウンローダー: OSやフラグに応じて適切なlibtorchを取得・配置する。
-
ビルド情報の書き換え: リンクオプション(
extraLibDirs等)を動的に注入する。
以下、それぞれの詳細を解説します。
1. フックの選択:confHook
Cabalには preBuild や postConf など様々なフックがありますが、外部ライブラリのパス設定を行うには confHook をカスタマイズするのが定石です。なぜなら、コンパイラやリンカーに渡すフラグはconfigureステージで決定され、LocalBuildInfo というデータ構造に保存されるからです。
基本形は以下のようになります。
main :: IO ()
main = defaultMainWithHooks simpleUserHooks
{ confHook = myConfHook
}
myConfHook :: (GenericPackageDescription, HookedBuildInfo) -> ConfigFlags -> IO LocalBuildInfo
myConfHook (gpd, hbi) flags = do
-- 1. ここでlibtorchのダウンロードや展開を行う
libPath <- setupLibtorch flags
-- 2. 標準のconfigure処理を実行して LocalBuildInfo (lbi) を取得
lbi <- confHook simpleUserHooks (gpd, hbi) flags
-- 3. lbi を書き換えて、ダウンロードしたライブラリのパス情報を注入する
let lbi' = updateBuildInfo lbi libPath
return lbi'
2. 外部ライブラリの検知とダウンロード
ユーザーが手動でライブラリをインストールする手間を省くため、Setup.hs 内で「必要なバージョンのlibtorchがあるか」をチェックし、なければダウンロード・解凍を行います。
ここでは Cabalのフラグ(.cabal ファイルで定義した flag cuda など)を読み取り、CPU版かCUDA版か、あるいはmacOS (Metal) 版かを分岐させます。
-- 擬似コード的なイメージ
setupLibtorch :: ConfigFlags -> IO FilePath
setupLibtorch flags = do
let useCuda = lookupFlag "cuda" flags -- フラグの確認
let osType = buildOS -- OSの検知
-- 適切なURLとファイル名を決定
let (url, filename) = case (osType, useCuda) of
(OSX, _) -> ("https://download.pytorch.org/...", "libtorch-macos-...")
(Linux, True) -> ("https://download.pytorch.org/...", "libtorch-cxx11-abi-...")
(Linux, False) -> ("https://download.pytorch.org/...", "libtorch-cpu-...")
-- ダウンロードとunzip (すでに存在すればスキップ)
unless (doesDirectoryExist expectedPath) $ do
download url filename
unzip filename
return expectedPath
これにより、ユーザーは cabal build -f cuda と打つだけで、背後で勝手に適切な巨大ライブラリがダウンロードされるようになります。Nixのサンドボックス内など、ネットワークが遮断されている環境のために「システム標準のパスを使う」という逃げ道も用意しておくと親切です。
3. LocalBuildInfoの書き換え(リンク情報の注入)
ここが最も重要なハックです。標準のconfigure処理で生成された LocalBuildInfo には、当然ながら私たちが勝手にダウンロードしたlibtorchのパス情報は含まれていません。
そこで、LocalBuildInfo の中の libraryConfig や executableConfig に含まれる extraLibDirs(ライブラリ検索パス)や includeDirs(ヘッダ検索パス)をプログラム中で書き換えます。
updateBuildInfo :: LocalBuildInfo -> FilePath -> LocalBuildInfo
updateBuildInfo lbi libtorchPath =
let
-- ヘッダファイルのパス
incDir = libtorchPath </> "include"
-- .so / .dylib のあるパス
libDir = libtorchPath </> "lib"
-- ビルド情報を更新する関数
updateBI bi = bi
{ extraLibDirs = libDir : extraLibDirs bi
, includeDirs = incDir : includeDirs bi
, extraLibs = ["torch", "c10", "torch_cpu"] ++ extraLibs bi
}
in
-- ライブラリと実行ファイル両方の設定を更新
lbi { localPkgDescr = updatePackageDescription (localPkgDescr lbi) }
このように動的に値を注入することで、.cabal ファイルにハードコードすることなく、環境ごとに異なるパスを柔軟に渡すことができます。
4. RPATHの設定(環境変数地獄からの解放)
「LD_LIBRARY_PATH の設定漏れで動かない」という問い合わせをなくすための最後の仕上げが RPATH(Run-time Search Path) の設定です。
共有ライブラリを利用する場合、ビルド時だけでなく実行時にもライブラリの場所を知っている必要があります。通常は環境変数で指定しますが、Setup.hs でリンカーオプションにRPATH(macOSでは @rpath や install_name)を焼き込むことで、環境変数なしで実行バイナリがライブラリを見つけられるようになります。
-- OSによって渡すオプションを変える
rpathFlags = if isOsx
then ["-Wl,-rpath," ++ libDir]
else ["-Wl,-rpath," ++ libDir, "-Wl,--disable-new-dtags"]
updateBI bi = bi
{ ldOptions = rpathFlags ++ ldOptions bi
-- ... (他の設定)
}
これにより、「ビルドは通ったけど実行時に error while loading shared libraries が出る」というあるある問題を根絶できました。
まとめ:Setup.hsは強力な武器になる
Setup.hs を build-type: Custom にしてフル活用することで、Hasktorchでは以下の成果を得られました。
-
UXの向上: ユーザーは
cabal buildするだけで、数GBあるC++ライブラリの準備からリンクまで完結するようになった。 - 問い合わせの激減: バージョン不整合やパス設定ミスによるビルドエラーが構造的に発生しなくなった。
-
環境差分の吸収: OSやGPU有無による差異をHaskellコード内で吸収し、
.cabalファイルをシンプルに保てた。
Haskellのビルドシステムは「難しい」と思われがちですが、Distribution.Simple が提供するAPIは非常に強力です。C言語バインディングや特殊なコード生成が必要なプロジェクトでは、ぜひ Setup.hs のカスタマイズに挑戦してみてください。