避免 C# 虚拟调用的开销
Avoiding the overhead of C# virtual calls
我有几个高度优化的数学函数需要 1-2 nanoseconds
才能完成。这些函数每秒被调用数亿次,因此尽管性能已经非常出色,但调用开销仍是一个问题。
为了保持程序的可维护性,提供这些方法的类继承了一个IMathFunction
接口,以便其他对象可以直接存储特定的数学函数,并在需要时使用它。
public interface IMathFunction
{
double Calculate(double input);
double Derivate(double input);
}
public SomeObject
{
// Note: There are cases where this is mutable
private readonly IMathFunction mathFunction_;
public double SomeWork(double input, double step)
{
var f = mathFunction_.Calculate(input);
var dv = mathFunction_.Derivate(input);
return f - (dv * step);
}
}
由于使用代码的使用方式,与直接调用相比,此接口会产生巨大的开销。 直接调用需要 1-2ns,而虚拟 接口调用需要 8-9ns。显然,接口的存在及其对虚拟调用的后续翻译是这种情况的瓶颈。
如果可能,我希望同时保留可维护性和性能。 有没有一种方法可以在实例化对象时将虚函数解析为直接调用,以便所有后续调用都能够避免开销?我认为这将涉及使用 IL 创建委托, 但我不知道从哪里开始。
所以这有明显的局限性,不应该在任何有接口的地方一直使用,但如果你有一个地方确实需要最大化性能,你可以使用泛型:
public SomeObject<TMathFunction> where TMathFunction: struct, IMathFunction
{
private readonly TMathFunction mathFunction_;
public double SomeWork(double input, double step)
{
var f = mathFunction_.Calculate(input);
var dv = mathFunction_.Derivate(input);
return f - (dv * step);
}
}
并且不传递接口,而是将您的实现作为 TMathFunction 传递。这将避免由于接口而导致的 vtable 查找,并且还允许内联。
注意这里使用 struct
很重要,否则泛型将通过接口访问 class。
一些实现:
我做了一个简单的 IMathFunction 实现用于测试:
class SomeImplementationByRef : IMathFunction
{
public double Calculate(double input)
{
return input + input;
}
public double Derivate(double input)
{
return input * input;
}
}
...以及结构版本和抽象版本。
所以,这是界面版本的情况。您可以看到它相对低效,因为它执行两个级别的间接寻址:
return obj.SomeWork(input, step);
sub esp,40h
vzeroupper
vmovaps xmmword ptr [rsp+30h],xmm6
vmovaps xmmword ptr [rsp+20h],xmm7
mov rsi,rcx
vmovsd qword ptr [rsp+60h],xmm2
vmovaps xmm6,xmm1
mov rcx,qword ptr [rsi+8] ; load mathFunction_ into rcx.
vmovaps xmm1,xmm6
mov r11,7FFED7980020h ; load vtable address of the IMathFunction.Calculate function.
cmp dword ptr [rcx],ecx
call qword ptr [r11] ; call IMathFunction.Calculate function which will call the actual Calculate via vtable.
vmovaps xmm7,xmm0
mov rcx,qword ptr [rsi+8] ; load mathFunction_ into rcx.
vmovaps xmm1,xmm6
mov r11,7FFED7980028h ; load vtable address of the IMathFunction.Derivate function.
cmp dword ptr [rcx],ecx
call qword ptr [r11] ; call IMathFunction.Derivate function which will call the actual Derivate via vtable.
vmulsd xmm0,xmm0,mmword ptr [rsp+60h] ; dv * step
vsubsd xmm7,xmm7,xmm0 ; f - (dv * step)
vmovaps xmm0,xmm7
vmovaps xmm6,xmmword ptr [rsp+30h]
vmovaps xmm7,xmmword ptr [rsp+20h]
add rsp,40h
pop rsi
ret
这是摘要class。它的效率更高一些,但可以忽略不计:
return obj.SomeWork(input, step);
sub esp,40h
vzeroupper
vmovaps xmmword ptr [rsp+30h],xmm6
vmovaps xmmword ptr [rsp+20h],xmm7
mov rsi,rcx
vmovsd qword ptr [rsp+60h],xmm2
vmovaps xmm6,xmm1
mov rcx,qword ptr [rsi+8] ; load mathFunction_ into rcx.
vmovaps xmm1,xmm6
mov rax,qword ptr [rcx] ; load object type data from mathFunction_.
mov rax,qword ptr [rax+40h] ; load address of vtable into rax.
call qword ptr [rax+20h] ; call Calculate via offset 0x20 of vtable.
vmovaps xmm7,xmm0
mov rcx,qword ptr [rsi+8] ; load mathFunction_ into rcx.
vmovaps xmm1,xmm6
mov rax,qword ptr [rcx] ; load object type data from mathFunction_.
mov rax,qword ptr [rax+40h] ; load address of vtable into rax.
call qword ptr [rax+28h] ; call Derivate via offset 0x28 of vtable.
vmulsd xmm0,xmm0,mmword ptr [rsp+60h] ; dv * step
vsubsd xmm7,xmm7,xmm0 ; f - (dv * step)
vmovaps xmm0,xmm7
vmovaps xmm6,xmmword ptr [rsp+30h]
vmovaps xmm7,xmmword ptr [rsp+20h]
add rsp,40h
pop rsi
ret
因此接口和抽象 class 都严重依赖分支目标预测来获得可接受的性能。即便如此,您仍然可以看到还有很多事情要做,所以最好的情况仍然相对较慢,而最坏的情况是由于预测错误导致管道停滞。
最后是带有结构的通用版本。您可以看到它的效率大大提高,因为所有内容都已完全内联,因此不涉及分支预测。它还具有删除其中大部分 stack/parameter 管理的不错的副作用,因此代码变得非常紧凑:
return obj.SomeWork(input, step);
push rax
vzeroupper
movsx rax,byte ptr [rcx+8]
vmovaps xmm0,xmm1
vaddsd xmm0,xmm0,xmm1 ; Calculate - got inlined
vmulsd xmm1,xmm1,xmm1 ; Derivate - got inlined
vmulsd xmm1,xmm1,xmm2 ; dv * step
vsubsd xmm0,xmm0,xmm1 ; f -
add rsp,8
ret
我会将方法分配给委托。这允许您仍然针对接口进行编程,同时避免接口方法解析。
public SomeObject
{
private readonly Func<double, double> _calculate;
private readonly Func<double, double> _derivate;
public SomeObject(IMathFunction mathFunction)
{
_calculate = mathFunction.Calculate;
_derivate = mathFunction.Derivate;
}
public double SomeWork(double input, double step)
{
var f = _calculate(input);
var dv = _derivate(input);
return f - (dv * step);
}
}
为了回应@CoryNelson 的评论,我进行了测试以了解实际影响。我已经密封了函数 class,但这似乎完全没有区别,因为我的方法不是虚拟的。
大括号中减去空方法时间的测试结果(1 亿次迭代的平均时间,以 ns 为单位):
Empty Work method: 1.48
Interface: 5.69 (4.21)
Delegates: 5.78 (4.30)
Sealed Class: 2.10 (0.62)
Class: 2.12 (0.64)
委托版本时间与接口版本大致相同(具体时间因测试执行而异)。在 class 上工作大约快 6.8 倍(比较时间减去空工作方法时间)!这意味着我与代表合作的建议没有帮助!
令我惊讶的是,我预计界面版本的执行时间会更长。由于这种测试不代表 OP 代码的确切上下文,因此其有效性有限。
static class TimingInterfaceVsDelegateCalls
{
const int N = 100_000_000;
const double msToNs = 1e6 / N;
static SquareFunctionSealed _mathFunctionClassSealed;
static SquareFunction _mathFunctionClass;
static IMathFunction _mathFunctionInterface;
static Func<double, double> _calculate;
static Func<double, double> _derivate;
static TimingInterfaceVsDelegateCalls()
{
_mathFunctionClass = new SquareFunction();
_mathFunctionClassSealed = new SquareFunctionSealed();
_mathFunctionInterface = _mathFunctionClassSealed;
_calculate = _mathFunctionInterface.Calculate;
_derivate = _mathFunctionInterface.Derivate;
}
interface IMathFunction
{
double Calculate(double input);
double Derivate(double input);
}
sealed class SquareFunctionSealed : IMathFunction
{
public double Calculate(double input)
{
return input * input;
}
public double Derivate(double input)
{
return 2 * input;
}
}
class SquareFunction : IMathFunction
{
public double Calculate(double input)
{
return input * input;
}
public double Derivate(double input)
{
return 2 * input;
}
}
public static void Test()
{
var stopWatch = new Stopwatch();
stopWatch.Start();
for (int i = 0; i < N; i++) {
double result = SomeWorkEmpty(i);
}
stopWatch.Stop();
double emptyTime = stopWatch.ElapsedMilliseconds * msToNs;
Console.WriteLine($"Empty Work method: {emptyTime:n2}");
stopWatch.Restart();
for (int i = 0; i < N; i++) {
double result = SomeWorkInterface(i);
}
stopWatch.Stop();
PrintResult("Interface", stopWatch.ElapsedMilliseconds, emptyTime);
stopWatch.Restart();
for (int i = 0; i < N; i++) {
double result = SomeWorkDelegate(i);
}
stopWatch.Stop();
PrintResult("Delegates", stopWatch.ElapsedMilliseconds, emptyTime);
stopWatch.Restart();
for (int i = 0; i < N; i++) {
double result = SomeWorkClassSealed(i);
}
stopWatch.Stop();
PrintResult("Sealed Class", stopWatch.ElapsedMilliseconds, emptyTime);
stopWatch.Restart();
for (int i = 0; i < N; i++) {
double result = SomeWorkClass(i);
}
stopWatch.Stop();
PrintResult("Class", stopWatch.ElapsedMilliseconds, emptyTime);
}
private static void PrintResult(string text, long elapsed, double emptyTime)
{
Console.WriteLine($"{text}: {elapsed * msToNs:n2} ({elapsed * msToNs - emptyTime:n2})");
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static double SomeWorkEmpty(int i)
{
return 0.0;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static double SomeWorkInterface(int i)
{
double f = _mathFunctionInterface.Calculate(i);
double dv = _mathFunctionInterface.Derivate(i);
return f - (dv * 12.34534);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static double SomeWorkDelegate(int i)
{
double f = _calculate(i);
double dv = _derivate(i);
return f - (dv * 12.34534);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static double SomeWorkClassSealed(int i)
{
double f = _mathFunctionClassSealed.Calculate(i);
double dv = _mathFunctionClassSealed.Derivate(i);
return f - (dv * 12.34534);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static double SomeWorkClass(int i)
{
double f = _mathFunctionClass.Calculate(i);
double dv = _mathFunctionClass.Derivate(i);
return f - (dv * 12.34534);
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
的思想是防止编译器在方法被内联时在循环之前计算方法的地址。
我有几个高度优化的数学函数需要 1-2 nanoseconds
才能完成。这些函数每秒被调用数亿次,因此尽管性能已经非常出色,但调用开销仍是一个问题。
为了保持程序的可维护性,提供这些方法的类继承了一个IMathFunction
接口,以便其他对象可以直接存储特定的数学函数,并在需要时使用它。
public interface IMathFunction
{
double Calculate(double input);
double Derivate(double input);
}
public SomeObject
{
// Note: There are cases where this is mutable
private readonly IMathFunction mathFunction_;
public double SomeWork(double input, double step)
{
var f = mathFunction_.Calculate(input);
var dv = mathFunction_.Derivate(input);
return f - (dv * step);
}
}
由于使用代码的使用方式,与直接调用相比,此接口会产生巨大的开销。 直接调用需要 1-2ns,而虚拟 接口调用需要 8-9ns。显然,接口的存在及其对虚拟调用的后续翻译是这种情况的瓶颈。
如果可能,我希望同时保留可维护性和性能。 有没有一种方法可以在实例化对象时将虚函数解析为直接调用,以便所有后续调用都能够避免开销?我认为这将涉及使用 IL 创建委托, 但我不知道从哪里开始。
所以这有明显的局限性,不应该在任何有接口的地方一直使用,但如果你有一个地方确实需要最大化性能,你可以使用泛型:
public SomeObject<TMathFunction> where TMathFunction: struct, IMathFunction
{
private readonly TMathFunction mathFunction_;
public double SomeWork(double input, double step)
{
var f = mathFunction_.Calculate(input);
var dv = mathFunction_.Derivate(input);
return f - (dv * step);
}
}
并且不传递接口,而是将您的实现作为 TMathFunction 传递。这将避免由于接口而导致的 vtable 查找,并且还允许内联。
注意这里使用 struct
很重要,否则泛型将通过接口访问 class。
一些实现:
我做了一个简单的 IMathFunction 实现用于测试:
class SomeImplementationByRef : IMathFunction
{
public double Calculate(double input)
{
return input + input;
}
public double Derivate(double input)
{
return input * input;
}
}
...以及结构版本和抽象版本。
所以,这是界面版本的情况。您可以看到它相对低效,因为它执行两个级别的间接寻址:
return obj.SomeWork(input, step);
sub esp,40h
vzeroupper
vmovaps xmmword ptr [rsp+30h],xmm6
vmovaps xmmword ptr [rsp+20h],xmm7
mov rsi,rcx
vmovsd qword ptr [rsp+60h],xmm2
vmovaps xmm6,xmm1
mov rcx,qword ptr [rsi+8] ; load mathFunction_ into rcx.
vmovaps xmm1,xmm6
mov r11,7FFED7980020h ; load vtable address of the IMathFunction.Calculate function.
cmp dword ptr [rcx],ecx
call qword ptr [r11] ; call IMathFunction.Calculate function which will call the actual Calculate via vtable.
vmovaps xmm7,xmm0
mov rcx,qword ptr [rsi+8] ; load mathFunction_ into rcx.
vmovaps xmm1,xmm6
mov r11,7FFED7980028h ; load vtable address of the IMathFunction.Derivate function.
cmp dword ptr [rcx],ecx
call qword ptr [r11] ; call IMathFunction.Derivate function which will call the actual Derivate via vtable.
vmulsd xmm0,xmm0,mmword ptr [rsp+60h] ; dv * step
vsubsd xmm7,xmm7,xmm0 ; f - (dv * step)
vmovaps xmm0,xmm7
vmovaps xmm6,xmmword ptr [rsp+30h]
vmovaps xmm7,xmmword ptr [rsp+20h]
add rsp,40h
pop rsi
ret
这是摘要class。它的效率更高一些,但可以忽略不计:
return obj.SomeWork(input, step);
sub esp,40h
vzeroupper
vmovaps xmmword ptr [rsp+30h],xmm6
vmovaps xmmword ptr [rsp+20h],xmm7
mov rsi,rcx
vmovsd qword ptr [rsp+60h],xmm2
vmovaps xmm6,xmm1
mov rcx,qword ptr [rsi+8] ; load mathFunction_ into rcx.
vmovaps xmm1,xmm6
mov rax,qword ptr [rcx] ; load object type data from mathFunction_.
mov rax,qword ptr [rax+40h] ; load address of vtable into rax.
call qword ptr [rax+20h] ; call Calculate via offset 0x20 of vtable.
vmovaps xmm7,xmm0
mov rcx,qword ptr [rsi+8] ; load mathFunction_ into rcx.
vmovaps xmm1,xmm6
mov rax,qword ptr [rcx] ; load object type data from mathFunction_.
mov rax,qword ptr [rax+40h] ; load address of vtable into rax.
call qword ptr [rax+28h] ; call Derivate via offset 0x28 of vtable.
vmulsd xmm0,xmm0,mmword ptr [rsp+60h] ; dv * step
vsubsd xmm7,xmm7,xmm0 ; f - (dv * step)
vmovaps xmm0,xmm7
vmovaps xmm6,xmmword ptr [rsp+30h]
vmovaps xmm7,xmmword ptr [rsp+20h]
add rsp,40h
pop rsi
ret
因此接口和抽象 class 都严重依赖分支目标预测来获得可接受的性能。即便如此,您仍然可以看到还有很多事情要做,所以最好的情况仍然相对较慢,而最坏的情况是由于预测错误导致管道停滞。
最后是带有结构的通用版本。您可以看到它的效率大大提高,因为所有内容都已完全内联,因此不涉及分支预测。它还具有删除其中大部分 stack/parameter 管理的不错的副作用,因此代码变得非常紧凑:
return obj.SomeWork(input, step);
push rax
vzeroupper
movsx rax,byte ptr [rcx+8]
vmovaps xmm0,xmm1
vaddsd xmm0,xmm0,xmm1 ; Calculate - got inlined
vmulsd xmm1,xmm1,xmm1 ; Derivate - got inlined
vmulsd xmm1,xmm1,xmm2 ; dv * step
vsubsd xmm0,xmm0,xmm1 ; f -
add rsp,8
ret
我会将方法分配给委托。这允许您仍然针对接口进行编程,同时避免接口方法解析。
public SomeObject
{
private readonly Func<double, double> _calculate;
private readonly Func<double, double> _derivate;
public SomeObject(IMathFunction mathFunction)
{
_calculate = mathFunction.Calculate;
_derivate = mathFunction.Derivate;
}
public double SomeWork(double input, double step)
{
var f = _calculate(input);
var dv = _derivate(input);
return f - (dv * step);
}
}
为了回应@CoryNelson 的评论,我进行了测试以了解实际影响。我已经密封了函数 class,但这似乎完全没有区别,因为我的方法不是虚拟的。
大括号中减去空方法时间的测试结果(1 亿次迭代的平均时间,以 ns 为单位):
Empty Work method: 1.48
Interface: 5.69 (4.21)
Delegates: 5.78 (4.30)
Sealed Class: 2.10 (0.62)
Class: 2.12 (0.64)
委托版本时间与接口版本大致相同(具体时间因测试执行而异)。在 class 上工作大约快 6.8 倍(比较时间减去空工作方法时间)!这意味着我与代表合作的建议没有帮助!
令我惊讶的是,我预计界面版本的执行时间会更长。由于这种测试不代表 OP 代码的确切上下文,因此其有效性有限。
static class TimingInterfaceVsDelegateCalls
{
const int N = 100_000_000;
const double msToNs = 1e6 / N;
static SquareFunctionSealed _mathFunctionClassSealed;
static SquareFunction _mathFunctionClass;
static IMathFunction _mathFunctionInterface;
static Func<double, double> _calculate;
static Func<double, double> _derivate;
static TimingInterfaceVsDelegateCalls()
{
_mathFunctionClass = new SquareFunction();
_mathFunctionClassSealed = new SquareFunctionSealed();
_mathFunctionInterface = _mathFunctionClassSealed;
_calculate = _mathFunctionInterface.Calculate;
_derivate = _mathFunctionInterface.Derivate;
}
interface IMathFunction
{
double Calculate(double input);
double Derivate(double input);
}
sealed class SquareFunctionSealed : IMathFunction
{
public double Calculate(double input)
{
return input * input;
}
public double Derivate(double input)
{
return 2 * input;
}
}
class SquareFunction : IMathFunction
{
public double Calculate(double input)
{
return input * input;
}
public double Derivate(double input)
{
return 2 * input;
}
}
public static void Test()
{
var stopWatch = new Stopwatch();
stopWatch.Start();
for (int i = 0; i < N; i++) {
double result = SomeWorkEmpty(i);
}
stopWatch.Stop();
double emptyTime = stopWatch.ElapsedMilliseconds * msToNs;
Console.WriteLine($"Empty Work method: {emptyTime:n2}");
stopWatch.Restart();
for (int i = 0; i < N; i++) {
double result = SomeWorkInterface(i);
}
stopWatch.Stop();
PrintResult("Interface", stopWatch.ElapsedMilliseconds, emptyTime);
stopWatch.Restart();
for (int i = 0; i < N; i++) {
double result = SomeWorkDelegate(i);
}
stopWatch.Stop();
PrintResult("Delegates", stopWatch.ElapsedMilliseconds, emptyTime);
stopWatch.Restart();
for (int i = 0; i < N; i++) {
double result = SomeWorkClassSealed(i);
}
stopWatch.Stop();
PrintResult("Sealed Class", stopWatch.ElapsedMilliseconds, emptyTime);
stopWatch.Restart();
for (int i = 0; i < N; i++) {
double result = SomeWorkClass(i);
}
stopWatch.Stop();
PrintResult("Class", stopWatch.ElapsedMilliseconds, emptyTime);
}
private static void PrintResult(string text, long elapsed, double emptyTime)
{
Console.WriteLine($"{text}: {elapsed * msToNs:n2} ({elapsed * msToNs - emptyTime:n2})");
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static double SomeWorkEmpty(int i)
{
return 0.0;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static double SomeWorkInterface(int i)
{
double f = _mathFunctionInterface.Calculate(i);
double dv = _mathFunctionInterface.Derivate(i);
return f - (dv * 12.34534);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static double SomeWorkDelegate(int i)
{
double f = _calculate(i);
double dv = _derivate(i);
return f - (dv * 12.34534);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static double SomeWorkClassSealed(int i)
{
double f = _mathFunctionClassSealed.Calculate(i);
double dv = _mathFunctionClassSealed.Derivate(i);
return f - (dv * 12.34534);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static double SomeWorkClass(int i)
{
double f = _mathFunctionClass.Calculate(i);
double dv = _mathFunctionClass.Derivate(i);
return f - (dv * 12.34534);
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
的思想是防止编译器在方法被内联时在循环之前计算方法的地址。