🦔

C++ ライブラリ libsparseir の開発記録(他言語サポート編)

に公開

前置き

本記事は sparse-ir と呼ばれる Python, Julia で書かれたライブラリを C++ に移植した libsparseir の開発で得た知見を共有するために執筆されました.本記事は C++ への移植編 の続きです.2025/3月ごろには概ね SparseIR.jl が持っていた機能を C++ へ移植することができました.本記事では2025/9 月までの進捗を共有します.

C-API を利用した多言語サポート

ここでの多言語サポートというのは Julia, Python, Fortran ユーザから C++ ライブラリ libsparseir が持っている機能を呼び出すように整備することを指しています.

C-API の具体的なソースコードは下記のようになっています.

https://github.com/SpM-lab/libsparseir/blob/v0.6.0/src/cinterface.cpp

概ね次のようになっています.

extern "C" {
    // コンストラクタ
    // 戻り値は C++ オブジェクトをラップした struct へのポインタ
    spir_kernel* spir_logistic_kernel_new(double lambda, int* status){
        // 略
    }
    // spir_kernel に関する計算は計算が成功・失敗したかに関する
    // ステータスコードを戻り値とする.
    // API を呼び出すことで xmin, xmax, ymin, ymax などが更新される
    int spir_kernel_domain(const spir_kernel *k, double *xmin, double *xmax,
                           double *ymin, double *ymax){
        // 略
    }
    // その他いろいろ
}

spir_kernel は下記のリンクにあるIMPLEMENT_OPAQUE_TYPE マクロを使って定義されたOpaque typeです.例えば spir_kernel であれば下記の実装に対応します:

IMPLEMENT_OPAQUE_TYPE(kernel, sparseir::AbstractKernel)

これによりリソースの解放をする spir_kernel_release などの関数を自動的に定義してくれます.

https://github.com/SpM-lab/libsparseir/blob/v0.6.0/src/cinterface_impl/opaque_types.hpp

Julia, Python, Fortranなどの 開発者に対してはこの C-API を使って libsparseir の機能を呼び出すように要請します.各言語の開発者はライブラリユーザが C-API で生成されたオブジェクトを意識しなくても良いように各言語で持っている struct, class などでラップして機能を使ってもらうように設計します.

Python の場合

C ライブラリとのやりとりは ctypes を利用しています.基本的な部品は Cursor で作らせ,細かい部分は人間がデバッグしながら引数,戻り値を調整しています.今思えば,clang 関係の機能でヘッダーから自動生成する仕組みをもっと頑張っても良かった気がしますが,人力でなんとかなってるので良いとします.

https://github.com/SpM-lab/libsparseir/blob/v0.6.0/python/pylibsparseir/core.py にある実装を一部持ってきましょう.下記は C-API spir_logistic_kernel_new を利用する例です.

# pylibsparseir パッケージ
# C-API を管理する薄いラッパーパッケージ

import ctypes
_lib = ctypes.CDLL("path/to/sharedlib")

# Kernel functions
_lib.spir_logistic_kernel_new.argtypes = [c_double, POINTER(c_int)]
_lib.spir_logistic_kernel_new.restype = spir_kernel

def logistic_kernel_new(lambda_val):
    """Create a new logistic kernel."""
    status = c_int()
    kernel = _lib.spir_logistic_kernel_new(lambda_val, byref(status))
    if status.value != COMPUTATION_SUCCESS:
        raise RuntimeError(f"Failed to create logistic kernel: {status.value}")
    return kernel

Python 開発者は LogisticKernel クラスを実装し,コンストラクタで上記のlogistic_kernel_new 関数を呼び出しています.

# libsparseir リポジトリが管理する C-API をラップした薄いパッケージ
from pylibsparseir.core import _lib
from pylibsparseir.core import logistic_kernel_new

class LogisticKernel(AbstractKernel):
    def __init__(self, lambda_):
        """Initialize logistic kernel with cutoff lambda."""
        self._lambda = float(lambda_)
        self._ptr = logistic_kernel_new(self._lambda)

    def __del__(self):
        """Clean up kernel resources."""
        if hasattr(self, '_ptr') and self._ptr:
            _lib.spir_kernel_release(self._ptr)

具体的ない実装は下記を参照すること:

https://github.com/SpM-lab/sparse-ir/blob/aa095eddf9d2b836eee964d100d442591aa7fa29/src/sparse_ir/kernel.py#L14-L74

パッケージマネージャは uv を利用し,C/C++ライブラリのビルドは scikit-build にまかせています.

