Nimを調べてみる
動機
NimにArc/Orcが導入されて「この、メモリ管理方式なら本格的にゲーム開発に採用できるのでは?」と思ったので調査してみる。
目的
ゲーム開発に採用できるのか調べる。
仮想のシチュエーション:
1からゲーム開発環境を構築する際にCではキツイがC++は諸々の問題から積極的に採用したくないのでNimを検討する。
※諸々:includeによるライブラリの管理、コンパイル速度、パッケージマネージャの欠落、複雑な構文、メモリ管理の手間
調査の方向
- とにかく速度を出せるか調べる
- メモリ管理について調べる
- Cとの連携について調べる
- Cとの構文の比較
- C++で欠如している部分を補えるか
現状の知識
数年前に触って基本的な構文は知っている(Ver1.0にヒットする前)
細かいところは知らない。
速度について
ゲーム開発で使うには高速に動作する必要があるので速度が出るか調べる。
高速に動作するだけだと、ざっくりしすぎなので細かく定義する。
- 実行時の処理速度が高速
プログラムを実行した後の処理が速いということ - 処理全体が高速
Pythonで一部関数をCに置き換えたから高速だよね、とはならない - 安定的に高速
GCが走ってスパイクが起きたりするのはNG
を満たせると高速であると言える。
速度測定の定番であるベンチマーク計測
- 概ねC++のx1.0 ~ x3.0の間
- メモリ管理をすべて手動にすることでx1.0に近づけられる
気になるのはGCをArcに切り替えた場合のパフォーマンス。
現在(Version 1.4.8)のNimのデフォルトGCはOrc(Arc)では無いので、これらのベントマークもおそらく違う。
と、書いたけど正直この手のベンチマークはあんまり当てにならないので参考までに考える。
ただ最悪でも3倍遅い程度なので使えないほど遅いわけでは無い。
言語機能的に高速に動作させる事が出来るかを考えてみる。
言語としてはNimは他言語にコンパイルされる言語なので最終的にはCにコンパイルされCの最適化が受けられる。
「Cに変換されるからCと同等の速度です」は素直過ぎるがCに近い速度が得られる。
コンパイル時に計算できると一部処理を事前に計算したり、そもそも計算しなくて済むようになる。
どのくらい柔軟に扱えるかは分からないがマニュアルを見る限り行える様子(マニュアルではコンパイル時にフィボナッチ数列を定数化している)
Move Semanticsについてもサポートされているのでコピー周りの処理も高速に動作させられる。
上のベンチでも触れられているがメモリ自体は手動で管理する機能も有るので自身が書くシステム自体は手動メモリ管理による高速化も行える。
本命のメモリ管理(と言いつつこれも実質は速度のお話
ここまで(Move Semanticsは除く)はArc導入前のNimにも言えることだがArcに関しては別。
まず、なぜ以前のNimはNGかといとGCがあるから。
ゲーム開発に使うには基本的にGCはNGで何でかというと速度を落とすから。
ベンチを見る限りGCが有っても高速な言語は多々あるのだがGCが避けられる理由は大きく2つでスパイクと最悪時間の長さが読めないこと。
ベンチで高速でも一定期間動かすたびにGCが走ってフレームレートが低下するとかだと採用できない。
指定した場所にメモリを割り当てたいという要件は多分無い。
ゲームじゃないけどDiscordがGoからRustに移行した理由に似ている。
特にゲームは16ms(60FPS)ですべての動作を終える必要が有るので極力ランタイムでコストを払いたくない。
じゃあ、なぜArcなら良いかというとArcはGCじゃないから(--gc:arc
で指定するけど)
Arcの中身は単純な参照カウントで使用されなくなったメモリを即破棄する仕組みになっている(C++のスマートポインタと違うのはコンパイル時に決定される点)
なのでGCと違いランタイムで払うコストは限りなく0に近くなる。
弱点は参照カウントそのままに循環参照を検出できない点。
ゲーム開発的にありがたいメリット:
- ランタイムコスト(ほぼ)なし
- メモリの開放は即時
- RAIIが可能
- 動作がコードから予測可能
気になるポイント:
- 標準ライブラリはArcでリークしないのか
フォーラムだと幾つかは使えるみたいな書き方なので全部が全部いけるわけではなさそう
もう一つOrcというArcベースで循環参照を検出する機構が付いたモノも存在するが、こちらはArcよりもコストを支払うことになるので今回は除外(とはいえ既存のGCよりはかなり少ない様子)
幾つかオプションを指定してコンパイルすればvalgrindなどでメモリリークを検出できることからもプレーンなCと同等のメモリ管理を実現していそう。
外部のライブラリのリークについては仕方が無い。
Arcでメモリのリークが無いことを保証するライブラリを使うしかない。
一応、回避策として{.acyclic.}
というプラグマも存在するが完全解決するわけでは無い。
(そもそも所有権モデルが循環参照前提のデータ構造に弱い)
Orcのパフォーマンスでも満足できるならOrcで解決することはできるが今回は無視する。
Arcがランタイムコスト(ほぼ)0だとして動作にどれほど影響するかを考える。
ここでは競合相手としてC++を想定する。
まずC++自体、ヒープにメモリを確保した場合はほぼ間違いなくスマートポインタで管理することになる。
unique_ptr
, shared_ptr
で管理するので実際にはメモリ管理に全くコストを払わないわけでは無い。
スマートポインタでの管理とArcでの管理のどちらがコストが高いかを比較する必要が有るのだが...正直、分からん。
unique_ptr
は別としてshare_ptr
は同じく参照カウントなので、どちらかが非常に重いという事は無いはず。またC++にはweak_ptr
もあるので一概に比較は出来ない。
C++がshare_ptr
を活用して高速に動作しているのを見るにNimのArcも十分高速に動作するのではないだろうか...。
ランタイムコストについては分からないがメモリ管理の精神的コストで言えばArc搭載のNimの方が優れていると思われる。
NimはGCを動作させないモードもあるが、それは検討しない。
ほかのGCを切れる言語にも言えることだがGCを切ると標準ライブラリがリークするので標準ライブラリ無しでコードを書く必要があるのが辛い。
そもそもGC切るのは通常の動作から離れたこととされるので資料や言語機能の点でも辛い。
Arcの仕組みから言って速度面の問題は無さそう。
懸念点はライブラリ側のリーク問題か。
Arc/Orcはヒープのサイズによって処理速度が変わるわけでは無いのでDiscordの例と同じ問題は起きない。
Orcも速そうなんだけどOrcに対して言及されているドキュメントが少なくて判断しかねる。
GCの辛いところはGCに起因するバグだったり動作を修正したくてもGC自体が言語と深く絡みついているから取り除けないのが辛いところ。通常は自前でGC用意して書き換えるわけにもいかないし。
ヒープサイズに依存せず、ソフト側からも制御出来るしOrcで致命的な問題が起きる未来は見えないが確約はできない。
メモリ管理まとめ
- パフォーマンスの問題は無い
- GCの利点は受けられる(一部)
- 標準ライブラリもバグなく使える(一部)
- 外部ライブラリがバグなく使えるかはライブラリによる
Arcだと自分で書く分には問題ないけど外部ライブラリに不安が残る。
Orcだとパフォーマンス面でやや不安が残るがGCの恩恵はすべて受けられる。
簡単に手元でArc/Orcを切り替えてテストしてみたがメモリ使用量がOrcの方がやや多いくらいで開放のタイミングは同じだった。
なのでOrcを使用しても循環参照が無ければArcと同じようなパフォーマンスを望めるか?
OrcはArcをベースにしたメモリ管理方式で循環参照を処理するためにCycleCollectorが搭載されている。
Cycle collector自体はソフト側で停止させたり動作させるなどの制御も可能で動作タイミングも指定できる。
なので自分で書くコードに関してはなるべく循環参照しない形式にして最低限の動作になるように努める。
標準ライブラリや外部ライブラリへの保険としての役割をCycleCollectorに期待する。
OrcであってもArcに近い挙動でCycleCollector自体はソフト側から制御できるので極端に問題が起きることは考えにくい。
が、コレは作るソフトの規模にかかわる話なので何とも言えないところもある。
極端なこと言えばソロで作る場合は、どうしたって規模は小さくなるので今議題に挙げているGCが低速な言語でもどうにかなる。
分かりやすい例はC#でMonoGameで作られているゲームは多く有る一方でUnityやカプコンはC#をゴリゴリにカスタマイズして極力GCの影響を少なくしている。
AAA級の大規模開発を想定しているわけでは無いが完全ソロレベルは想定していない。
で言えばSmall-size Studiosで運用されるレベルを考えている。Cとの連携
ゲーム開発のメイン言語としてはC/C++が使われているのでゲーム開発に使えるライブラリの多くはC/C++で記述されている。
またウィンドウ制御やGPUの呼び出しなどはどうしてもCを呼び出す必要が有る。
以上の点からCとの連携が重要なので、どの程度行えるか調べる。
※NimからCを呼び出すパターンをメインに調査
Nimは他言語にトランスコンパイルする言語でCにもコンパイルできる。
最終的にはCのコードしてコンパイルできる辺りCとの連携は容易そうに思える。
ただしCからNimを呼び出す場合はGCがArcでないとGC関連で面倒なことになりそう。
公式のSDLバインディングを見ても特別色々なことをやる必要性は無い。
proc setTitle*(window: WindowPtr; title: cstring) {.importc: "SDL_SetWindowTitle".}
単純なラッパーなのでガッツリCコードだがNimのコードに問題なく組み込める様子。
CはABIも安定しているし単純な言語なのでCFFIを搭載した言語は多く存在する。
問題はC++で、こっちは期待していない。機能的にも独自のものが多く複雑なので単純にNimに置き換えられないという問題が有る(テンプレートを使った関数をNim側からどう呼び出すのかなど)
実際Cが呼べれば何とかなる部分は多いのでCが呼べれば良しとする。
header only libraryに関しては.c
ファイルに展開してヘッダ部分をnimで書き換えることで対応できる。
enumやstructはnim側で再定義する必要があるもののCの呼び出し自体は難しくなさそう。
Cとの構文の比較
基本的な構文から何もかも比較するわけでは無い。
Cで有用に使っているテクニックやCで気になる挙動がNimだとどうなるかを調べる。
関数の値渡し参照の挙動の確認
やりたいこと:
- 関数の引数は参照で渡す
- struct(object)を関数に渡す
- 関数からの戻り値をコピーせずにstruct(object)で返す
関数に渡す引数は最適化に応じて値渡しか参照渡しかが決まる。
proc value_or_ref(i: int32) =
echo i
value_or_ref(0)
効率的に値の受け渡しが出来れば問題ない。
またNimでは関数の引数はデフォルトでimmutableなので使う側からしたら気にすることが無い挙動である。
上記の通りなので関数に引数を渡す挙動には何の問題も無い。
やりたかったこととしては
void create_enemy(enemy_data_t data) {
...
}
create_enemy((enemy_data_t){...});
みたいなコードで、Cだとポインタにするか最適化に期待するかみたいな択が存在したのだがNimでは気にする必要は無さそう。
type return_value_t = object
...
proc create_data(...): return_value_t =
return retur_value_t(...)
let data = create_data(...)
のような関数で効率的に構造体を返したい。
マニュアルによるとC++のNRVOに近い挙動になるという事でコピーを気にする必要は無い。
構造体のコピー
- 構造体を素直にコピーしたい
なんで?
メモリ管理の手間を減らすためにコピーで済むデータはコピーして所有したい。
import strformat
type ref_object_t = ref object
data: int32
proc struct_copy_test() =
let r = ref_object_t(data:32)
let rr = r
var cr = r[]
let ccr = cr
r.data = 100
cr.data = 99
echo fmt"r.data = {r.data}"
echo fmt"rr.data = {rr.data}"
echo fmt"cr.data = {cr.data}"
echo fmt"ccr.data = {ccr.data}"
echo fmt"repr r = {repr r}"
echo fmt"repr rr = {repr rr}"
echo fmt"repr cr = {repr cr}"
echo fmt"repr ccr = {repr ccr}"
struct_copy_test()
出力
r.data = 100
rr.data = 100
cr.data = 99
ccr.data = 32
repr r = ref_object_t(data: 100)
repr rr = ref_object_t(data: 100)
repr cr = ref_object_t:ObjectType(data: 99)
repr ccr = ref_object_t:ObjectType(data: 32)
ref object
のコピーは参照のコピー、[]
で関節参照をコピーすると値自体をコピーできる。
nimはref object
で宣言できるので、object
との挙動の違いが気になったが単純な参照として扱える様子。
(思えば当たり前の挙動ではある)
強い型付け
- 既存の型を別の型として扱う
typedef struct texture_id_t {
int32_t id;
} texture_id_t;
みたいなやつ。
type texture_id_t = uint32# typedef相当
type entity_id_t = object# 上の例
id: int32
type model_id_t = distinct uint32# 完全に別の型として扱う
という書き方が有る。
distinct
がやりたいことには一番近い。
# 元の型の関数を適用できるようにする
# proc `+`(x, y: model_id_t): model_id_t {.borrow.}
proc typedef_test() =
var entity = entity_id_t(id: 0)
var texture_id: texture_id_t = 0
var model_id: model_id_t = model_id_t(0)
texture_id = 1 + 2
# {.borrow.}プラグマで機能を持ってくると使えるようになる
# model_id = model_id_t(1) + model_id_t(2)
既存の関数に適用することもできるので、より柔軟性が高い。
構造体を渡す関数の省略
- タイプ量を減らしたい
typedef struct sparse_set_o {
int32_t data;
} hoget_t;
void sparse_set_alloc(sprase_set_o* sset, int32_t val) { sset->data = val; }
sprase_set_o sset = {0};
sprase_set_alloc(&sset, 0);
構造体を渡す関数の名前が長くなりがちなのでタイプ量が増える。
OOP的に.
でつないで呼び出したい。
D言語のUFCSのような機能がNimにも存在する。
type sparse_set_o = object
data: int32
proc alloc(sset: var sparse_set_o, val: int32) =
sset.data = val
var sset = sparse_set_o()
sset.alloc(0)
のように書ける。
またNimは引数が1つなら()
を省略できるので
proc get_data(sset: sprase_set_o): int32 =
return sset.data
sset.get_data
で呼び出せる。
さらに言うとNimは渡す引数が1つなら
sset.alloc 1
get_data sset
みたいな書き方もできる。
Nimの関数呼び出しはかなりフリーダムなことになっておりキャメルケースとスネークケースを区別しないという仕様も有るので
sset.getData()
sset.getData
getData sset
sset.get_data()
sset.get_data
get_data sset
上記のコードは全て等価になる。
途中から違う話になっていったがUFCS自体は
- 元のtypeに手を加えなくてもメソッドを追加できる
- メソッドチェインしやすい
-
()
のネストが少なくなる
というメリットが有る
構造体と関数のみでOOPライクに書くには非常に便利な機能だと思う。
関数ポインタ・高階関数・ラムダ
ジェネリクスとラムダが存在しないのでCだと便利に使うのが難しい機能郡。
import sugar
proc mul_10(x: int32): int32 =
return x * 10
let mul_10_ptr1: (proc (x:int32): int32) = mul_10
let mul_10_ptr2: (int32) -> int32 = mul_10
echo mul_10(10)
echo mul_10_ptr1(10)
echo mul_10_ptr2(10)
# echo 10.mul_10_ptr1# これでも呼べる
let add_one = proc(val: int32): int32 = val + 1
let add_one_sugar = (x: int) => x + 1
echo 1.add_one
echo 1.add_one_sugar
proc pass1(x: int32, f: (proc (x:int32): int32)): int32 =
return f(x)
proc pass2(x: int32, f: (int32) -> int32): int32 =
return f(x)
echo 2.pass1(mul_10)
echo 2.pass1(mul_10_ptr1)
echo 2.pass1((x) => x * 10)
echo 2.pass2((x) => x * 10)
->
や=>
はsugar
モジュールをインポートしないと使えない。
関数に副作用が無いことを保証する
Cにもconstは存在するが限定的にしか保証できない。
type
child_t = object
data: int32
parent_o = object
child: child_t
func no_sideeffect_function(parent: ref parent_o): int32 =
parent.child.data = 99
return parent.child.data
var parent = new parent_o
parent.child.data = 100
discard parent.no_sideeffect_function()
echo parent.child.data# 99
proc
ではなくfunc
で関数を宣言することで副作用が無いことを保証できる。
のだが上記のコードは普通にコンパイルできるし動く。
これはCの関数でポインタをconstで受け取った場合と同じような挙動。
欲しい機能は存在して構文的に気になる部分もない。
調べていて思ったのは
- 糖衣構文が多い
- 記述の容易さを意識している
このあたりはZigとかなり違うところだなと感じる。
とはいえ読みにくいわけでもないし、構造体と関数ベースの記述なので極端に複雑というわけでもないという印象。
C++で欠如している部分を補えるか
- モジュール管理
- 公式のビルドシステム
- パッケージマネージャー
言語機能というよりは所謂エコシステム周り。
モジュール
# Module A
type
T1* = int # Module A exports the type ``T1``
import B # the compiler starts parsing B
proc main() =
var i = p(3) # works because B has been parsed completely here
main()
# Module B
import A # A is not parsed here! Only the already known symbols
# of A are imported.
proc p*(x: A.T1): A.T1 =
# this works because the compiler has already
# added T1 to A's interface symbol table
result = x + 1
ざっと読んだだけだがPythonのモジュールに似ている。
import
するとモジュール名を無視してアクセスもできるのだが、個人的にソレは微妙なので
import strutils
echo replace("abc", "a", "z")
ではなく
from strutils import `%`# 一部をimport
from strutils import nil# strutils.でのアクセスを強制できる
echo replace("abc", "a", "z")# compile error
echo strutils.replace("abc", "a", "z")
のようにしてimportする機能を明示するかモジュール名の記述を強制したい。
ただし標準ライブラリはフルで記述すれば呼び出せるようなので上記コードのfrom strutils import nil
に意味はない。
ビルドシステム&パッケージマネージャー
nimbleというビルドシステム兼パッケージマネージャーがデフォルトで付いている。
nimbleのビルド自体はNimScriptというNimに似たスクリプト言語で制御できる。
C++の乱立するビルドシステムの面倒な点はビルドシステムによって使用しているスクリプト言語が違っていちいち新しいスクリプトを覚える必要のあるところだと思っているので、使用している言語に近い言語(NimScriptはNimのサブセット)で記述できるのは良い。
調べた感想
全体的な印象は良い。
過去に触ったときは
- GCが面倒
- funcの制限が微妙
- Pythonっぽいと言うけどPythonっぽくはないよね(クラス周り)
が微妙だなと思っていたんだけど
- 新しいGCが導入された
- funcの制限を厳しくできるようになった
- 以前に比べてクラス周りを僕が気にしなくなった
ということで気になっていた点が改善されている。
「効率的で表現豊かで優雅」とか主観的すぎて、どんなプログラミング言語か分からなさすぎるので
調べてみての印象を詳しく書いてみる。
個人的にはNimもBetterCなプログラミング言語だと感じる。
「Cを修正して GC・関数型言語由来の機能・強力なマクロ を搭載した言語」というのが一番しっくり来る紹介文。
まあCを修正のくだりは僕がC/C++とかC系の経験が長いせいで他の言語を触っていたら他の言語が収まる部分なのかもしれない(C云々より手続き型言語という記述を追加したほうが適している気がする)
C++とかPythonらしくないのはOOPへのアプローチでクラスベースじゃなくて構造体をベースに関数の多重ディスパッチで対応しているあたりが、それっぽくないなと感じる。
今回はマクロについては調べてないが強力なマクロというのは所謂Lisp族が持っている同図像性があるマクロという意味。
Pythonっぽいのはインデントの構文とかモジュール周りくらいで言語機能的に何か強く影響されているようには感じなかった。
冗長に書かざる負えない部分をUFCSとか演算子オーバーロードとか関数呼び出しの糖衣構文とかで記述量を少なく自由に書けるところが、Pythonというか動的型付けの言語的な気軽に書ける印象を与えている(でもこれPythonというよりRubyっぽい気がする)
あと調べていて思ったのは「D言語っぽいな」と。
言語機能がどうとか見た目が似ているとかではなくて
「C++のように柔軟で高速に、Javaのように堅牢に、Pythonのように手軽に」書ける
みたいな所を意識しているのをNimにも感じて似ているなと。
(DもNimも真剣に使ったことはないので両方ちゃんと使ってみたら全然似ていない可能性はある)
実際のところGCがどれくらい足枷になるかは謎。
仕組み的には軽量だし、ゲームは常にループしているので余裕のあるタイミングで定期的にGCを動作させれば大丈夫じゃないかなという気持ち。
GCのデメリットを極力少なくしてGCのメリットを受けられるのではないかと期待している。
懸念点は
- 関数の呼び出し方がフリーダムすぎでは
- 省略できるところを何でも省略し過ぎでは(enumとかimportとか)
- 開発規模・コミュニティの小ささ
上2つは、そこまで致命的だとは思ってないし、何なら気楽に書けるほうが好きなので個人的には好きなんだけど久々に見たときとかに何やってるか分からなくなりそうなのが怖い。あとは多人数で書くときとか。
開発規模とかコミュニティはマイナー言語故に致し方なし。小さくて良いことはほぼ無いと思うんだけど、大きくしたいと思ってすぐに大きく出来るものでもないからなぁ。
気になるけど気にしてもしょうがない問題ではある。
結論としてゲーム開発には使える(んじゃないかな)
- 低コストで制御可能なGC
- GCでの容易なメモリ管理
- 簡潔な記述
- 豊富な言語機能
- マクロによる強力なメタプログラミング
など魅力に感じる点は多かった。
自分で使うかという話。
使いますね。
Nimで大規模な何かを作成するかは分からないがゲームのプロトタイプレベルのものはSDLとNimで作ってみたい。
まずは単純にNimで書く体験が良いかどうかを確認していく。
ソレが良ければマクロによるメタプログラミングを活かしていきたい(DSLの構築とか)