多段階計算(マクロ)

多段階計算(マクロ) #

mimium v3では、多段階計算という強力な機能が導入されました。これは、MetaOCamlScala 3のマクロSATySFiなどに影響を受けた機能で、型安全性を維持しながらコンパイル時にコードを生成するマクロを書くことができます。

概要 #

多段階計算は、計算を異なるステージ(段階)に分けて実行する仕組みです。mimiumでは主に以下の2つのステージがあります:

  • ステージ0(macro): コンパイル時に実行される
  • ステージ1(main): 実行時(オーディオ処理時)に実行される

この機能により、マクロとは言いつつも、計算体系自体はソースコードや構文木を操作するのではなく、直接ランタイムで走るコードと共通したラムダ計算に基づくものにできます。マクロ展開前に型検査と推論が完了することが最も大きな特徴です。

動機 #

オーディオ処理には、しばしば2つの異なるフェーズが含まれます:

  1. 計算グラフの構築(構造の決定)
  2. 計算グラフの実行(サンプル処理)

従来の方法では、パラメトリックなオーディオグラフを作るために高階関数をグローバル環境で実行してから使用する必要がありました。これは、高階関数が実行されるタイミングで初めて信号処理用のメモリが確保されるため、毎サンプル新しいプロセッサを生成する意味になってしまうからです。

多段階計算により、この問題をより自然で効率的に解決できます。

基本構文 #

多段階計算には以下の基本的な構文要素があります:

プリミティブ #

  1. クォート (`expr): 次のステージで評価される式をマークします
  2. スプライス ($expr): 前のステージから式を評価して挿入します
  3. マクロ呼び出し構文 (f!(args)): ${f(args)}のシンタックスシュガー

型システム #

ステージ0から見て次のステージで評価される型としてCode型が型システムに追加されます。例えば、float型の値をステージ1で評価する場合、ステージ0では `float型として扱われます。

使用例 #

基本的な例 #

#stage(macro)
fn mymacro(n:float) -> `float {
  `{ $n * 2.0 }
}

#stage(main)
fn dsp() {
  mymacro!(21)  // 結果は42.0
}

パラメトリックなフィルターバンク #

従来の高階関数を使った方法:

fn bandpass(x,freq){
    // バンドパスフィルタの実装
}

fn filterbank(n,filter_factory:()->(float,float)->float){
  if (n>0){
    let filter = filter_factory() 
    let next = filterbank(n-1,filter_factory)
    |x,freq| filter(x,freq+n*100) + next(x,freq)
  }else{
    |x,freq| 0
  }
}

let myfilter = filterbank(3,| | bandpass)  // ここで一度グラフ生成
fn dsp(){
    myfilter(input, 1000)
}

多段階計算を使った方法:

#stage(main)
fn bandpass(x, freq) {
  // バンドパスフィルタの実装...
}

#stage(macro)
fn filterbank(n, filter) {
  if (n > 0) {
      let newf = lift_f(freq + n*100)
    `{ 
        |x,freq| filter(x,$newf) + filterbank!(n-1, filter)(x, freq)
    }
  } else {
    `{ |x,freq| 0 }
  }
}

#stage(main)
fn dsp() {
  filterbank!(3, `bandpass)(input, 1000)
  // コンパイル時に展開されたフィルターバンクを生成
}

ここで、lift_f関数は新しい組み込みの関数で、ステージ0で計算した数を、ステージ1での数値リテラルに変換して埋め込む関数です。(複合型のliftについては現状未対応です)

累乗関数の生成 #

よりクラシックな多段階計算の例ですが、p^qのような階乗でqの方があらかじめ決まっている場合、例えばqが4なら、毎回実行時に再帰的チェックを行わずあらかじめp*p*p*pという関数を生成してしまうのが効率的です。この例をmimiumでも書くとこうなります。

#stage(macro)
fn genpower(n:float) -> `(float)->float {
    letrec aux = |n:float, x| {
        if (n > 1) {
            `{ $x * $(aux(n-1, x)) }
        } else {
            x
        }
    }
    `{ |x:float| $(aux(n, `x)) }
}

#stage(main)
fn dsp() {
   genpower!(3)(2.0)  // 2.0 * 2.0 * 2.0 = 8.0 にコンパイルされる
}

グローバルステージ宣言 #

変数のスコープを維持しながらステージを行き来していると、クォートとスプライスのネストがどんどん深くなってしまいます。

深くネストされた括弧を避けるため、mimium v3ではグローバルレベルのステージ宣言を導入しています:

#stage(macro)
// ここ以下のすべてはコンパイル時(ステージ0)で評価される
fn mymacro(n:float) -> `float {
  `{ $n * 2.0 }
}
let compile_time_constant = 42

#stage(main)
// ここ以下すべては実行時(ステージ1)で評価される
fn dsp() {
  mymacro!(21)  // 結果は42.0
}

利点 #

多段階計算を使用することで、以下の利点があります。

  1. パフォーマンスの向上: コンパイル時に計算グラフが展開されるため、実行時オーバーヘッドが削減されます1
  2. 型安全性: マクロ展開前に型検査が完了するため、型エラーを早期に発見できます
  3. 表現力の向上: 複雑なパラメトリックなオーディオ処理を自然に記述できます
  4. ライブコーディング: コンパイル時に内部状態のメモリレイアウトが決定されるため、ソースコード更新時の内部状態メモリの差分更新ができます(ライブコーディングを参照)

注意点 #

多段階計算はややマイナーな計算体系であることもあり、慣れるまで少し時間がかかるかもしれません。特に、ステージ0とステージ1それぞれで定義された変数は同じステージ内でしか使用できず、ステージを間違えるとコンパイルエラーになります。どこでステージを変更するか、どこでlift_fを使い変数を次のステージに持ち越すかなど、単純な例から慣れていくと良いでしょう。

組み込みの関数のみ、両方のステージから同じように呼び出せるようになっていますが、ユーザー定義でこのような(stage-persistent)変数定義をできるようにする拡張は今後の課題となっています。


  1. コンパイル時計算によるパフォーマンス向上に関しては、階乗関数の事前生成のような、動的なif文の実行を減らせる場合には見込めますが、高階関数としてオシレーターをグローバル環境でパラメトリックに生成する場合と、コンパイル時に計算する場合とでは現状ほとんどパフォーマンスの差はありません。これはmimiumのMIRとバイトコードに対する最適化がほとんど行われていないことにも起因するため、今後変化する可能性はあります。 ↩︎