https://github.com/SpM-lab/libsparseir/blob/v0.6.0/python/pyproject.toml

PyPI への登録は v0.6.0 のようにタグがついた時のイベントをトリガーとするワークフローファイルを構築し自動化できる部分は自動化しています.さまざまな OS に対応するため,cibuildwheel を使ってパッケージングしています.

参考: https://github.com/SpM-lab/libsparseir/blob/main/.github/workflows/PublishPyPI.yml

その他,Anaconda を使う人々向けにもサポートするためにワークフローが作られています.

https://github.com/SpM-lab/libsparseir/blob/main/.github/workflows/conda.yml

Julia の場合

C の資源を呼び出すには ccall 関数を利用します.Clang.jl を使うことで ccall を直接使う部分は自動生成できます.

https://github.com/SpM-lab/SparseIR.jl/blob/main/src/C_API.jl

自動生成するためのスクリプトは下記のリンク先にあります.

https://github.com/SpM-lab/SparseIR.jl/tree/main/utils

このスクリプトは Clang.jl に依存しています.このパッケージを利用することで sparseir.h を読み込んで薄いラッパーモジュールを作ることができます.

pylibsparseir に相当する薄いラッパーパッケージは libsparseir_jll に対応します.これを作るためには JuliaPackaging/Yggdrasil に対して build_tarballs.jl というビルドスクリプトを追加するためのプルリクを作る必要があります.

新規追加 PR: https://github.com/JuliaPackaging/Yggdrasil/pull/12027
バージョンを更新する PR: https://github.com/JuliaPackaging/Yggdrasil/pull/12161

build_tarballs.jl の更新・プッシュはオレオレスクリプト https://github.com/SpM-lab/libsparseir/tree/main/julia/YggdrasilCommitHelper.jl が担当しています.

バージョンを更新するための PR は手動で作っています.ここら辺が自動化する仕組みが Julia 本家から提供されると嬉しいですね.

Julia 開発者は下記のように構造体で C のリソースをラップして管理しています.

mutable struct LogisticKernel <: AbstractKernel
    ptr::Ptr{spir_kernel}
    Λ::Float64

    function LogisticKernel(Λ::Real)
        Λ  0 || throw(DomainError(Λ, "Kernel cutoff Λ must be non-negative"))
        status = Ref{Cint}(-100)
        ptr = spir_logistic_kernel_new(Float64(Λ), status)
        status[] == 0 || error("Failed to create logistic kernel")
        kernel = new(ptr, Float64(Λ))
        finalizer(k -> spir_kernel_release(k.ptr), kernel)
        return kernel
    end
end

finalizer を使うパターンは https://docs.julialang.org/en/v1/base/base/#Base.finalizer を参考にしています.

本当はトテモツライ

さらっと書いていますが,多言語対応する工数も C++ ライブラリを作る工数ぐらい時間がかかっています.C-API では多次元配列を入出力としてやりとりする API があり,多次元配列の受け渡しを適切に実装する必要がありました.例えば下記のような項目があります:

  • 配列の長さ,形状,次元の情報を渡す.
  • 呼び出しがわで連続したメモリレイアウトを持たすようにする
  • メモリレイアウトは row/colum-major な言語で異なるのでそれ用のモードで計算をするためのフラグを設定する
  • C-API で受け取った配列を C++ の内部API が理解できる形に直す (Eigen が理解できる形に直す)

Python 向けにパッケージングするために pyproject.toml, setup.py, CMakeLists.txt Publish.yml などをいろいろセットアップする必要がありました.LLM の力を借りてなんとかなっていますが,お膳立てするためにやることが多すぎます.

Julia はこの点では多少楽ではありますが,Yggdrasil に登録するためにローカルでテストをするなど手間・時間は無視できないです.

執筆者はLLMの力を借りつつ Julia, Python <-> C/C++ のブリッジを担当しました.C++のライブラリを整備する上でソフトウェア開発者として良い経験にはなりました.が,libsparseir は計算物理のためのパッケージでした.日々,計算機を通して物理学に向かい合う彼ら(=学生・研究者)が,メンテナンス・新規機能開発のために,今後も含め私が行った体験を同様にすべきなのかというといろいろ思うところはあります.

ちなみに直近では to be continued in Rust! な動きもあります.sparse-ir プロジェクトのバックエンドが C++ から Rust に変わった時にどれだけ苦労するのか・楽になるのか気になるところです 👀.

Discussion