Nimのメモリ管理を理解する② ― Nimのムーブセマンティクス
Nimのメモリ管理はこれまでの参照カウントを使っていた時代から、ARCというC++のスマートポインタやRustの所有権に基づいた手法を採用し、大幅にパフォーマンスを向上させることに成功しました。
しかしその仕組みについてはまだよく理解していませんし、日本語での情報もありません。
そこで新しくなったNimのメモリ管理について理解し、その内容を日本語でちゃんと公開されている状態にしようということで、昔書いた記事からシリーズ化させた続編として今回は作者の講演の全訳をしてみたいと思います。
〜Nimのメモリ管理を理解するシリーズ〜
- Nimのメモリ管理を理解する① ― Nimの新しいGC、ARCについて
- Nimのメモリ管理を理解する② ― Nimのムーブセマンティクス
- Nimのメモリ管理を理解する③ ― GCなしのNim
- Nimのメモリ管理を理解する④ ― ORC - アルゴリズムによるアドバンテージ
- Nimのメモリ管理を理解する⑤ ― ムーブセマンティクスとデストラクタ
- Nimのメモリ管理を理解する⑥ ー Rustと比較して
はじめに
"悪いデザインを真似することは良いデザインではない" -- Nim非公式モットー
- "悪い設計をコピーしてはいけない!"
- "いくつものソースからの良い設計を組み合わせよう!"
さて、私はAndreas Rumpf、Nimの元々の発明者であり、今もなおリード開発者です。今回は、Nimに新たに導入されるムーブセマンティクスについての話をします。これはRustやC++に触発されたものであり、我々はそれを再調整しました。それでは、始めましょう。
Nimの非公式なモットーは、「悪いデザインをコピーすることは良いデザインではない」です。
これは、何をしないべきかを教えてくれるため、非常に有用なモットーです。
そうですね、悪いデザインをコピーすべきではありません。何をしないべきかを知るよりも、何をすべきかを知ることがもっと有用です。
そこで、「いくつかのソースから良い部分を再組み合わせる」と言い換えました。
私たちは、RustやC++、そしてSwiftがどのようにメモリ管理を行っているかを調べ、これらの概念がNimにも適用できるかどうかを見ました。
そして、その答えは「はい」だということが分かりました。
モチベーション
var someNumbers = @[1, 2]
someNumbers.add 3
ここに例があります。私は2つの要素が入った配列を持っています。
そして、その後に数字の3を追加します。これは成長する配列です。C++ではこれをベクターと呼び、Nimではシーケンスと呼びます。
メモリ上で起きていること
someNumbers
Length: 2 ┌──> 1
Capacity: 2 │ 2
Data──────────┘
では、メモリ内で何が起こるかを説明します。
私たちはこのグローバル配列を持っていて、実際には長さと容量があり、成長可能なメモリブロックへの単一ポインタがあります。
メモリ上で起きていること(2)
someNumbers
Length: 3 ┌─/─> 1
Capacity: 4 │ 2
Data──────────┤
│
└────>1
2
3
数字を追加するとき、既に容量がいっぱいの場合(たとえば2つの要素分の容量しかなかった場合)、3つの数字をすべて含むのに十分な新しいメモリブロックを作成する必要があります。
そして、古いメモリブロックに対して何かをする必要があります。通常、C++ではこれを「realloc」と言い、古いブロックをすぐに解放します。
浅いコピー、コピー、ムーブ
var someNumbers = @[1, 2]
var other = someNumbers
someNumbers.add 3 # otherがダングリングポインタを持っている
これは最も効果的な方法ですが、問題を引き起こします。
問題は、このポインタに他のエイリアスがある場合、そのエイリアスがダングリングポインタを引き起こさないようにしなければならないことです。
たとえば、2行目で「他の変数も同じ内容を持つべき」と言っているとしましょう。
もし浅いコピーを行ってすべてのビットをコピーするだけだと、3行目での追加操作によってこのポインタが無効になり、これがダングリングポインタを含むことになります。
これは非常に危険であり、非常に悪いアイデアです。
浅いコピー、コピー、ムーブ(2)
var someNumbers = @[1, 2]
var other = someNumbers
someNumbers.add 3 # otherがダングリングポインタを持っている
- 解決策1: 全く同じ内容の新しい配列を作る ("Deep" copy: C++98, これまでのNim)
- 解決策2: ポインタへの参照を持つポインタを作る (多くallocが発生し、遅い)
- 解決策3: 代入を禁止する
- 解決策4: GCによって古いポインタを掃除してもらう
- 解決策5: メモリを"盗み"、ブロック(の所有権)を移動させる
この問題を解決するためには、いくつかの解決策があります。
1つは、コンテナ内の要素を深くコピーすることです。これはC++が行っていることであり、Nimのセマンティクスでも同様です。
別の解決策としては、全員が新しい更新を受け取るためにポインタのポインタを持つという方法があります。これはJavaやC#で行われていますが、はるかに労力がかかり、やや効率が悪くなります。
さらに、これが「悪い代入」であるとして、それを禁止するという方法もあります。これはひどい解決策ですが、可能です。
前述した最初の解決策として、ガベージコレクターがこの悪いポインタをクリーンアップするようにするか、他の変数がそれを参照していない場合のみクリーンアップさせることが考えられます。
または、最終的にはこれを「移動」するという方法もあります。これは、メモリブロックを盗んでムーブを実行するというものです。
明示的なムーブ
var someNumbers = @[1, 2]
var other = move(someNumbers)
# someNumbersは空になった
someNumbers.add 3
assert someNumbers == @[3]
これはC++でも利用可能な明示的なムーブで、Nimでも同様です。
これを行うことができるなら、「この数値を『他』に移動する」と言えます。そしてその後、ソースは無効になり、空のシーケンスになります。
次に3を追加すると、これが唯一残るものです。6行目に見られるように、その後「someNumbers」には3しか残りません。
これが明示的なムーブです。このスタイルでプログラムを作成しようとすると、あまり快適ではありませんが、明示的であれば問題ありません。たとえば「someNumbers」がその後空になることを知っているからです。
しかし、暗黙的にムーブできるケースがたくさんあります。
暗黙的なムーブ
var a = f()
# 関数fの結果をaに"移動"させる
Rustで有名な最初の例として、関数呼び出しの結果を持っている場合、それが後で使用されないことを知っているので、変数aに直接ムーブできます。
暗黙的なムーブ(2)
var namedValue = g()
var a = f(namedValue) # namedValueをfに移動できる
# fの結果をaに移動できる
1つの設計目標は、これを機能させることでした。
関数呼び出しはムーブできることを知っていますが、可読性を損なわず、パフォーマンスのオーバーヘッドをかけたくありません。
"namedValue"がローカル変数である限り、Nimコンパイラは"namedValue"が関数呼び出しのために使用され、その後には使用されないことを確認できます。
これにより、"namedValue"を関数fにムーブし、次に関数fの結果を"a"にムーブします。
暗黙的なムーブ(3)
var x = @[1, 2, 3]
var y = x # は'x'の最後の呼び出しなので、'y'に代入することができる
var z = y # は'y'の最後の呼び出しなので、'z'に代入することができる
別の例として、3つの整数が含まれているリストがあるとします。
そして、「y = x」と言った場合、xがもう使用されないので、ムーブできます。
同様に、「y = z」の代入でもムーブできます。
これはローカル変数で機能します。
Sink引数
func put(t: var Table; key: string; value: seq[string]) =
var h = hash(key)
t.slots[h] = value # コピーを行っている (´・ω・`)
var values = @["a", "b", "c"]
tab.put("key", values)
次に、関数のパラメータについて考えてみましょう。これが問題を引き起こすのは、関数に渡された値が後で使用されるかどうかがわからないからです。
この例では、ハッシュテーブルの実装のための擬似コードを使っています。
通常は2行以上になるでしょうが、値をハッシュし、このキーと値のペアをテーブルにムーブしたいと思っています。
現在のセマンティクスでは、ここでこの高価なコピー操作を行うことを意味します。
Sink引数(2)
func put(t: var Table; key: string; value: sink seq[string]) =
var h = hash(key)
t.slots[h] = value # ムーブする (´∀`*)
var values = @["a", "b", "c"]
tab.put("key", values) # valueの最後の使用箇所なので、ムーブができる
ただし、このパラメータ値に「sink」キーワードを使用するように注釈を付けることができます。そして、その後はもう使用されるべきでないという制約が呼び出し元にも強制させることができます。
したがって、「sink」パラメータのおかげで、後で使用されないことがわかり、3行目でムーブを実行できます。
同様に、3つの文字列が含まれているリストの値を持っていて、それを後で使用しない場合、ムーブできます。
Sink引数(3)
func put(t: var Table; key: string; value: sink seq[string]) =
var h = hash(key)
t.slots[h] = value # ムーブする (´∀`*)
var values = @["a", "b", "c"]
tab.put("key", values) # 最後の呼び出しではないので、ムーブできない
echo values
>> Warning: Passing a copy to a sink paramater.
さて、もし値を後で使用する場合はどうなりますか?
このオブジェクトの内部を所有したいので、コンパイラは警告を出して、「後で使用される何かをsinkしようとしています。安全性を確保するためにコピーを作成します」と教えてくれます。
これも設計基準の1つであり、もし間違えてしまった場合、パフォーマンスは低下しますが、奇妙なクラッシュは起こりません。
コンパイラはパフォーマンスの側面について警告します。現在、この警告は過剰ですので、これをもう少し改善する必要があります。
Sink引数(4)
func put(t: var Table; key: string; value: sink seq[string]) =
var h = hash(key)
t.slots[h] = value # ムーブする (´∀`*)
var values = @["a", "b", "c"]
echo values
tab.put("key", values)
>> Solution: Move code around.
1つの解決策としては、これを移動することです。
もし、これをハッシュテーブルに埋め込む前にechoしたなら、コンパイラはechoが値の所有権を取ろうとしていないことを知っているので、うまく機能しますが、「table.put」はこの「sink」アノテーションのために所有権を取得します。これが1つの解決策です。
もちろん、デバッグ目的でコードを追加しているだけなら、それが多くのコピーを引き起こしても気にしないでしょう。なぜなら、このコードは後ですぐに削除されるからです。
Sinkのその他の例
- sink引数は最適化のためのものです
- 間違えた呼び出し方をすると、パフォーマンスに影響が出ます。
func `[]=`[K, V](t: var Table[K, V]; k: K, v: V)
func `==`[T](a, b: T):bool
func `+`[T](a, b: T): T
func add[T](s: var seq[T]; v: T)
先ほど述べたように、「sink」パラメータは最適化です。無理に使用する必要はありません。
間違えた場合、パフォーマンスが以前より悪くなりますが、正しく行うとパフォーマンスが向上します。
また、このプロパティを推測するための作業も進めており、アノテーションを全く付けなくても済むようにしています。
標準ライブラリを見直し、これらの「sink」アノテーションを追加しようとしましたが、「いや、これはやらない。コンパイラに任せよう」と思いました。
とにかく、ここでいくつかの好きな例を挙げます。
まず、ハッシュテーブルがあり、これが「put」関数または「insert」や「update」などです。
次に、ジェネリック型Tに対する等式やTに対する「plus」、最後にこのグローバルシーケンスに対する「append」や「add」があります。
質問は、「sink」アノテーションをどこに置くべきかです。推測する必要はありません。教えます。
Sinkのその他の例(2)
func `[]=`[K, V](t: var Table[K, V]; k: sink K, v: sink V)
func `==`[T](a, b: T):bool
func `+`[T](a, b: T): T
func add[T](s: var seq[T]; v: sink T)
さて、ハッシュテーブルに何かを埋め込むと「sink」アノテーションが付き、シーケンスの「append」にも「sink」アノテーションが付きます。
最初の行は挿入または更新です。
ハッシュテーブルに挿入する場合、キーの所有権も取得したいのですが、テーブルを更新するだけなら、既にキーがあります。
それからどうなるかというと、これは「sink」になるべきかどうかということです。
まあ、わかりません。でも、「sink」を使うと、コンパイラはすべてのケースでこの値が消費されることを実際に保証してくれるので、心配する必要はありません。
また消費することが何を意味するかについての概念があります。とにかく、これはデストラクタに関連していますので、別の問題です。
ゲッター:値の借用
func get[K, V](t:Table[K, V]; key: K): V =
var h = hash(key)
result = t.slots[h] # コピーしている?
さて、これで非常に簡単にハッシュテーブルにものを入れることができるようになりました。
しかし、どうやって値を取り出すかという問題があります。
また、同じ問題が生じます。「result = something」の代入はreturn文と同じですが、これが再度高価なコピーであることをより明確にするために代入として書きました。
ゲッター:値の借用(2)
func get[K, V](t:Table[K, V]; key: K): V =
var h = hash(key)
result = move(t.slots[h]) # コンパイルエラー
さて、これをムーブしようと試みることができますが、コンパイラは「t」が実際にはミュータブルでないため、ムーブできないと文句を言います。なぜなら、ムーブはソースを変異させるからです。
ゲッター:値の借用(3)
func get[K, V](t:var Table[K, V]; key: K): V =
var h = hash(key)
result = move(t.slots[h]) # コンパイルされるが、かなり危険
では、これをミュータブルにしてみましょう。
これで機能しますが、今度は、テーブルからこの値をムーブするとどうなるかを考える必要があります。
一度だけアクセスできますが、その後は消えます。
これは非常に不便ですが、もし「pop」操作を持つスタックがあれば、それがまさに求めていることですが、ハッシュテーブルにとっては非常に不便です。
ゲッター:値の借用(4)
func get[K, V](t: Table[K, V]; key: K): lent V =
var h = hash(key)
result = t.slots[h] # コピーでもムーブでもなく、"借用"
ここで、別のアノテーションが必要です。それが「lent V」であり、これは借用操作になります。
Rustでは、これが借用ポインタに相当し、C++では「ref」と呼ばれます。実際には同じことです。
借用した後、そのコレクションの寿命を超えないようにする必要があります。
さて、ポイントは、Rustではこれがチェックされ、C++ではチェックされないということです。
そしてNimではチェックされますが、まだ改善の余地があります。
参照カウント
- これまでどのように最適化が偽のコピーを取り除くかを見てきました
- 同じ原理が参照カウント(=RC)にも適用されます
- 参照のコピー → incRc(src); decRc(dest); dest = src
- 参照の移動 → dest = src
- これが
--gc:arc
モードの開発につながりました
今、複雑な代入(ディープコピーやその他のもの)を最適化する方法を理解したので、この知識を他のことに応用できます。たとえば、参照カウントです。
参照カウントは、ポインタの代入が以前よりもはるかに高価になっただけであり、ポインタをコピーする際に、ソースの参照カウントをインクリメントし、ディスティネーションの参照カウントをデクリメントし、その後でポインタコピーを行う必要があります。
しかし、ポインタをムーブできる場合、それは単にこのビット単位のコピーであり、必要に応じてソースをnilにすることができます。
この洞察により、新しいガーベッジコレクターモードの開発が進みました。これは「GC」と呼ばれていますが、GCは実際にはNimの名前で、あなたが望むあらゆる種類のメモリ管理を意味します。
ARC
include prelude
type
Node = ref object
le, ri: Node
proc checkTree(n: Node): int =
if n.le == nil: 1
else: 1 + checkTree(n.le) + checkTree(n.ri)
proc makeTree(depth: int): Node =
if depth == 0: Node(le: nil, ri: nil)
else: Node(le: makeTree(depth-1), ri: makeTree(depth-1))
ここにベンチマークがあります。
これはバイナリツリーベンチマークであり、ガーベッジコレクターのスループットのための標準的なベンチマークです。
このすべてを理解することを期待しているわけではありませんが、ポイントは、ここで 「sink」や「lent」などのアノテーションが存在しない にもかかわらず、これらが内部で機能しているということです。
何兆ものバイナリツリーをある深さまで作成してみましょう。
ARC(2)
proc main =
let maxDepth = parseInt(paramStr(1))
const minDepth = 4
let stretchDepth = maxDepth + 1
echo("stretch tree of depth ", stretchDepth, "\t check: ", checkTree(makeTree(stretchDepth)))
let longLivedTree = makeTree(maxDepth)
var iterations = 1 shl maxDepth
for depth in countup(minDepth, maxDepth, 2):
var check = 0
for i in 1..iterations:
check += checkTree(makeTree(depth))
echo iterations, "\t trees of depth ", depth
iterations = iterations div 4
main()
これが主要な部分です。先ほど述べたように、これは標準的なベンチマークであり、結果は本当に素晴らしいものです。
ベンチマーク:処理能力
Memory management strategy | Time | Peak Memory |
---|---|---|
mark&sweep GC | 17s | 588.047MiB |
deferred refcounting GC | 16s | 304.074MiB |
Boehm GC | 12s | N/A |
ARC | 6.75s | 472.098MiB |
Nimではいくつものガーベッジコレクターを持っていて、すべてを比較できます。
そして、新しいものは非常に速いもので、3倍や2倍など、どのように比較するかにもよりますが、大幅に速いです。
メモリ消費は「boehmGC」とほぼ同じです。メモリ消費を正確に把握することはできていないので、わかりません。
これは以前よりもはるかに優れています。
手動メモリ管理
include prelude
type
Node = ptr object
le, ri: Node
proc checkTree(n: Node): int =
if n.le == nil: 1
else: 1 + checkTree(n.le) + checkTree(n.ri)
proc makeTree(depth: int): Node =
result = cast[Node](alloc(sizeof(result[]))) # 12行目
if depth == 0:
result.le = nil; result.ri = nil
else:
result.le = makeTree(depth-1)
result.ri = makeTree(depth-1)
proc freeTree(n: Node) =
if n != nil:
freeTree(n.le); freeTree(n.ri); dealloc(n)
さて、手動メモリ管理と比較するとどうなるでしょうか?
Nimは両方を行うことができ、独自のポインタを使用できます。以前は4行目に「ref」がありましたが、今では「ptr」です。ツリーを作成するには、12行目でこのキャストを使って厄介なアロケーションを行う必要があり、もちろん、ツリーを手動で解放する必要があります。
これは再帰的な解放であり、最初に左、次に右を解放し、その後、このノードを解放します。
手動メモリ管理(2)
proc main =
let maxDepth = parseInt(paramStr(1))
const minDepth = 4
let stretchDepth = maxDepth + 1
let stree = makeTree(stretchDepth)
echo("stretch tree of depth ", stretchDepth, "\t check:",
checkTree(stree))
let longLivedTree = makeTree(maxDepth)
var iterations = 1 shl maxDepth
for depth in countup(minDepth, maxDepth, 2):
var check = 0
for i in 1..iterations:
let tmp = makeTree(depth)
check += checkTree(makeTree(tmp))
freeTree(tmp) # 15行目
echo iterations, "\t trees of depth ", depth
iterations = iterations div 4
freeTree(longLivedTree); freeTree(stree) # 18行目
main()
今、main関数では、これらのツリーを手動で解放する必要があり、非常に面倒です。例えば18行目や、後で解放するために新しい一時変数を導入する必要があった15行目でこれを見ることができます。
ベンチマーク:処理能力
Memory management strategy | Time | Peak Memory |
---|---|---|
mark&sweep GC | 17s | 588.047MiB |
deferred refcounting GC | 16s | 304.074MiB |
Boehm GC | 12s | N/A |
ARC | 6.75s | 472.098MiB(379.074MiB) |
manual | 5.23s | 244.563MiB |
manual(with RC) | 6.244s | 379.074MiB |
結果は、それでもまだ遅いです。残念です。
しかし、ここで重要なのは、ARCが実際に行うことは参照カウントの最適化であり、手動バージョンが行うことは基本的に参照カウントを持たないことです。なぜなら、これらが一意のポインタであることを知っているからです。
この参照カウントをこの手動バージョンに戻すためにマシンワードを追加すると、再びほぼ同じになり、6.2秒に戻りますが、ARCは6.7秒です。
残りのバグを修正した場合、メモリ消費は同じです。
手動メモリ管理に近づいていますが、この特定のベンチマークでは、違いがノイズレベルに達するまで近づけると思いますが、まだそこまでは達していません。
ベンチマーク:レイテンシ
Memory management strategy | Latency | Total Time | Peak Memory |
---|---|---|---|
defferd refcountng GC | 0.0356ms | 0.314s | 300MiB |
ARC | 0.0106ms | 0.254s | 271MiB |
さて、レイテンシーのための別のベンチマークがありますが、そのソースコードはありません。
しかし、以前はソフトリアルタイムのガーベッジコレクターを持っていて、このベンチマークではレイテンシーが0.03ミリ秒でした。
現在、ARCを使用すると、それが3倍以上改善されました。全体の実行時間が短縮され、ピークメモリ消費も改善されました。したがって、スループットだけでなく、レイテンシーも改善されています。
カスタムコンテナ
- カスタムデストラクタ、代入と移動の最適化
- ファイル/ソケットなどを自動的にクローズできる(C++やRustのように)
- 特殊なメモリ管理手法を合体できる
すでに概説したように、内部では、デストラクタやムーブ演算子、代入があり、それらを他のことに利用することができます。
これらはあなたに公開されています。少し後で見てみますが、今ではファイルが使用後に自動的に閉じられるようになりました。これは非常に便利で、これにより、これらのカスタムコンテナ間での構成が改善されました。
以前は、手動メモリ管理とGCメモリ管理があり、混ぜてはいけませんでした。なぜなら、うまく機能しないからです。しかし、これらの拡張ポイントを使用すると、これら2つの世界の間の相互作用が以前よりもはるかに改善されました。
オブジェクトプール
include prelude
type
NodeObj = object
le, ri: Node
Node = ptr NodeObj
PoolNode = object
next: ptr PoolNode
elems: UncheckedArray[NodeObj]
Pool = object
len: int
last: ptr PoolNode
lastCap: int
ここで、今できるようになったもう1つのことを紹介します。同じベンチマークですが、今度はオブジェクトプールを持ちたいと思います。これを「アリーナ」と呼ぶ方が良いでしょう。
アリーナアロケータがあり、これらのノードを扱い続けますが、これには2つのポインタしかありません。
※訳者注:
C++のメモリ管理手法に「Arena Allocation」というものがあり、これはNimでの実装である。
Project Snowflake に代わって1つ有望視されているのは、 Arena Allocation という手法です。
Protocol Buffersの C++ 版がこの手法によるメモリ管理を提供しているんですが、それを .NET にも導入できないかという調査をしているみたいです。 まだあんまりドキュメントがなく、QConSFの登壇で軽く紹介された程度ですが。
CLR/CoreCLR: How We Got Here & Where We're Going
これも、「ある程度まとまった単位でごっそり処理する方が高効率」という原理に則ったものです。 以下のように、「ごっそり消す」タイミングを明示するような方式。 メモリ放棄のまとまった単位を指して arena (舞台、競技場、界)と呼んでいます。
引用元:https://ufcpp.net/blog/2018/12/futurememorymanagement/
オブジェクトプール(2)
proc newNode(p: var Pool): Node =
if p.len >= p.lastCap:
if p.lastCap == 0: p.lastCap = 4
elif p.lastCap < 65_000: p.lastCap *= 2
var n = cast[ptr PoolNode](alloc(sizeof(PoolNode) *
p.lastCap * sizeof(NodeObj)))
n.next = nil
n.next = p.last
p.last = n
p.len = 0
result = addr(p.last.elems[p.len])
p.len += 1
新しいノードを割り当てるには、シーケンスのように、容量が残っているかどうかを基本的に確認します。
ノード自体はチェックされていないポインタであり、バックアップストレージとして使用する配列の要素のアドレスを取得します。
オブジェクトプール(3)
proc `=`(dest: var Pool; src: Pool) {.error.}
proc `=destroy`(p: var Pool) =
var it = p.last
while it != nil:
let next = it.next
dealloc(it)
it = next
p.len = 0
p.lastCap = 0
p.last = nil
ここでプールをコピーしたい場合、「サポートされていない」と言うことができます。なぜなら、実装するのが面倒だったからです。
したがって、プールを誤ってコピーしようとした場合、コンパイラが「できません」と教えてくれます。
プールがスコープ外になると、93行目に見られるように、デストラクタが呼び出されます。
当然のことながら、デストラクタで何をするかというと、リンクリストを介してチェーンされたメモリブロックを解放します。
オブジェクトプール(4)
proc checkTree(n: Node): int =
if n.le == nil: 1
else: 1 + checkTree(n.le) + checkTree(n.ri)
proc makeTree(p:var Pool; depth: int): Node =
result = newNode(p)
if depth == 0:
result.le = nil
result.ri = nil
else:
result.le = makeTree(p, depth-1) # 11行目
result.ri = makeTree(p, depth-1) # 12行目
残念ながら、プログラムを変更する必要があります。
ツリーを作成したい場合は、このプールが新しいノードをどこから取得するかを認識する必要があります。
したがって、これが「makeTree」のパラメータになり、再帰的に11行目と12行目で渡す必要があります。
オブジェクトプール(5)
proc main =
let maxDepth = parseInt(paramStr(1))
const minDepth = 4
let stretchDepth = maxDepth + 1
var longLived: Pool # 5行目
let stree = makeTree(longLived, maxDepth)
echo("stretch tree of depth ", stretchDepth, "\t check ",
checkTree(stree))
let longLivedTree = makeTree(longLived, maxDepth)
var iterators = 1 shl maxDepth
for depth in countup(minDepth, maxDepth, 2):
var check = 0
for i in 1..iterators:
var shortLived: Pool # 14行目
check += checkTree(makeTree(shortLived, depth))
echo iterators, "\t trees of depth ", depth
iterators = iterators div 4
main()
ベンチマークはどうでしょうか?さて、自動的にプールが解放されるので、使用が少し簡単になりました。
この場合、長寿命データ用(longLived)と短命データ用(shortLived)に2つのプールを作成する必要がありました。これは5行目と14行目に見られます。
ベンチマーク:処理能力
Memory management strategy | Time | Peak Memory |
---|---|---|
mark&sweep GC | 17s | 588.047MiB |
deferred refcounting GC | 16s | 304.074MiB |
Boehm GC | 12s | N/A |
ARC | 6.75s | 472.098MiB(379.074MiB) |
manual | 5.23s | 244.563MiB |
manual(with RC) | 6.244s | 379.074MiB |
object pooling | 2.4s | 251.504MiB |
パフォーマンスはどうでしょうか?結果ははるかに速く、パフォーマンスが2倍以上向上し、メモリ消費もほぼ同じです。
まとめ
- 所有権の移動は開発者には見えないところで動きます
-
sink
とlent
のキーワードは任意です - 信じられないほどのスピードアップとアルゴリズムの改良につながります
- Nim をより速く、"決定論的"にします
- 新しい戦略が以下を改善します
- 処理速度
- レイテンシ
- メモリ消費
- スレッド
- コーディングの容易さ
- 柔軟な構成
さて、まとめると、ムーブセマンティクスは主に内部で機能しており、本当に優れた最適化を提供してくれます。
スピードアップを見てきましたし、メモリ管理が決定論的になります。
ここでの実際の状況は、参照カウント方式を使用してそれを最適化すると、プログラミング言語にコストモデルを添付できるということです。
それを行うと、Nimを使ってハードリアルタイムシステムに移行できるようになります。この技術を使用して、スループット、レイテンシー、メモリ消費、およびスレッド処理が改善されました。
例はありませんが、データを1つのスレッドから別のスレッドに移動できると、それがこのデータの最後のユーザーであることが保証されるため、データレースが発生しないことが想像できます。それは非常に便利な機能です。
また、プログラミングの容易さも向上します。ファイルが自動的に閉じられ、ソケットも同様です。
また、これらの異なるコンテナクラス間の構成が改善されます。
Happy hacking!ーハックを楽しもう!
これらのベンチマークを試してみてください。GitHubにアップロードしました。
すでにご存じでない場合は、私たちのウェブサイトやフォーラム、そしてIRCにもアクティブに参加しています。
では、私の講演はこれで終わりです。ご清聴ありがとうございました。
Website | https://nim-lang.org/ |
Forum | https://forum.nim-lang.org/ |
Github | https://github.com/nim-lang/Nim |
IRC | https://webchat.freenode.net/?channels=nim |
訳者あとがき
いかがでしたでしょうか。これがNim作者であるAraqが求めるNimでのメモリ管理です。
この講演が行われた2020年2月5日当時ではまだここで挙げられているARCのメモリ管理手法は使えませんでしたが、その後2020年4月3日のv1.2のリリースからサポートされ、2020年10月16日のv1.4のリリースからはARCを更に循環参照にも対応できるようにしたORCというメモリ管理手法が使えるようになりました。AraqはNimのデフォルトのメモリ管理手法を参照カウントからORCに切り替えられるように、日々研究を続けているようです。
NimではRustの所有権モデルやC++のスマートポインタを用いたムーブセマンティクスなど参考にして、高いパフォーマンスが発揮できるプログラミング言語となっています。
sink
やlent
と言ったアノテーションを引数に対して使い、Rustと同じモデルで所有権に基づくプログラミングをすれば、通常の参照カウントよりも大幅にパフォーマンスが向上することができました。
Nimではそれを更に進めて、ARCモードでコンパイルを実行すれば、所有権モデルについて開発者が気にしてsink
やlent
のアノテーションを用いる必要がなく、同じだけのパフォーマンスを実現できるようになっています。
更にオブジェクトプールを使うことでARCから3倍の効率化ができます。こちらは明示的にオブジェクトプールを使ったプログラミングが必要ですが、特にハードなリアルタイムシステム向けのチューニングもできるようになっています。
これを読んだ読者の中から一人でも多くNimを使った開発を始めてくれることを心から願っています。
Discussion