.NETはどのくらいAPLに近づくことができるか

.NET, .NET Core, monoのランタイム・フレームワーク・ライブラリ Advent Calendar 2018の1日目は「.NETはどのくらいAPLに近づくことができるか」というお題でお送りします。

APLとは何か

APL(audio programming language)というのは、主として音楽を作成したり音響効果を実現したりする目的で音声を処理するためのもので、主に非プログラマー(というか、「ガチの」プログラマーではない人々)でも「簡単に」書けるようにするために開発されています。ここで言うAPLというのはひとつの言語ではなく、さまざまな言語の総称です(固有名詞であるA Programming LanguageことAPL言語は、オーディオとは一切関係ありません)。類似の概念としてVPL(visual programming language)があると言ってもよいでしょう。

APLのガラパゴス

APLの例としては、Csound、ChucK、Pure Data、Alda、Faust、Tidalといったものが挙げられます。面白いのは、これらの多くがそれぞれ独自の言語を定義しているということです。もっとも、これらの少なからぬ部分がSchemeなどのLisp系の言語の体系を利用していることを考えると、独自の文法であるとまで言えるかは何とも言えないところです。とはいえ、最終的に実行するプログラムが独自の生態系を構築しているということは言えるでしょう。

これらの言語の一般的な傾向として興味深いのは、これらはほぼ間違いなくJavaや.NETのような仮想マシン技術を用いて実装されないということです。音楽や音響効果を記述するのは、たとえばゲームの開発などにおいては有用そうですし、これらがJavaや.NETで開発されていないというのは意外なことではないでしょうか? なぜ音声処理を行うのにユーザーたるわれわれが独自言語を学習しなければならないのでしょうか? 生オーディオや生MIDIを扱うAPIが存在しているように、音声オブジェクトのライブラリとして提供してくれれば、後は自分の好きなようにどうとでも生成するのに…

しかしAPL開発者には彼らなりの理由づけがあってそうしているのです。面白いことに、それらの理由づけのいくつかは論文化されており、各APLの公式サイトや関連コミュニティなどで紹介されていたりもします。

今回は、その中からExtempore言語の作者Andrew Sorensenによる "The design, implementation and application of a cyber-physical programming language" という論文が、「なぜ.NETではダメなのか」というような疑問に対して、さまざまな実装の可能性を検討しつつ論じていて面白かったので、これを紹介しながら内容を吟味していきたいと思います。

https://openresearch-repository.anu.edu.au/handle/1885/144603

(Extemporeは自称「サイバーフィジカル言語」で、音楽だけを対象とするものではないそうで、この特徴は割と多くのAPLに共通するものなのですが、ここでは踏み込まないでおきます。Impromptuという言語処理系をもとに誕生したようです。あと、この論文自体は.NETについて議論しているものではありません。CLRへの言及などはまれに登場しますが。)

おまけ。OSCON 2014のキーノートで行われたExtemporeのライブコーディングも見られます。

https://www.youtube.com/watch?v=yY1FSsUV-8c

音声処理の用途

一般的なAPLが「仮想マシンではダメだ」としながら担っている仕事は、主に音声の「リアルタイム」に近い処理です。「リアルタイム」とはなんぞや?という疑問は今はおいておいて、音声のリアルタイム処理がどのような場面で必要になるか、いくつかの例を分かりやすい順に挙げていきましょう。

(1) バーチャルなピアノの鍵盤をソフトウェアで実現することを考えてみてください。ピアノのキーを押すとそれに反応してピアノの音が出ます。押したキーの位置に応じて音階が変わります。キーを押してから音が出るまでに時間がかかっていたら、ユーザーはこれを使いたいとは思わないでしょう。

(2) MP3プレイヤーは、ユーザーが指定したMP3ファイルをデコードしてサウンドバイスに出力します。PCMデータのデコードにかかる時間は、生PCMを再生する時間よりは短いですが、再生するタイミングが適切に管理されて守られていないと、音が飛び飛びになってしまって、ユーザーは聴くに耐えなくなります。

