以前書いた C#で<任意の数値型>のような型制約を実現するの類似例です。
高速化のほか演算子オーバーロードのジェネリック対応などにも応用が効く手法です。
演算インターフェイス
公式の用語ではないですが、本記事では
publicinterfaceIComparer<inT>{intCompare(Tx,Ty);}
のように引数に対する演算を定義する型を 演算インターフェイスと呼ぶことにします。
演算インターフェイスの実現
このような演算インターフェイスのような機能を実現するには大きく2つのアプローチがあります。
- 1. 演算インターフェイスを引数で受け取る
publicclassArray{publicstaticvoidSort<T>(T[]array,IComparer<T>comparer);}
のような方式です。
Javaではデリゲートが存在しないのですべてこのような形式です。
- 2. 演算をデリゲート型で受け取る
publicclassArray{publicstaticvoidSort<T>(T[]array,Comparison<T>comparison);}
のような方式です。
提案手法
- 1'. 演算インターフェイス制約をつけて引数で受け取る
publicclassArray{// 実際には存在しないメソッドですpublicstaticvoidSort<T,TOp>(T[]array,TOpcomparer)whereTOp:IComparer<T>;}
演算インターフェイスを直接受け取らずに、ジェネリック型制約で渡します。
TOp
型を struct
で実装することで高速化が期待できます。
ベンチマーク
二分探索で関数の極小値を求めるコードで試してみます。
コード
usingSystem;usingBenchmarkDotNet.Attributes;usingBenchmarkDotNet.Configs;usingBenchmarkDotNet.Diagnosers;usingBenchmarkDotNet.Jobs;usingBenchmarkDotNet.Running;usingBenchmarkDotNet.Toolchains.CsProj;_=BenchmarkRunner.Run(typeof(Benchmark).Assembly);publicclassBenchmarkConfig:ManualConfig{publicBenchmarkConfig(){AddExporter(BenchmarkDotNet.Exporters.MarkdownExporter.GitHub);AddJob(Job.ShortRun);}}[Config(typeof(BenchmarkConfig))]publicunsafeclassBenchmark{publicstaticdoubleF(doublex)=>2*x*x+4*x+5;[Benchmark(Baseline=true)]publicdoubleDirect(){doubleok=100000000000;doubleng=-100000000000;while(Math.Abs(ok-ng)>1e-8){varm=(ok+ng)/2;if(F(m)<F(m+1e-8))ok=m;elseng=m;}returnok;}publicstaticdoubleBinarySearch(doubleok,doubleng,Predicate<double>isOk){while(Math.Abs(ok-ng)>1e-8){varm=(ok+ng)/2;if(isOk(m))ok=m;elseng=m;}returnok;}publicstaticdoubleBinarySearch(doubleok,doubleng,delegate*<double,bool>isOk){while(Math.Abs(ok-ng)>1e-8){varm=(ok+ng)/2;if(isOk(m))ok=m;elseng=m;}returnok;}publicstaticdoubleBinarySearch(doubleok,doubleng,IOk<double>op){while(Math.Abs(ok-ng)>1e-8){varm=(ok+ng)/2;if(op.Ok(m))ok=m;elseng=m;}returnok;}publicstaticdoubleBinarySearch<TOk>(doubleok,doubleng,TOkop)whereTOk:IOk<double>{while(Math.Abs(ok-ng)>1e-8){varm=(ok+ng)/2;if(op.Ok(m))ok=m;elseng=m;}returnok;}publicinterfaceIOk<T>{boolOk(Tvalue);}structOkStruct:IOk<double>{publicboolOk(doublev)=>F(v)<F(v+1e-8);}classOkClass:IOk<double>{publicboolOk(doublev)=>F(v)<F(v+1e-8);}[Benchmark]publicdoubleLambda()=>BinarySearch(100000000000,-100000000000,v=>F(v)<F(v+1e-8));[Benchmark]publicdoubleFunctionPointer()=>BinarySearch(100000000000,-100000000000,&Fp);privatestaticboolFp(doublev)=>F(v)<F(v+1e-8);[Benchmark]publicdoubleOperatorStruct()=>BinarySearch(100000000000,-100000000000,newOkStruct());[Benchmark]publicdoubleOperatorClass()=>BinarySearch(100000000000,-100000000000,newOkClass());[Benchmark]publicdoubleOperatorInterfaceStruct()=>BinarySearch(100000000000,-100000000000,(IOk<double>)newOkStruct());[Benchmark]publicdoubleOperatorInterfaceClass()=>BinarySearch(100000000000,-100000000000,(IOk<double>)newOkClass());}
結果
BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19042IntelCorei7-4790CPU3.60GHz(Haswell),1CPU,8logicaland4physicalcores.NETCoreSDK=5.0.101[Host]:.NETCore5.0.1(CoreCLR5.0.120.57516,CoreFX5.0.120.57516),X64RyuJITShortRun:.NETCore5.0.1(CoreCLR5.0.120.57516,CoreFX5.0.120.57516),X64RyuJITJob=ShortRun IterationCount=3 LaunchCount=1 WarmupCount=3
Method | Mean | Error | StdDev | Ratio | RatioSD |
---|---|---|---|---|---|
Direct | 446.4 ns | 136.00 ns | 7.45 ns | 1.00 | 0.00 |
Lambda | 658.4 ns | 93.17 ns | 5.11 ns | 1.48 | 0.01 |
FunctionPointer | 689.9 ns | 164.93 ns | 9.04 ns | 1.55 | 0.04 |
OperatorStruct | 463.3 ns | 253.98 ns | 13.92 ns | 1.04 | 0.03 |
OperatorClass | 748.3 ns | 207.55 ns | 11.38 ns | 1.68 | 0.05 |
OperatorInterfaceStruct | 857.2 ns | 824.01 ns | 45.17 ns | 1.92 | 0.10 |
OperatorInterfaceClass | 759.3 ns | 206.67 ns | 11.33 ns | 1.70 | 0.01 |
考察
- 構造体をジェネリックに渡すと直接書いているのに肉薄するレベルで高速
- 構造体をインターフェイスにキャストするとboxingが発生するため低速
- 意外にもラムダ式で渡しても関数ポインタでも大差なし
- ラムダ式の最適化が優秀?
まとめ
演算インターフェイスをジェネリックで定義して構造体で渡すと高速になる。
性能向上を目指したいがコードを共通化したい場合などに有効。