2025-10-12

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

漸くまとまった時間が取れるようになった。とりあえず毎週何もできないとかは当面ないと思われる。

早速 krymtkts/pocof を開発した。 主に String 操作の効率化だ。 hot path ではなるべく F# の String module も使わないようにしてみている。 #368

benchmark をとってもみて極端に良くできたところもあれば、何故速くなった・遅くなったかわからないところもある。 効果が顕著そうなところを benchmark ピックアップする。

修正前。

Method Mean Error StdDev Gen0 Allocated
QueryState_getCurrentProperty 89.43 ns 2.249 ns 6.595 ns 0.0267 112 B
Method Mean Error StdDev Gen0 Allocated
QueryCondition_toString 26.93 us 0.862 us 2.486 us 2.0142 8.38 KB
Method Mean Error StdDev Gen0 Allocated
invokeAction_AddQuery 1.144 us 0.0228 us 0.0577 us 0.4005 1.64 KB
Method Mean Error StdDev Gen0 Allocated
invokeAction_BackwardChar 590.3 ns 11.59 ns 24.44 ns 0.0248 104 B
invokeAction_BackwardWord 681.4 ns 12.35 ns 14.70 ns 0.0477 200 B

修正後。

Method Mean Error StdDev Gen0 Allocated
QueryState_getCurrentProperty 51.49 ns 1.229 ns 3.547 ns 0.0153 64 B
Method Mean Error StdDev Allocated
QueryCondition_toString 4.406 ns 0.0873 ns 0.2361 ns -
Method Mean Error StdDev Gen0 Allocated
invokeAction_AddQuery 1.093 us 0.0217 us 0.0434 us 0.4005 1.64 KB
Method Mean Error StdDev Gen0 Allocated
invokeAction_BackwardChar 535.8 ns 10.61 ns 18.57 ns 0.0248 104 B
invokeAction_BackwardWord 689.1 ns 12.58 ns 22.68 ns 0.0477 200 B

String.length が不要な箇所は直に String.Length を使ってみるのは、実際に StringModule.Length の呼び出しがなくなる分僅かに速くなる。 invokeActionAddQueryBackwardChar はそれに当たるとだろう。 何故か BackwardWord は遅くなってしまったが軽微な差なので誤差かな。

QueryState.getCurrentProperty は中間文字列を生成していたのをやめて 4 割ほど高速化した。

         let getCurrentProperty (state: QueryState) =
let s =
- state.Query
- |> String.upToIndex state.Cursor
- |> fun x -> String.fromIndex <| x.LastIndexOf " " + 1 <| x
+ let q, c = state.Query, state.Cursor
+ let start = if c > 0 then q.LastIndexOf(' ', c - 1) + 1 else 0
+ q.Substring(start, c - start)

また QueryCondition.toString に関しては list の生成と中間文字列の生成を一切なくすことで、比較にならないほど劇的に速くメモリも少なくできた。 代わりに愚直な match expression を採用したので、全てのケースがベタ書きされているので、一見するとギョッとする。 でもある意味 match expression で経路も網羅できてるし「正しい」使い方ではあるかな。 速さと省メモリは正義なのだ。

今回修正を試みた中で、完全に読みが外れたのは String interpolation だ。 以下に例を示す。

    module InternalState =
let queryInfo (state: InternalState) (count: int) =
$" %s{state.QueryCondition |> QueryCondition.toString} [%d{count}]"
    module InternalState =
let queryInfo (state: InternalState) (count: int) =
String.Concat(" ", QueryCondition.toString state.QueryCondition, " [", string count, "]")

これらは C# での表現だと以下に compile されていた。

[CompilationArgumentCounts(new int[] { 1, 1 })]
public static string queryInfo(InternalState state, int count)
{
return PrintfModule.PrintFormatToStringThen(new PrintfFormat<string, Unit, string, string, Tuple<string, int>>(" %s%P() [%d%P()]", new object[2]
{
QueryConditionModule.toString(state.QueryCondition@),
count
}, null));
}
[CompilationArgumentCounts(new int[] { 1, 1 })]
public static string queryInfo(InternalState state, int count)
{
return " " + QueryConditionModule.toString(state.QueryCondition@) + " [" + ((IFormattable)(object)count).ToString(null, CultureInfo.InvariantCulture) + "]";
}

String.Concat の方が期待しない形になっているのがよく分かる。 なので断然 String interpolation の方が速い。

Method Mean Error StdDev Median Allocated
InternalState_queryInfo 0.0462 ns 0.0330 ns 0.0968 ns 0.0 ns -
Method Mean Error StdDev Median Allocated
InternalState_queryInfo 0.1410 ns 0.0528 ns 0.1516 ns 0.0935 ns -

このような細かいチューニングで、しかも最も遅いところから着手するわけでないという進め方は、仕事だと中々できない。 やはり趣味のプログラミングだからこそ、そのような重箱の隅をドリルしまくれる。

当然、これらの分析と改善は今 pocof が使ってる .NET 9 でのみこうなるという可能性が十分にある。 それでもこういう挙動を少しずつ頭の中に積んでいくことで、一般的に F# のチューニングで言われているようなパターンの原理を理解できて、良い感じがする。