(3) DAW(デジタルオーディオワークステーション)は、複数のトラックで音楽を編集して制作するためのものです。場合によっては、PCMとして生成されるオーディオトラックの他に、MIDIバイスにメッセージを送信するMIDIトラックが存在するかもしれません。オーディオトラックとMIDIトラックの間で時間差が生じてしまうと、リスナーにとってはちぐはぐな音楽として聞こえることになります。

(4) デジタル楽器を使ったライブ演奏を行うことを考えてみてください(冒頭で紹介したOSCONのキーノートスピーチのように、最近ではAPLを使った Live Codingと呼ばれる実演もあります)。ライブ中にアプリケーションが固まったり処理が一時的に遅くなってしまったら致命的です。

もちろん、「リアルタイムでない」(リアルタイムであることが要求されない)音声関連の処理もいろいろあります。たとえば生PCMデータをMP3にエンコードする仕事は、何もリアルタイムで行う必要がありません。音声に対してエフェクターとして機能するソフトウェアもいくつかあります。これらは(i)再生しながらエフェクトをかける場合はリアルタイムで処理する必要がありますが、(ii)単に変換して結果を保存したり他のソフトウェアに渡すような場合はリアルタイムである必要はありません。

また、前記(1)〜(4)で求められている要件は、厳密には「リアルタイム」とは限らないものもあります(たとえば(2)や(3)はタイミングが合っていれば即応的である必要はありません)。しかし観念としては処理時間の正確さが重要であるものであり、ここで列挙しておくべきものでしょう。

リアルタイム性

いずれにせよ、音声処理ではリアルタイム性が求められる場面が多数あることがわかりました。しかし「リアルタイム」とはどのような意味なのでしょうか? ピアノの打鍵から1秒経ってから音が出たら、誰が聞いてもおかしいと思うでしょう。100ミリ秒だったら? 10ミリ秒だったら? 1ミリ秒だったら? …こうなってくるとわれわれは直感的にYES/NOで答えることが出来なくなってきます。

同じような問題がVRなどで用いられる3Dアニメーションのフレームレートについても語られます。VRも60FPSとか120FPSといった要求数値が出てきてなかなか厳しいのですが、音声も割とシビアなほうで、20ミリ秒くらいだとそれなりに気づかれてしまうようです。50FPSだと思うとなかなか厳しいことがわかります。

ここでひとつはっきりさせておくべきことがありますが、リアルタイム性とは「コンピューターの処理能力を可能な限り上げることで高度な処理も期待された時間内に終わらせる」仕組みではありません。あるタスクが一定間隔の「期待された時間」に必ず呼び出されて処理できることが、リアルタイムの要件です。

リアルタイム処理は、パフォーマンスの最大化すなわち「コンピューターの処理能力を最大限に活用して、もっともコンピューティングリソースを必要とする処理に最大限のリソースを差し向けよう」という思想とは、むしろ真っ向から対立するものであると言えます(リアルタイムに呼び出される処理はむしろ大して仕事しないかもしれず、それでも定期的に呼び出すことは重要なので、タスクマネージャーはその優先度を上げたままで維持します)。

現代のマルチタスク・コンピューティング環境において、「期待された時間」の枠は、OSのプロセスとスレッドの管理に依存するところが大きいです。前述のSorensenの論文は、そもそも「OSが自動的にタスク切り替えを処理するプリエンプティブ・マルチタスクにするか、アプリケーションが手動でタスク切り替えを管理する協調的マルチタスクにするか」というところから、リアルタイム処理の可能性を検討するのですが、協調的マルチタスクは、もはや一般的なデスクトップ環境があらかたプリエンプティブである現代において現実的ではないので、すぐに検討から外されています。

いずれにせよ、プリエンプティブなマルチタスク環境においては、リアルタイム処理を実現するためには、リアルタイムの精度で必ず呼び出しが発生するプロセス/スレッドが存在することが求められます。一般的なスレッドには、そのような保証はまったくありません。スレッドは大量に生成されて、また多くは既存のスレッドをスレッドプールから使い回されます。全てのスレッドが期待された時間に必ず呼び出されるような理想的な世界があれば問題にはなりませんが、現実はそのようにはなっていません。せいぜい、リアルタイム処理のために特権的に許されたスレッドだけが、優先度の高い割り込みを実現できるのです。それもRTLinuxのような特殊なOSカーネルによって実現してきたのです。

