🌟

Unity/C#のための静的型付けスクリプト言語「YukataScript」

2023/02/19に公開

個人で開発中のYukataScriptというものの紹介。
レポジトリ
WebGL Demo
Qiita版
Qiitaでも途中まで書いてますが、ここにまとめたいと思います。
Pure C#の高速化技術があるので、エクストリームなC#好きは見てってください。
(ついでにコントリビュートしてほしい。)

Demoは無圧縮で20MBです(スマホでもExampleコードは動かせます)。
以下はDemoのexample4

概要

YukataScriptは静的型付けなプログラミング言語でUnityにおいてEditor/Runtime両方でC#の機能を使いやすくすることを目指しています。

using UnityEngine
var ok = true
isZero := 1 + 1 == 0;
if  ok && isZero{
	print(new Vector3(1,1,1))
}

とか

using YS
var timer=new Timer(4f)
while not timer.IsExpired {
	await 16.ms()
	var current=timer.GetEasedElapsedRatio(EasingType.InSine)
	transform.localScale=v3 * current
}

みたいにほぼC#書けてEditMode, PlayMode ,Build後(AOT可)でも動くのがまず良いところ。
「Xluaやmoonsharpでいいじゃん」と思うかもしれませんが、
リフレクションで実行「できない」のがミソで完全に事前のコード生成を行っていて、指定したクラスしか使えません。
しかも使わない関数は自分で消せばいいだけなので、脆弱性を自分で塞いで、プレイヤーに公開することもできます。(Editorではリフレクション対応はしたい)

またエディタから自由に変数を追加、変更できるのが便利なところ。

パフォーマンス

using UnityEngine
for i in ..100000{
    v3+=new Vector3(1,1,1)
}

例えば、上のようなコードを書きます。
v3は参照型のフィールドのときC#で書くと4.5ms程度で、
以下2/22に編集
Mono(Editor)上で動くYukataScriptでは18.4ms,IL2CPPでは7.4ms、WebGLでは86msでした。(WebGL遅い。。)
これはかなり速いと思います。Transformの更新はそもそも遅いのでたいして遅くならないこともあります。

実現方法

参照型と値型を区別せず、かつボクシングを起こさないということが最低条件です。
そこで導入したのが、ref struct意外を共通に扱えるVariable型です。

public abstract class Variable {
	public ref T As<T>() {
            return ref Unsafe.As<Variable<T>>(this).Value;
        }
	public abstract Type type { get; }
}
public  class Variable <T>:Variable {
	public T Value;
	public override Type type => typeof(T);
}

Unsafeの力にによって型チェックを行いません。これによりC#の型チェックを完全にスルーできます。(2/22時点で自力でインライン化したのでこの関数はあまり使いません)
静的型付けの良いところは事前に型チェックを済ますことで、実行時のチェックなしに、型の共変性、反変性、参照渡しなどを扱えるところです。(ちゃんとチェックしないと参照にintが入ったりする。)
まだ大事な点があって、返り値をどうするかです。返り値があるけど、使わないときありますよね。
そんなときに無駄に分岐したり、返り値用のVariable<T>を作りたくないわけです。
ということでUnusableMemoryというものを作りました。

public sealed class UnusableMemoryBox : Variable {
        LongMemory _memory;
        [StructLayout(LayoutKind.Sequential, Size = 32)]
        struct LongMemory {
        }
        public override Type type => null;
}

これは大きなstructのHeapに返り値を全部突っ込めるように作りました。多分32byteより大きなstructは使わないだろうという仮定で、全ての返り値がただの値として、実質無に消えます。
ついでにnullとかdefaultとかようのインスタンスも作って、返り値用とdefault用の2つのインスタンスを使いまわことで、パフォーマンスと省メモリー両方を実現できました。
--以下2/22に編集(もとはDelegate)
変数の扱いは以上ですが、関数はどうでしょう。
コンパイル後のバイナリサイズを気にしない場合、関数すべてのクラスを生成するという方法がありますが、それはもったいないので、delegate*<void>、delegate*<Variable,void>、delegate*<Variable,Variable,void>...みたいな引数と返り値の数の合計ごとの関数ポインタを作っています。
そして、その関数ポインタをYukataScript内部の辞書に登録していきます。またコンパイル時に必要な情報はリフレクションで取得しています。

static void Vector3_op_Addition(Variable result,Variable input1,Variable input2,Variable input3)  
=>  result.As<Vector3>()=input1.As<Vector3>()+input2.As<Vector3>();
module.RegisterMethod("op_Addition", &Vector3_op_Addition);

例えば上のような感じです。(本当はちょっとだけ違う。)
これをいちいち書くのは面倒ですが、自動生成なのでクラスを選択するだけで作ってくれるので、楽ですね(コード生成器はライブラリを使用せず、完全に自力で作りました。)
YukataScriptのVirtualMachineはVariableの配列と、どんなことをするかの関数ポインタ(例えばdelegate*<Variable,void>を呼びだすもの)のインデックスの配列、何番目の関数、何番目のVariableを使うかのインデックスの配列という3つの配列を用いて、任意なコードを実行しています。

以下関数ポインタへの置き換え時に気が付いたことのメモ---
IL2CPPにおいてはUnsafeクラスは直接書かないとインラインかされない。
(インラインかされたらコスト0(il2cpp_unsafe_as<T>))
関数ポインターはMonoではDelegateとあまり変わらないが、IL2CPPでは結構違いが出る。
IL2CPPではポインタはvoid*同一視され、関数ポインタの型以外同じ関数があると、それら全部が呼ばれなくなる。
Monoでは直接"&"で関数ポインタを取得すると、遅い関数ポインタを取得する場合がある。
デリゲートからMethod.MethodHandle.GetFunctionPointer()を取得後(これでも良い)、もしくは遅い関数ポインタを実行後再度取得すると速い関数ポインタが得られる。IL2CPPではこの問題はない。

ハイパフォーマンスな文字列の取り扱い

コンパイラの工夫ですが、コンパイラは文字列の辞書を多用します。また1つのファイルに文字列は1つです。そこから切り出した文字列をいちいちアロケートするのは非効率ですし、別の辞書で使うときにHashを再計算するのも非効率なので、独自のハッシュ関数でReadOnlySpan<char>とstringで同じHashを使えるようにして、さらにHash値と一緒に渡すとHashを計算しないStringDictionary<T>を自作したり、StringSegment、StringSegmentList、といったアロケーションと再計算をとことん避けた実装をしています。
LexerのトークンもStructLayout(LayoutKind.Explicit)を用いて、int、float、といったprimitiveを同じメモリに置いたりとC#なのにCみたいなメモリ節約が行っています。

今後

現状はジェネリクスに対応してなかったり、バグが多いという欠点がありますが、かなり可能性のあるものであると思っています。問題点の解消はしていきますが、時間が足りないので、手伝ってもらえればと思います。

最後に

Pure C#のハイパフォーマンスな動的実行としてILを用いないなかではおそらく最速ではないかと思います。Unityを使う上でVisualScriptingの内部実装もこれに置き換えたら数倍は速くなるはずなので、このライブラリの認知度を広げて、より使いやすく良い実装にしていきたいですね。

レポジトリ
WebGL Demo

Discussion