2025-09-07

F# で Cmdlet を書いてる pt.72

Single-producer/single-consumer append-only buffer 改善

krymtkts/pocof を開発した。

ConcurrentQueue に代わる collection の実装をまだやってた。 #358 #359 で実施。

分岐の改善、 property access の削減等ちまちました改善と、あと bug が混入してたので coverage 100% にするためのテストを AI に追加させてみた。 大幅に性能が改善された感じではない。 でも個人的には勉強なったものもある。例えば Happened-beforeMethodImplOptions.AggressiveInlining

元々 SPSC append-only buffer では件数の読み書きと次の segment の更新に Volatile を利用していた。 次の segment の読みに Volatile.Read が不要なのは happens-before 関係があるためだった。 実はこの次の segment の更新も同様みたいで、結局件数の読み書きによって次の segment の更新は保証されてるようだった。 以下にざっくり変更箇所を示す。

type SpscSegment<'T>(capacity: int) =
let mutable next: SpscSegment<'T> | null = null
member __.Items = items
member __.Capacity = items.Length
- // NOTE: Non-volatile read: safe after reader has observed published count.
- member __.Next = next
- // NOTE: Writer-side publish with volatile write to establish happens-before with count increment.
- member __.PublishNext(seg: SpscSegment<'T>) = Volatile.Write(&next, seg)
+ // NOTE: Non-volatile reads/writes are safe once the reader has observed the published SpscAppendOnlyBuffer.count.
+ member __.Next
+ with get () = next
+ and set v = next <- v

...

type SpscAppendOnlyBuffer<'T>() =
let readCount () = Volatile.Read(&count)
let writeCount (v: int) = Volatile.Write(&count, v)

+ [<MethodImpl(MethodImplOptions.AggressiveInlining)>]
member __.Add(item: 'T) : unit =
// NOTE: Acquire local view of the current tail segment.
let t = tail
let idx = tailIndex
+ let cap = t.Capacity

- if idx >= t.Capacity then
+ if idx >= cap then
// NOTE: Current segment is full: create a new one, link it, and advance tail.
- let newSeg = SpscSegment<'T>(min (t.Capacity <<< 1) segSizeMax)
+ let newSeg = SpscSegment<'T>(min (cap <<< 1) segSizeMax)
// NOTE: Publish linkage before the element becomes observable via count.
- t.PublishNext newSeg
+ t.Next <- newSeg
tail <- newSeg
// NOTE: Write into the new tail
newSeg.Items[0] <- item
tailIndex <- 1
else
t.Items[idx] <- item
tailIndex <- idx + 1

// NOTE: Publish new count after the element write (happens-before for reader).
// NOTE: A volatile read is not required because SPSC guarantees a single writer.
writeCount (count + 1)

AI に説明させたり順を追って注意深く読むとわかるのだけど、一目で「あ~ happens-before 関係やな(キリッ」といえるくらいには理解したいな。

あと MethodImplOptions.AggressiveInlining を初めて知った。 .NET の JIT に対して(少しは AOT に対しても) inlining したいというヒントを強く与える属性みたい。 あくまでヒントで必ずではない。例えばクソデカメソッドなんかだと inlining されないらしい。 SPSC append-only buffer の場合は元々付与したいメソッドが小さいので付けてなくても inlining されることが多いようだが、 hot path では付与する価値がある。 F# の inline は IL を複製するパターンで、こちらは JIT の実行時に複製を判断するという違いがある。

今振り返ると MoveNextAdd にはつける意味あるが GetEnumerator は呼び出し回数がさほど多くないだろうし意味なさそうだ。 まとめて属性を付けて bench 結果が改善したようだったのでそのままにしてたけど、削っていいな。

最終的な benchmark はこんな感じだった。

Add

Method EntryCount Mean Error StdDev Ratio RatioSD Gen0 Gen1 Gen2 Allocated Alloc Ratio
ConcurrentQueue 100 1,853.6 ns 49.35 ns 141.60 ns 1.01 0.11 1.0529 - - 4.31 KB 1.00
SpscAppendOnlyBuffer 100 829.8 ns 16.53 ns 33.40 ns 0.45 0.04 0.2575 - - 1.05 KB 0.24
ConcurrentQueue 10000 278,770.7 ns 5,498.68 ns 9,773.89 ns 1.00 0.05 41.5039 41.5039 41.5039 257.83 KB 1.00
SpscAppendOnlyBuffer 10000 88,778.9 ns 1,691.91 ns 3,177.83 ns 0.32 0.02 19.4092 - - 79.66 KB 0.31
ConcurrentQueue 1000000 23,523,948.8 ns 438,956.52 ns 505,502.98 ns 1.00 0.03 125.0000 93.7500 93.7500 16387.36 KB 1.00
SpscAppendOnlyBuffer 1000000 27,094,229.8 ns 427,652.16 ns 357,108.92 ns 1.15 0.03 1281.2500 937.5000 - 7868.55 KB 0.48

Iterate

Method EntryCount Mean Error StdDev Median Ratio RatioSD Gen0 Allocated Alloc Ratio
ConcurrentQueue_iterate 100 950.3 ns 18.94 ns 54.04 ns 950.3 ns 1.00 0.08 0.0172 72 B 1.00
SpscAppendOnlyBuffer_iterate 100 733.9 ns 14.60 ns 22.29 ns 737.6 ns 0.77 0.05 0.0134 56 B 0.78
ConcurrentQueue_iterate 10000 79,012.7 ns 1,571.08 ns 2,989.13 ns 78,779.6 ns 1.00 0.05 - 72 B 1.00
SpscAppendOnlyBuffer_iterate 10000 65,541.9 ns 1,287.66 ns 2,541.71 ns 65,775.4 ns 0.83 0.04 - 56 B 0.78
ConcurrentQueue_iterate 1000000 12,770,833.4 ns 727,602.69 ns 2,099,300.27 ns 12,829,000.0 ns 1.03 0.25 - 74 B 1.00
SpscAppendOnlyBuffer_iterate 1000000 8,072,519.3 ns 442,006.93 ns 1,232,138.74 ns 7,454,282.8 ns 0.65 0.15 - 62 B 0.84

大量件数の追加以外は ConcurrentQueue より良さそう。 ConcurrentQueue は 32 ~ 1,048,576(1024 * 1024) の範囲で segment の要素数を決めてる。 これが大量件数の場合に利いてくるっぽい。 runtime/src/libraries/System.Private.CoreLib/src/System/Collections/Concurrent/ConcurrentQueue.cs

代わりに bench で gen2 が発生してるし、多分大量の要素があるケースで LOH が発生することは諦めてる設計と思われる。 まあ確かに queue にそんな件数溜まるのは、データが適切に捌けてないケースだけか。

PowerShell の場合だと、 Cmdlet は呼び出しが終われば破棄されるが、そのときに LOH があると破棄のときに必ず回収されるものではない。 つまり同じ PowerShell の session で Cmdlet の実行を繰り返すと LOH が溜まり続けることになる。 この認識で合ってるかな。 こうしてみると、 pocof はなるべく LOH を発生させないようにして session を clean に保った方が無難に思えるな。 いま LOH の発生を嫌って segment 内の配列サイズを最大 1024 にしてる。 もし ConcurrentQueue と同じようなクソデカ segment 方式にすれば多分速くなるけど LOH が発生しメモリが肥大化し続けそう。 .NET や PowerShell Cmdlet のメモリの仕組みに詳しくないので、もっと研究しておく必要があるな。

とりあえず今のままが一旦良さそうに思えてきたな。 一応この現状で良さそうな踏ん切りはついてきたので、いい加減 pocof を新しい version で公開する方向で動くか。