唐突に本題に戻りますが、.NETでこのようなリアルタイム用スレッドの作成が出来てコードが実行できるのであれば、.NETでAPLの要件を満たしたコードが書けるかもしれません。この論点は後でまた言及します。

(Sorensen論文は、さらに「時間をどう計測するか」というトピックについても一節を使って論じていて面白いのですが、さすがにそこまで紹介する意味はほぼ無いので、これだけの言及にとどめます。)

ガベージコレクション

仮にリアルタイム処理を行えるスレッドが利用できたとしても、それはあらゆるアプリケーションがリアルタイム処理に対応していると言える十分条件になるわけではありません。リアルタイム処理においては、1回の呼び出しサイクルが、1サイクル分の時間以内に処理を終えて呼び出し元(OS)に制御を戻すことが求められます。すなわち、この処理で予定外に長い時間がかかってはいけないのです。そして、これは処理時間の平均値の問題ではなく、WCETと呼ばれる「最悪の場合にかかる処理時間」 (worst case execution time)の問題なのです。

この観点で、.NETやJavaをはじめ、その他各種言語ランタイムで問題になるのがGCの処理です。GCの多くは確保されたオブジェクトの利用状態を安定的にトラッキングするために、よく"stop the world"と呼ばれる全アプリケーション・スレッド停止処理を施した上で、メモリをスキャンして、使用されなくなったオブジェクトをマークして、これをスイープします。

stop the worldはCLRのような世代別GCでは、一般的には第2世代以降のみに当てはまる話ですが、いずれにせよ、ここで重要なのはstop the worldが発生しうるということです。stop the worldは当然ながらリアルタイム処理を行っているアプリケーション・スレッドも止めなければなりません。…あれ? リアルタイム処理では、1回の呼び出しサイクルで行われる処理が1サイクル分の時間に必ず収まらないといけない、という条件はどうなるのでしょうか? はい皆さんご想像の通りです。これは満たせません。

実際には、ここには「力こそパワー!」が働く余地があって、極端な話、GCのstop the worldが十分に短いサイクルで実現できるのであれば、これは問題になりません。たとえばGoのGCはマイクロ秒単位で完了するらしいです。ビックリですね…! GC開発者が2016年に「10ミリ秒の停止なんてもう古い!」みたいなことを書いています。

https://groups.google.com/forum/#!msg/golang-dev/Ab1sFeoZg_8/_DaL0E8fAwAJ

あと全然関係ないところでHISEというプロジェクトがしれっと "Customized and real time safe Javascript Engine" などと書いていてたいへん気になるところです。ざっと見た感じでは、JS実装はどうやらインタープリタの独自実装で、一方でメモリ確保は自動で行わないように拡張したものであるようです。

http://hise.audio/

翻って、われらが.NETはどうでしょうか。2017年にMatt WarrenがまとめたGC停止時間に関する投稿がひとつの情報源として信頼できるでしょう。

http://mattwarren.org/2017/01/13/Analysing-Pause-times-in-the-.NET-GC/

Workstationで20ミリ秒強…これではオーディオのリアルタイム処理を任せられるとは言えなそうです。もっとも、この投稿ではGCLatency Modesに関する言及が何もなく、もしかしたら全く調整を試みずに測定しただけなのかもしれません。Low Latencyモードは.NET 3.5で追加されたもので、この記事がよくまとめています。

http://blogs.microsoft.co.il/sasha/2008/08/10/low-latency-gc-in-net-35/

もっともLowLatencyモードは「gen-2以降はGCしない」という剛毅な動作条件なので、長時間実行しっぱなしのライブパフォーマンスのような用途で使う余地はありません。

同じ2017年1月のこちらの記事では、もう少し悲観的な数値が出ています(最後のほう)。このベンチマークではbackground (concurrent) workstation GCとLowLatencyモードを明示的に対象としています。WCETが200ミリ秒を超えることがちょいちょいあるようです。これではやはり使い物にならないでしょう。

