Как коммутативные перегрузки операторов должны быть эффективно реализованы в C#? - PullRequest
0 голосов
/ 28 марта 2020

Скажем, у меня есть тип Vector3 с перегруженным оператором *, позволяющий умножить на двойное число:

public readonly struct Vector3
{
    public double X { get; }
    public double Y { get; }
    public double Z { get; }

    public Vector3f(double x, double y, double z)
    {
        X = x;
        Y = y;
        Z = z;
    }

    public static Vector3f operator *(in Vector3f v, in double d) => new Vector3f(d * v.X, d * v.Y, d * v.Z);
}

С единственной перегрузкой выражения, похожие на new Vector3(1,2,3) * 1.5, будут компилироваться, но 1.5 * new Vector3(1,2,3) будет не. Поскольку векторно-скалярное умножение является коммутативным, я бы хотел, чтобы любой заказ работал, поэтому я добавляю еще одну перегрузку с обращенными параметрами, которая будет просто вызывать исходную перегрузку:

public static Vector3f operator *(in double d, in Vector3f v) => v * d;

Это правильный путь? Должна ли вторая перегрузка быть реализована как

public static Vector3f operator *(in double d, in Vector3f v) => new Vector3f(d * v.X, d * v.Y, d * v.Z);

? Наивно я ожидал бы, что компилятор оптимизирует «лишний» вызов и всегда будет использовать первую перегрузку, если это возможно (или, возможно, заменит тело короткой перегрузки на длинную), но я не знаю поведение компилятор C# достаточно хорош, чтобы сказать в любом случае.

Я понимаю, что во многих случаях это тот тип спора производительности, который затмевается выбором алгоритма, но в некоторых случаях сжатие до последней капли производительности критично , В случаях, критичных к производительности, следует ли реализовывать коммутативные перегрузки операторов в виде двух одинаковых перегрузок, за исключением порядка параметров, или же столь же эффективно иметь один делегат другому?

1 Ответ

1 голос
/ 28 марта 2020

Здесь вы можете увидеть разницу между двумя подходами.

Пожалуйста, помните, что это IL, а не код окончательной сборки, сгенерированный после JIT-оптимизации.

  1. ", реализованный как два Перегрузки, которые идентичны, за исключением порядка параметров "

Сгенерированный IL в этом случае ниже.

.method public hidebysig specialname static 
        valuetype lib.Vector3f  op_Multiply([in] float64& d,
                                            [in] valuetype lib.Vector3f& v) cil managed
{
  .param [1]
  .custom instance void System.Runtime.CompilerServices.IsReadOnlyAttribute::.ctor() = ( 01 00 00 00 ) 
  .param [2]
  .custom instance void System.Runtime.CompilerServices.IsReadOnlyAttribute::.ctor() = ( 01 00 00 00 ) 
  // Code size       33 (0x21)
  .maxstack  8
  IL_0000:  ldarg.0
  IL_0001:  ldind.r8
  IL_0002:  ldarg.1
  IL_0003:  call       instance float64 lib.Vector3f::get_X()
  IL_0008:  mul
  IL_0009:  ldarg.0
  IL_000a:  ldind.r8
  IL_000b:  ldarg.1
  IL_000c:  call       instance float64 lib.Vector3f::get_Y()
  IL_0011:  mul
  IL_0012:  ldarg.0
  IL_0013:  ldind.r8
  IL_0014:  ldarg.1
  IL_0015:  call       instance float64 lib.Vector3f::get_Z()
  IL_001a:  mul
  IL_001b:  newobj     instance void lib.Vector3f::.ctor(float64,
                                                         float64,
                                                         float64)
  IL_0020:  ret
} // end of method Vector3f::op_Multiply
"или так же эффективно иметь одного делегата для другого?":

Итак, здесь вы можете увидеть издержки вызова оператора *(v,d) изнутри оператора *(d,v)

.method public hidebysig specialname static 
        valuetype lib.Vector3f  op_Multiply([in] float64& d,
                                            [in] valuetype lib.Vector3f& v) cil managed
{
  .param [1]
  .custom instance void System.Runtime.CompilerServices.IsReadOnlyAttribute::.ctor() = ( 01 00 00 00 ) 
  .param [2]
  .custom instance void System.Runtime.CompilerServices.IsReadOnlyAttribute::.ctor() = ( 01 00 00 00 ) 
  // Code size       8 (0x8)
  .maxstack  8
  IL_0000:  ldarg.1
  IL_0001:  ldarg.0
  IL_0002:  call       valuetype lib.Vector3f lib.Vector3f::op_Multiply(valuetype lib.Vector3f&,
                                                                        float64&)
  IL_0007:  ret
} // end of method Vector3f::op_Multiply

Конечно, увеличивается общее количество выполненных операций IL, и если вы этого хотите избежать, вы должны выполнять одинаковый код в обоих ваших операторах.

Вы также можете попробовать метод Multiply(Vector3f v, double d), украсить его с помощью [MethodImpl(MethodImplOptions.AggressiveInlining)] и вызвать этот метод от обоих операторов - и надеяться на лучшее. Это не будет в IL, но JIT, вероятно, будет встраивать код Multiply ().

Возможно, мастерам будет что сказать по этому поводу.

...