PicoRubyでマルチコアを活用するpicoruby-multicoreというライブラリをつくっている

https://github.com/bash0C7/picoruby-multicore

PicoRuby で動かす楽器や装置を作っていると、重い計算を挟みたくなる場面が出てきます。
たとえば音を作る途中で少し重いループを回したい、といったケースです。ところが
PicoRuby の Task は協調的なマルチタスクなので、ひとつの Task が計算に専念している
間は同じコア上の他の Task が止まってしまいます。ESP32 や RP2350 (Pico 2 W) のような
マイコンは物理的に複数のコアを持っているので、この重い計算だけをもう片方のコアに
逃がせないか、と考えたのが picoruby-multicore のきっかけです。

Task との使い分け

TaskMulticore は競合するものではなく、役割が違います。

  • Task: 同じコアの中で、I/O 待ちやイベント待ちの間に他の処理へ切り替える協調的な
    マルチタスク。ボードの入出力やタイミング制御はこちらの領分です
  • Multicore: 計算そのものを、もう片方の物理コアへ丸ごと渡してしまう仕組み。呼び出した
    側の Task はブロックされず、他の Task は普段どおり動き続けます

つまり、ボードの全体の制御や入出力は Task で組み立てつつ、その中で「これは重いので
待たされたくない」という計算だけを Multicore.run に投げる、という組み合わせで使います。

ワークフロー

picoruby-multicore で一番こだわったのは、別コアで動くコードも Ruby だけで書けることです。
別コアにやらせる計算のことを、picoruby-multicorekernel と呼びます (GPU プログラミング
などで使われるのと同じ呼び方です)。裏では次の 3 つの道具が働きます。

  • spinel: 型付きの Ruby を native code に変換する AOT コンパイラ
  • suppify: spinel の出力を、そのまま PicoRuby の
    mrbgem として組み込める形にするラッパー
  • picoruby-multicore: 呼び出し引数と戻り値の byte 列への変換 (MessagePack)、コア間の
    受け渡し、失敗時の状態コードを例外に変換する Ruby API (Multicore.run / spawn / open)

自分の firmware に組み込むところまで、順番に追ってみます。前提として、対象の board 向けの
firmware build (build_config) がすでにあること、spinel
ビルド済みで SPINEL_LIB (または SPINEL / PATH) から見つかることが要ります (spinel 自身の
README 参照)。

1. kernel を書く

別コアにやらせたい計算を、型付きの普通の Ruby ファイルとして書きます。型は RBS で
書きます (def の上にインラインで書くか、同名の .rbs を横に置くか)。

# lcg.rb
# LCG (線形合同法) を n 回まわした値。重い job の見本 (n を大きくすると数秒かかる)。
#: (Integer) -> Integer
def lcg(n)
  seed = 1
  i = 0
  while i < n
    seed = (seed * 75 + 74) % 65537
    i += 1
  end
  seed
end

2. suppify で picoruby 向けの mrbgem にする

SPINEL_LIB=/path/to/spinel/lib ruby suppify.rb lcg.rb -o lcg -t picoruby

picoruby-lcg/ という、そのまま conf.gem gemdir: で組み込める mrbgem 一式 (mrbgem.rake
include/lcg.hsrc/*.c) ができます。中身は 1 つの MessagePack メッセージを受けて 1 つ
返す、C ABI のフラットな関数 lcg_lcg_call と、その型を返す lcg_lcg_signature です。

3. 呼び出し表を自分で書く

picoruby-multicore は kernel を自動では見つけません。「どの名前で呼んだら、どの関数を
呼ぶか」を対応づける小さな表 (multicore_kernels[]。構造体は
picoruby-multicore/include/multicore.h が定義) を、自分の firmware プロジェクトの中に
新しい .c ファイルとして自分で書きます
。ファイル名は自由です。ここでは
kernels_registry.c とします。

/* kernels_registry.c */
#include "lcg.h"

const multicore_kernel_t multicore_kernels[] = {
  { "lcg", lcg_lcg_call, lcg_lcg_signature, lcg_init },
  { NULL, NULL, NULL, NULL },
};

kernel を増やしたら、この配列に行を足していきます。

4. build_config に足す

自分の firmware の build_config (MRuby::Build.new do |conf| ... end を書いているファイル)
に、生成した mrbgem と picoruby-multicore 本体を足します。

MRuby::Build.new do |conf|
  # ... 自分の board 向けの既存の設定 ...

  conf.gem gemdir: "/abs/path/to/picoruby-lcg"
  conf.gem github: "bash0C7/picoruby-multicore"
end

5. C ソースを firmware の source 一覧に足す

picoruby-multicoreports/<board>/multicore.c (board の SDK を直接使うので、
conf.gem だけでは firmware に取り込まれません) と、手順 3 の kernels_registry.c を、
自分の firmware の C ソース一覧に足します。kernels_registry.clcg.h を include
できるよう、picoruby-lcg/include を include path にも足します。書く場所は board の
build の仕組み次第です (ESP-IDF なら idf_component_registerSRCS /
INCLUDE_DIRS、Pico SDK なら CMake の target_sources / target_include_directories)。
たとえば ESP-IDF なら、こう足します。

idf_component_register(
  SRCS
    # ... 既存の SRCS ...
    ${COMPONENT_DIR}/picoruby-multicore/ports/esp32/multicore.c
    ${COMPONENT_DIR}/kernels_registry.c
  INCLUDE_DIRS
    # ... 既存の INCLUDE_DIRS ...
    ${COMPONENT_DIR}/picoruby-lcg/include
  ...
)

6. いつもどおり firmware をビルドする

ここから先は、この board にいつも使っているビルドコマンドをそのまま実行するだけです。
新しい手順は増えません。

7. 呼び出す

require "multicore"

Multicore.run(:lcg, 100_000)
# => 40222

# 待たずに投げておいて、あとで結果を受け取ることもできます
job = Multicore.spawn(:lcg, 20_000_000)
# ここで他の処理を続けられます
job.value

引数や戻り値は Integer、Float、String、Symbol、nil、true / false、それらを入れ子にした
Array や Hash まで、普通の Ruby の値としてそのまま渡せます。byte 列への変換もコア間の
受け渡しも、呼び出す側からは完全に隠れています。失敗した場合も状態コードではなく
Multicore::TypeErrorMulticore::RangeError のような例外になります。

今の状態

ESP32 (M5Stack Chain DualKey) では実機で動作確認できています。
RP2350 (Pico 2 W) は現在調査中で、まだ実機での確認は取れていません。

picoruby-multicore は GitHub で公開しています。
https://github.com/bash0C7/picoruby-multicore