https://blogs.msdn.microsoft.com/seteplia/2017/01/05/understanding-different-gc-modes-with-concurrency-visualizer/

(少しだけGCの速度について補足すると、goはreified genericsをサポートする.NETに比べると、だいぶシンプルな要件に基づいて実装されているはずなので、実装の本気度合いの比較と考えてしまうのはフェアではないとわたしは考えています。ただし、言語の総合的な可能性を評価する要素にはもちろん含まれます。)

JITの予見できない動的コード生成

GCの話はここまでにしておいて、もうひとつ、この論文が取り上げるJITの話題に移りましょう。一般に、仮想マシンJITエンジンは、実行時にCPUネイティブの実行可能なコードを生成します。.NETプログラムを初めて起動したときにつっかかるような遅さを体感できると思いますが、その正体はJITコンパイルです。より正確を期するなら、JITコンパイル処理そのものよりは、JITコンパイラをプログラムとしてロードして実行するための遅さ、であると考えたほうがよいでしょう(コンパイル処理自体にあれほど長大な時間がかかるとまでは言えないので)。しかしそこまで極端に長い時間はかからないとしても、JITコンパイル処理自体には無視できないコストがかかります。

一般的には、ある仮想マシンコードのメソッドを実行するためには、まずJITコンパイルしてから実行することになります。ここで先般から話題にしているリアルタイム処理の要求事項の話を思い出してください。リアルタイム処理の呼び出しが行われたときに、初めて実行するコードがあったとします。このコードはまずJITコンパイルされなければなりません。このコンパイル処理はリアルタイム処理に期待される応答時間内に完了するでしょうか? …ちょっとこれは期待できそうにないですね。これがSorensenが提起するもうひとつの問題です。

ちなみに、これは.NETやJavaの問題というよりは、一般的な言語ランタイムに共通する問題です。Sorensenが論文で言及しているのはSonic PiのRuby、GibberのJavaScript、ImpromptuのScheme、OvertoneのClojureなどです。

Extemporeは、ではどのように設計され実装されているかというと、この言語はSchemeインタープリターとXTLangという独自の実行系のハイブリッド構成になっていて、XTLangではGCに依存しない、リアルタイム処理で期待されるコードを書く、それ以外はSchemeで書く、ということになります。XTLangで書かれたコードは、いったんコンパイルしてLLVM IRに変換し、それをネイティブコードに変換して実行することになります。

Extemporeは、ライブパフォーマンスなどの場面で、REPLのようにコードの断片を実行できることを、主目的としており(CやC++では実現できない課題としてこれを挙げています)、実際それがExtemporeコードの一般的な実行方法になります。ただ、XTLangのコンパイルには時間がかかるので、事前にコンパイルコマンドを実行しておくことで、実行時遅延の問題を回避します。

この論点ですが、2018年までにAOTやインタープリターのような、さまざまな.NET実行環境を見てきたわれわれとしては、「仮想マシンであればJITコンパイル」というのは、やや固定観念的であると評価せざるを得ないでしょう。古くからAOTをサポートしていたmonoランタイムだけでなく、.NET CoreでもAOT実行が可能になりつつあり(可能である、と言ったほうが良いでしょうか?)、この点での懸念は、少なくとも理論の上では無くなりつつあると考えられます。

もっとも、インタープリターで実行するのであれば、ネイティブコードのパフォーマンスは一切期待できず、何のために仮想マシン言語を使うのか、という話になるでしょう。その点AOTはまさにXTLangと同じことをやっているのであり、基本的にはリアルタイム処理を期待する場面ではAOTを適用するようにすれば良いと言えそうです。もっとも、Xamarin.iOSでも問題になるように、AOTでも不完全AOTになってしまうものは、JIT処理が発生する余地があるということで、リアルタイム処理に関する懸念をクリアできません。

いずれにせよ、ExtemporeはAOTに相当する静的コード生成をREPLのレベルで実現しているので、.NETでこれに相当するものを実現するためには、現存するC#のREPL環境では足りず、部分的に独自にAOTコンパイルを行えるようなREPL環境が必要になるでしょう(先ほど「少なくとも理論の上では」と留保した理由です)。

