https://github.com/bash0C7/picoruby-multicore
PicoRuby で動かす楽器や装置を作っていると、重い計算を挟みたくなる場面が出てきます。
たとえば音を作る途中で少し重いループを回したい、といったケースです。ところが
PicoRuby の Task は協調的なマルチタスクなので、ひとつの Task が計算に専念している
間は同じコア上の他の Task が止まってしまいます。ESP32 や RP2350 (Pico 2 W) のような
マイコンは物理的に複数のコアを持っているので、この重い計算だけをもう片方のコアに
逃がせないか、と考えたのが picoruby-multicore のきっかけです。
Task との使い分け
Task と Multicore は競合するものではなく、役割が違います。
Task: 同じコアの中で、I/O 待ちやイベント待ちの間に他の処理へ切り替える協調的な
マルチタスク。ボードの入出力やタイミング制御はこちらの領分ですMulticore: 計算そのものを、もう片方の物理コアへ丸ごと渡してしまう仕組み。呼び出した
側の Task はブロックされず、他の Task は普段どおり動き続けます
つまり、ボードの全体の制御や入出力は Task で組み立てつつ、その中で「これは重いので
待たされたくない」という計算だけを Multicore.run に投げる、という組み合わせで使います。
ワークフロー
picoruby-multicore で一番こだわったのは、別コアで動くコードも Ruby だけで書けることです。
別コアにやらせる計算のことを、picoruby-multicore は kernel と呼びます (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.h、src/*.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-multicore の ports/<board>/multicore.c (board の SDK を直接使うので、
conf.gem だけでは firmware に取り込まれません) と、手順 3 の kernels_registry.c を、
自分の firmware の C ソース一覧に足します。kernels_registry.c が lcg.h を include
できるよう、picoruby-lcg/include を include path にも足します。書く場所は board の
build の仕組み次第です (ESP-IDF なら idf_component_register の SRCS /
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::TypeError や Multicore::RangeError のような例外になります。
今の状態
ESP32 (M5Stack Chain DualKey) では実機で動作確認できています。
RP2350 (Pico 2 W) は現在調査中で、まだ実機での確認は取れていません。
picoruby-multicore は GitHub で公開しています。
https://github.com/bash0C7/picoruby-multicore