5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MesonによるCythonコンパイル

Last updated at Posted at 2023-03-23

はじめに

自分は普段、Pythonのlibrary, package製作をしているのですが、その際Cythonを多く用いています。
特に.pyxファイルにcython言語で記述しているのですが、それらをbuildする際、今まではsetup.pyファイルにcythonsetuptools(内部ではdistutils)を用いてコンパイルし、shared library (.soファイルや.pydファイル)を生成していました。
しかし、PEP517PEP518, PEP621で記述されているとおり、pyproject.tomlにbuildやmetadataの設定をすることが推奨されており、さらに、distutilsがPython 3.10でdeprecateされ、3.12では完全に削除される予定です(PEP632)。
実際、pythonのscipyversion1.9.0からmesonを用いたbuild systemに移行しています1
(要参考: https://labs.quansight.org/blog/2021/07/moving-scipy-to-meson)

しかし、mesonのドキュメントは例文が少ないためわかりにくく、未だに日本語での解説も少ないため備忘録としてPython+Cython libraryにおける設定の仕方をここにまとめておこうと思います。

Mesonとは

Meson is an open source build system meant to be both extremely fast, and, even more importantly, as user friendly as possible.
The main design point of Meson is that every moment a developer spends writing or debugging build definitions is a second wasted. So is every second spent waiting for the build system to actually start compiling code.
-- https://mesonbuild.com 引用

つまり高速でコンパイルできるbuildシステムを提供してくれるツールです。CMakeといったツールと近いみたいです。自分はC++を扱ったことがほとんどないのでそのあたりには詳しくないです...
Meson自体はPythonで記述されており、内部ではNinjaというライブラリ動いて、コンパイルを最適化してくれるみたいです。
(この方が詳しく書かれています: https://qiita.com/turenar/items/c727834fbf701beb47ef)

テストPython library構成

ソースコードについてはGitHubにて公開しております: https://github.com/munechika-koyo/test_meson

.
├── pyproject.toml
├── setup.py  <------------- setuptools developモードインストール用
├── dev.py   <-------------- 様々なbuild用CLI
├── meson.build
└── test_meson  <----------- ソースディレクトリ
    ├── __init__.py
    ├── meson.build
    ├── cmain.pyx
    ├── main.py
    ├── lib1  <------------- サブパッケージ1
    │   ├── __init__.pxd  <- module.pxyを他のcythonコードでも使用するために必要
    │   ├── __init__.py
    │   ├── meson.build
    │   ├── module.pxd  <--- TestClass1を他のクラスで継承するために必要
    │   └── module.pyx  <--- TestClass1の定義など
    └── lib2  <------------- サブパッケージ2
        ├── __init__.py
        ├── meson.build
        └── module.pyx  <--- TestClass1を継承したTestClass2の定義

テスト環境

OS: Ubuntu 22.04.2

Python環境

  • Python 3.12.0
  • Cython 3.0.6
  • meson 1.3.0
  • ninja 1.11.1
  • pip 23.3.1
  • build 0.7.0
  • meson-python 0.15.0

Cコンパイラ関連

  • gcc 11.4.0
  • pkg-config 1.8.0

conda環境にて上記を実現していますが、他の環境(pyenvなど)でも実現できると思います。

まず特徴として、ルート階層とソースコードのスクリプトファイル(*.py, *.pyx)がある階層にすべてにmeson.buildファイルが置かれていることです。
ここに、cythonやC言語によるコンパイル方法やincludeパス、dependency、ビルド済み配布物に含めるソースファイルなど全てを記述していきます。これが非常に冗長的で面倒な点ですが、逆にいうと非常に厳密的であると言えると思います。以下にそれぞれのmeson.buildファイルで何を記述しているか説明していきます。

meson.buildファイルの内容

./meson.buildの内容

まずルート階層にあるmeson.buildファイルは以下の内容です:

project(
  'test-meson',  # project名 (普通はpythonのパッケージ名と同じ)
  # === 以下に使用するプログラミング言語を列挙する ===
  'c',  # cythonを使う場合はcコンパイラも用いるため、列挙しといた方がよい。
  'cython',
  # ===
  version: '0.1',  # パッケージバージョン (ファイルに記述してある場合はそれを指定することも可能)
  meson_version: '>= 1.3',
  default_options: [  # ここにコンパイルする際、必ず使用するoptionを'key=value'で指定
    'cython_args=-3',  # cythonのコンパイルオプション
    'c_std=c99',  # 使用するC言語規格 - c11にするとwindowsでcomplexに関するエラーが起こる
    'buildtype=debugoptimized',  # mesonのbuild-in オプション -Ddebug=true -Doptimization=2と等価
  ]
)

# filesystemモジュール - ファイルをbuildディレクトリにコピーするのに用いる
fs = import('fs')

# Pythonモジュール - pythonプログラムを実行したりするのに用いる
py = import('python').find_installation(pure: false)  # pure: false でplatform依存のwheelを作成できる

# ======================================================================
# 以下にコンパイラに必要な外部ライブラリ(external dependency)について定義する
# ======================================================================
# Python dependency
py_dep = py.dependency()

# OpenMP dependency
omp_dep = dependency('openmp', required: true)

# NumPy dependency
incdir_numpy = run_command(
  py,  # Pythonでプログラムを実行
  [
    '-c',
    'import numpy; print(numpy.get_include())'
  ],
  check: true
).stdout().strip()
inc_np = include_directories(incdir_numpy)
np_dep = declare_dependency(include_directories: inc_np)  # includeディレクトリからdependencyを作成
# =======================================================================

# 次の下階層へ
subdir('test_meson')

まずproject()で基本的なビルドするソースに関する情報を載せます。
ここでdefault_optionsにcython2やc言語に関するコンパイルオプションを指定しています。c_stdbuidtypeはmesonのbuild-inオプションで、この2つのオプションはwindowsなどのOSでコンパイルする場合は必要なようです。
さらに、コンパイルの際に必要な外部ライブラリをdpendencyオブジェクトとして定義しています。これはmesonで便利なツールの一つで、このdepオブジェクトをコンパイル時の引数に指定することで自動的にリンク付けがされます。meson側で対応していないライブラリ(NumPyなど)はinclude_directories()関数からincludeディレクトリオブジェクトを作成し、そこからdepオブジェクトを作成できます。

また、run_command()関数からPythonなどプログラムを直接実行することもできます。上ではNumPyのメソッドを用いてincludeパスの文字列を取得しています。

最後にsubdir関数を用いて下の階層に移動しています。

./test_meson/meson.buildの内容

# local modules for .pyx file
pyx_files = [
  ['cmain', 'cmain.pyx'],  # [モジュール名, ファイル名]のリスト
]

# for文でpythonの外部モジュールをファイルごとに指定。ここで cython-> c -> .so/.pyd という流れを自動で行ってくれる。
foreach pyx_file : pyx_files
  py.extension_module(
    pyx_file[0],  # モジュール名
    pyx_file[1],  # ファイル名
    c_args: [numpy_nodepr_api],
    dependencies: [py_dep, np_dep],  # コンパイラに必要な外部ライブラリをここで指定する
    install: true,  # install_dir 先にインストールする
    install_dir: py.get_install_dir() / 'test_meson',  # .so/.pydファイルをインストールするディレクトリパスを指定
  )
endforeach

# pure pythonのソースコード
python_sources = [
    '__init__.py',
    'main.py',
]

# インストールする際のソースコードの指定 ビルド済み配布物に含める場合に必要
py.install_sources(
  python_sources,
  subdir: 'test_meson',  # インストールする際のディレクトリパス
)

# move to subdirectories
subdir('lib1')
subdir('lib2')

ここから本格的にcythonファイルのコンパイル(shared object作成等)の命令を記述していきます。
pyx_filesにモジュール名と元となるモジュールファイル名との対応を配列として静的に記述し、foreachpy.extension_moduleを用いて記述しています。このforeach文を用いたコンパイル法は上部で要参考として載せたサイトを参考にさせていただきました。
引数のdependencyに先ほど定義したdepオブジェクトをリスト形式で指定しています。必要となるライブラリだけでよいので、ここではomp_depは指定していません。

また、py.install_sources().pyファイルを指定しているのは、今後wheelなどのビルド済み配布物を制作する際に含める必要があるからです。

./test_meson/lib1/meson.buildの内容

# モジュール名と対応する.pxdファイルをbuildディレクトリにコピーし、dependecyとして宣言する
_cython_tree = declare_dependency(sources: [
  fs.copyfile('__init__.pxd'),
  fs.copyfile('module.pxd'),
])

pyx_files = [
  ['module', 'module.pyx'],
]

foreach pyx_file : pyx_files
  py.extension_module(
    pyx_file[0],
    pyx_file[1],
    c_args: [numpy_nodepr_api],
    dependencies: [py_dep, omp_dep, np_dep, _cython_tree],  # omp_depと_cython_treeを追加
    install: true,
    install_dir: py.get_install_dir() / 'test_meson/lib1',  # install先パスが異なることに注意
  )
endforeach

python_sources = [
    '__init__.py',
    '__init__.pxd',  # .pxdファイルは配布物として追加する
    'module.pxd',
]

py.install_sources(
  python_sources,
  subdir: 'test_meson/lib1',  # 同じくパスが異なることに注意
)

ここのサブパッケージにはcythonソースが記述されたmodule.pyxと、様々な宣言が記述されたmodule.pxdファイルがあります。ここで重要なのは、.pxdファイルもfs.copyfile()を用いて、buildディレクトリにコピーし、depオブジェクトとして宣言することです。コピーすることで、他のモジュールがこのlib1.moduleに依存していたとしてもcython側で適切に紐づけることができる様です。
また、py.extension_module()におけるdependency引数にomp_dep.pxdファイルから宣言された_cython_treeも加えられています。

さらに、install_dirをソースファイルがある階層と同じにします。py.install_sourcesにおけるsubdirも同様です。

cythonソースコードの注意点

cythonのコーディングにおいて気をつける点は 内部モジュールをcimportする際は必ず相対インポートをすることです。
例としてtest_meson/lib2/module.pyxの一部分を表示します:

from libc.stdio cimport printf

from ..lib1.module cimport TestClass1


cdef class TestClass2(TestClass1):
            :
            :

ここではtest_meson/lib1/module.pyxで定義してあるTestClass1を相対cimportしています。

mesonビルド方法

mesonninjaをインストールし、ルート階層でまず次のコマンドをコマンドラインで実行し、mesonをセットアップします:

$ meson setup build  # "build"ディレクトリに設定

成功すると標準出力に次のような内容が記述されます:

The Meson build system
Version: 1.3.0
Source dir: /home/koyo/Documents/test_meson
Build dir: /home/koyo/Documents/test_meson/build
Build type: native build
Project name: test-meson
Project version: 0.1
C compiler for the host machine: cc (gcc 11.4.0 "cc (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0")
C linker for the host machine: cc ld.bfd 2.38
Cython compiler for the host machine: cython (cython 3.0.6)
Host machine cpu family: x86_64
Host machine cpu: x86_64
Program python3 found: YES (/home/koyo/.conda/envs/test-meson/bin/python3.12)
Found pkg-config: YES (/usr/bin/pkg-config) 1.8.0
Run-time dependency python found: YES 3.12
Run-time dependency OpenMP found: YES 4.5
Build targets in project: 5

Found ninja-1.11.1 at /home/koyo/.conda/envs/test-meson/bin/ninja

ここで、Cコンパイラやリンカ、depndencyが見つからないとエラーを吐きます。その場合は適切に環境変数(CCPATHなど)を設定してください。やり直す場合は --wipeオプション付けてmeson setupを再び実行します。
適切にmesonのセットアップが完了した場合は次のようにコンパイラを実行します:

$ meson compile -C build

結果は以下の通りです:

INFO: autodetecting backend as ninja
INFO: calculating backend command to run: /home/koyo/.conda/envs/test-meson/bin/ninja -C /home/koyo/Documents/test_meson/build
ninja: Entering directory `/home/koyo/Documents/test_meson/build'
[11/11] Linking target test_meson/lib1/module.cpython-312-x86_64-linux-gnu.so

-jオプションでコア数を指定すると並列に処理してくれます。
コンパイルが成功すると./buildディレクトリ以下に.soファイルや様々な設定ファイルなどが作成されます。

これでmesonによるコンパイルは完了ですが、pythonからimport文で呼び出すには.buildにもパスを通す必要があり、少し面倒です。そこで作成された.so/.pydファイルをもとのソースツリー内にコピーしてあげましょう。その時のpythonコード例は以下の通りです:

from pathlib import Path
import sys
import shutil

BUILD_DIR = Path("build")

ext = ".pyd" if sys.platform == "win32" else ".so"
for so_path in BUILD_DIR.glob(f"**/*{ext}"):
    src = so_path.resolve()
    dst = so_path.relative_to(BUILD_DIR)
    shutil.copy(src, dst)
    print(f"copy {src} into {dst}")

カレントディレクトリがルート階層の場合のコードです。

pyproject.tomlの設定

pythonパッケージとして成立させるためにはpyproject.tomlファイルを作成し、そこに必要最小限のビルド設定を載せることがPEP518で決められています。今回のテストライブラリにおけるpyproject.tomlの設定の一部は以下のとおりです。

[build-system]
build-backend = "mesonpy"
requires = ["cython >= 3.0", "numpy", "meson-python", "ninja >= 1.8.2"]

[project]
name = "test-meson"
description = "Test for Meson with Cython"
version = "0.1"
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.9"
dependencies = ["numpy"]

ここで重要なのはbuild-backendとして"mesonpy"つまりmeson-pythonを利用している点です。このバックエンドはmesonシステムをpythonパッケージで利用するためのパッケージで現在なお開発が進められています。
また、ビルドの際必要となるパッケージはrequiresとして予め登録しておく必要があります。これらのパッケージはビルド時に必要になるだけなので、ビルド時独立環境の中にのみインストールされ、ビルドが終わると削除されます。通常利用環境で依存パッケージがある場合は[project]テーブルのdependenciesに記述しましょう。

ビルド済み配布物の作成

ビルド済み配布物、いわゆるbdistやsdistを作成する際は、PyPAが提供しているbuildというビルドフロントエンドツールを使用するのが最も簡単です。使用する際は次のようにコマンドを実行します。

$ python -m build

実行が完了するとルート階層にdistというディレクトリが作成され、その下にビルド済み配布物が作成されます。

.
├── dist
│   ├── test_meson-0.1-cp312-cp312-linux_x86_64
│   └── test_meson-0.1.tar.gz

wheelファイルの中身をlessコマンドを利用して見ると、

前半省略
Archive:  dist/test_meson-0.1-cp312-cp312-linux_x86_64.whl
 Length   Method    Size  Cmpr    Date    Time   CRC-32   Name
--------  ------  ------- ---- ---------- ----- --------  ----
    3300  Defl:N     1435  57% 2023-12-03 08:52 421158e4  test_meson-0.1.dist-info/METADATA
      88  Defl:N       85   3% 2023-12-03 08:52 8d43aa0c  test_meson-0.1.dist-info/WHEEL
    1071  Defl:N      632  41% 2023-11-14 06:00 028f879f  test_meson-0.1.dist-info/LICENSE
   53528  Defl:N    17186  68% 2023-12-03 08:52 5f2369a9  test_meson/cmain.cpython-312-x86_64-linux-gnu.so
  260488  Defl:N   104417  60% 2023-12-03 08:52 393f4ffe  test_meson/lib1/module.cpython-312-x86_64-linux-gnu.so
  100792  Defl:N    37501  63% 2023-12-03 08:52 fbf1c06d  test_meson/lib2/module.cpython-312-x86_64-linux-gnu.so
      22  Defl:N       24  -9% 2023-11-14 06:00 dce6f0a3  test_meson/__init__.py
     356  Defl:N      220  38% 2023-11-14 06:00 774030ef  test_meson/main.py
      22  Defl:N       24  -9% 2023-11-14 06:00 40344f1a  test_meson/lib1/__init__.py
      22  Defl:N       24  -9% 2023-11-14 06:00 b2f6c4ff  test_meson/lib1/__init__.pxd
     280  Defl:N      172  39% 2023-11-14 06:00 2563b6d8  test_meson/lib1/module.pxd
      57  Defl:N       51  11% 2023-11-14 06:00 0d7ef4b7  test_meson/lib2/__init__.py
    1110  Defl:N      623  44% 2023-12-03 08:52 9f8e2634  test_meson-0.1.dist-info/RECORD
--------          -------  ---                            -------
  421136           162394  61%                            13 files

となっており、meson.buildで指定したファイルと.soファイルが梱包されていることがわかります。

editableインストールの活用

ライブラリ開発を行う場合はpip install -e .というeditableモードでのパッケージインストールが便利です。
これはソースコードを普通のインストール先(多くは../lib/python{version}/site-packageディレクトリ)にインストールすることなく、python実行の際にソースコードへパスを通してくれるインストール法です。詳細はPEP660に載っています。
meson-pythonではv0.13以降でeditableインストールに対応し、以下のコマンドで可能です:

$ python -m pip install --no-build-isolation --editable .

このコマンドではpythonimportするたびに変更があればコンパイルされ、.soファイルが./build/cp12/以下に配置され、パスが通るようになります。

詳しくはmeson-pythonドキュメントをご覧ください。

dev.py CLIの活用

毎回、meson compile.soファイルのコピーを手打ちで実行するのは面倒なので、CLIを作成して楽にしましょう。
scipyのdev.py CLIを参考にして次のようなdev.pyを作成しました。

"""
Developer CLI: building (meson) sources in place, document, etc.
"""
import os
import shutil
import subprocess
import sys
from pathlib import Path

import rich_click as click

BASE_DIR = Path(__file__).parent.resolve()
BUILD_DIR = BASE_DIR / "build"
SRC_PATH = BASE_DIR / "test_meson"
ENVS = dict(os.environ)
N_CPUs = os.cpu_count()


@click.group()
def cli():
    """Developer CLI: building (meson) sources in place, document, etc."""
    pass


@cli.command()
@click.option("--build-dir", default=str(BUILD_DIR), help="Relative path to the build directory")
@click.option(
    "-j",
    "--parallel",
    default=N_CPUs,
    show_default=True,
    help="Number of parallel jobs for building.",
)
def build(build_dir, parallel):
    """Build & Install package using Meson build tool.
    \b
    ```python
    Examples:
    $ python dev.py build
    ```
    """
    # === setup build ===============================================
    cmd = ["meson", "setup", build_dir]
    if Path(build_dir).exists():
        cmd += ["--wipe"]
    click.echo(" ".join([str(p) for p in cmd]))
    ret = subprocess.call(cmd, env=ENVS, cwd=BASE_DIR)
    if ret == 0:
        print("Meson build setup OK")
    else:
        print("Meson build setup failed!")
        sys.exit(1)

    # === build project =============================================
    cmd = ["meson", "compile", "-C", build_dir, "-j", str(parallel)]
    click.echo(" ".join([str(p) for p in cmd]))
    ret = subprocess.call(cmd)

    if ret == 0:
        print("Build OK")
    else:
        print("Build failed!")
        sys.exit(1)

    # === install .so/.pyd files in source tree ==========================
    ext = ".pyd" if sys.platform == "win32" else ".so"
    for so_path in BUILD_DIR.glob(f"**/*{ext}"):
        src = so_path.resolve()
        dst = BASE_DIR / so_path.relative_to(BUILD_DIR)
        shutil.copy(src, dst)
        print(f"copy {src} into {dst}")
    print("Install .so files in place.")

rich_clickを用いてCLIを作成しています。ビルドを実行する際はpython dev.py buildとコマンド実行すれば良いです。

mesonビルドで注意すべき点

name-space パッケージのビルド

次のような__init__.pyファイルが最初の階層に無いパッケージのことをnamespaceパッケージと呼び、トップのライブラリ名を共通として複数のサブパッケージを用意する際に用いられます。

package/
    subpackageA/
        __init__.py
        modules.py
    subpackageB/
        __init__.py
        modules.py

この場合、cython v3.0以降では標準で対応しているのですが、mesonを用いた場合ではC言語ファイルを作成する際にトップのpackageを認識できないようです。
ですので、この場合はdev.py CLIにpackage/__init__.pyを作成した上でビルドし、その後削除するようなコードを作成する応急手当が必要となるようです。

最後に

meson + cython の pythonパッケージは、numpyscipyscikit-imageなどが採用していることから今後ますます利用されていくと思います。その時、この資料が少しでも皆様の参考になれば幸いです。

  1. https://github.com/scipy/scipy/issues/13615

  2. https://cython.readthedocs.io/en/latest/src/userguide/parallelism.html#compiling

5
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?