言語ランタイムとサウンドサーバーのプロセス内通信

Sorensen論文が本当に面白いのは、この論文はExtempore以外のさまざまな他の実装のアプローチについても、彼なりの視点で検討を加えているところにあります。そのひとつは、言語ランタイム側はリアルタイム処理を行う音声処理部分と、FFI(foreign function invocation/interface)などによってintra-process communication(interではなくintraであることに注意)を行うことで、他言語実装によるメリットを得る、というアプローチです。ChucK、Impromptu、Fluxusはこのアプローチで実現しているようです。

このアプローチの問題は、アプリケーションがクラッシュした場合に問題を解決するのがとても困難である、とまとめられています。これは確かに事実ではあるのですが、「難しいかどうか」はどうしても主観的な判断になってしまうので、その妥当性も評価が難しいところです(というかこの論文ここはスルーしてもらえたのか…)。

プロセス間通信による実装アプローチ

intra-process communicationが困難ならinter-process communicationすなわちIPCではどうでしょうか。これもSorensenは検討しており、実際にこのアプローチで実現している例としてSuperColliderを挙げています。プロセスが分離していると、クラッシュするのはクライアントかサーバーのいずれかになるので、問題の切り分けが容易になりますし、ABIではなくプロトコルによってやり取りが決まるようになります。特にSC3は仕様の安定化を図ったことで、SC1やSC2のようなクライアント・サーバー間の厳密な同一バージョン依存が無くなった、と説明されています。

しかし一方で、安定した仕様になってしまった結果プロトコルへのメッセージの追加が容易に行えなくなったことが問題である、という議論を加えてもいます。仕様を簡単に変更できることがintra-process communicationの大きなアドバンテージである、とまで書かれています。

Extemporeもクライアント・サーバー方式であり、プロセスはクライアントとサーバーで分離しているのですが、tightly-coupledとloosely-coupledの2つのアプローチの間ではフラフラしていたようです。基本的には不可分のものとしているつもりであるようで、その機能の一部をCのライブラリとして分離したりしなかったりといったことを、後方互換性を気にせずに行ってきた、というような話も書かれています。

このあたりの事情をどう評価するかも悩ましいところですが、わたしの個人的な意見としては、実装のアプローチがクライアント・サーバー分離モデルであることと、仕様の安定化の有無は個別に判断すればよいことであって、仕様は正直「ここは安定」「ここは未定」みたいな宣言で押し切るしか無いように思います。安定仕様がダメだったら、非互換の別の仕様として立て直すしか無いでしょう(今「たぶんSwiftはこうやって現状のようになっていったんだろうなー」という考えが浮かびましたが多分気のせいでしょう)。

.NETの文脈でこれを考えるなら、intra-process communicationの方法はあまり考えつかないのですが、CLR hostingやmono embedded APIによるホスティングでしょうか…あまりメリットが無いような気がしますが可能と言えば可能でしょう。(前述のstop the worldと向き合わなければならない事に変わりはないのですが、ここでは個別に検討します。)

inter-process communicationのアプローチは、特段論じるべきことはあまり無く、強いて言えばIPCに必要な共有メモリのサポートなどは.NET Coreでもだいぶ最近になってクロスプラットフォームで実装されてきた機能であることに注意したほうがよい、という程度でしょうか。TCPなどの通信スタックを使ってしまうと、リアルタイム処理を期待しているのに…となってしまいそうです。

ただ、そもそも根本的な問題として、クライアントとサーバーを分けて、ではサーバーは.NETやJavaで実装・拡張したくないのか、と考えると、やはりやりたくなるんじゃないかなあという気はします(個人の主観の問題でしょうか…?)。そうなると結局stop the worldなどの問題に正面から向き合うことになるでしょう。

.NETのGCに挽回の余地はあるか?

さて、ここまで長々とSorensen論文を中心に、APLを.NETで実現する場合の課題にはどんなものがありうるか、検討してきました。やはり一番問題になるのはGCのstop the worldではないでしょうか。

実のところ、この点では.NETよりはJavaのほうが可能性が広がっています。Java界隈には、リアルタイム処理を実現するための手段としてRTSJ(Real-Time Specification for Java)という仕様があり、JSR 282として標準化されています。RTSJの主な特徴をざっくり列挙するとこんな感じです:

  • RTSJでは、リアルタイム処理用のスレッドをユーザーが作成できる
  • そのスレッドではメモリの確保手段が限定されている
  • そのメモリ領域はGCの対象とならない

これらの取り決めをしておくことで、リアルタイム処理を妨げられないスレッドが実現できるというわけです。

Javaにはこのような機構があるのに.NETには無いのでしょうか? .NETにはCER (constrained execution region) という機構があって、「GCをLowLatencyモードに切り替えるときはこれを使え」と言われるものですが、これは別にメモリ管理を別途行うようにするというほどのものではないので、RTSJのようなことは出来ません。このあたりの柔軟性の無さは、やはり.NETがずっとクローズドソースで閉鎖的に開発されてきたからでしょう。CLR/CoreCLRのGCもあまり柔軟ではなく、このようなメモリ管理機構に耐えうる設計になっているのかはわかりません。

一方、.NETや.NET Coreとは異なり、MonoはGCの実装を差し替える機構がそれなりに整備されているので、RTSJ的なGCとハイブリッドに協調するGCを構築できる可能性があるかもしれません。こちらはずっとOSSだったにも関わらず、RTSJのような試みはなかったと思います。何処かではあったかもしれません(わたしは聞いたことがありません)。

実のところ、RTSJも高価な商用製品による実装がほとんどであり、オープンソースの実装というものは無いように思います。そういう意味では、気軽な音楽アプリとは住んでいる世界が違うかもしれません。

この方面で一番現実化しそうなのはUnityとくにECSかもしれません(リアルタイムオーディオのリクエストは何年も前から出ているようです)。ECSで要求されるC#コードの制約がどれくらい問題になるかはUnityを使っていない勢のわたしにはわかりませんが、機構としては一番可能性がある領域かなと思っています。

まあこれはあくまでGCに関連する部分だけの話です。REPL環境が作れないんじゃないかという気もしますし、Unityのエコシステムは.NETのそれとは根本的に異なるガラパゴスであり、これとデスクトップ環境の標準的なコントロールに基づくUIを繋ぐのはだいぶ無理がありそうなので、頑張って機構を作っても、利用する側の開発体験が悪くなるだけになりそうですし、こういうのはMono Frameworkが一番向いていますね。

考察

さて、ここまでSorensenの論文を主な肴としつつ長々と書いてきましたが、最後にこれまでの話をもとに、どんなアプローチであればAPL開発者が納得できるような仕組みが構築できるのか、考察していきます。現時点では、次の2通りのアプローチがあると思います。

  • Low Latencyモードでメモリ不足で落ちないところまで頑張ってみる
    • and/or Low Latencyモードで解放されずに増え続けるメモリ使用は「リーク」とみなしてつぶす
  • クライアントとサーバーをIPCで繋ぐ。やりとりは共有メモリ。サーバも.NETで作るなら、Low Latency GCで「リーク」しないように作り込む

後者のほうが現実的かな?という気がしています。ただ、いずれも定性的に「リーク」を検出できる機構になっておらず、これでは不安である、と言われてしまう可能性はあります(まあそれを言い出したらGCの無い言語の上にコードを構築すること自体が不安ということになりそうですが)。

わたしがAPLを使ったり作ったりしたくなるような時が来たら、このIPCもどき方式で試してみようかなと思っています(個人的にはライブパフォーマンスに走る予定は無く、停止時間はほぼ重要ではないので、作るとしてもあくまで仮想的なクライアント/サーバーを分けておく程度で、アプリケーションは単一のままになりそうですが)。

なお、これは全て「アプリケーションがリアルタイム処理用のスレッドを作成できる」OSであることを要求事項としています。Androidなど、これが実現できない環境については、また別個の考察を必要とするものです。(本当はこの辺の話をDroidKaigi 2019でしようと思って準備していたのですが、セッション不採択になってしまったので他の機会に…)