Mientras comparaba algunos tipos de vectores personalizados, descubrí que, para mi sorpresa, mi tipo Vector2 es mucho más lento para muchas operaciones básicas cuando se lee desde una matriz que mi tipo Vector4 (y Vector3), a pesar de que el código en sí tiene menos operaciones, campos y variables Aquí hay un ejemplo muy simplificado que demuestra esto:
using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; namespace VectorTest { [StructLayout(LayoutKind.Sequential, Pack = 4)] public struct TestStruct4 { public float X, Y, Z, W; [MethodImpl(MethodImplOptions.AggressiveInlining)] public TestStruct4(float x, float y, float z, float w) { X = x; Y = y; Z = z; W = w; } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static TestStruct4 operator +(in TestStruct4 a, in TestStruct4 b) { return new TestStruct4( aX + bX, aY + bY, aZ + bZ, aW + bW); } } [StructLayout(LayoutKind.Sequential, Pack = 4)] public struct TestStruct2 { public float X, Y; [MethodImpl(MethodImplOptions.AggressiveInlining)] public TestStruct2(float x, float y) { X = x; Y = y; } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static TestStruct2 operator +(in TestStruct2 a, in TestStruct2 b) { return new TestStruct2( aX + bX, aY + bY); } } public class Program { private const int COUNT = 10000; private static readonly TestStruct4[] s_arr4 = new TestStruct4[COUNT]; private static readonly TestStruct2[] s_arr2 = new TestStruct2[COUNT]; static unsafe void Main() { for(int i = 0; i < s_arr4.Length; i++) s_arr4[i] = new TestStruct4(i, i * 2, i * 3, i * 4); for(int i = 0; i < s_arr2.Length; i++) s_arr2[i] = new TestStruct2(i, i * 2); BenchmarkRunner.Run<Program>(); } [Benchmark] public TestStruct4 BenchmarkTestStruct4() { TestStruct4 ret = default; for (int i = 0; i < COUNT; i++) ret += s_arr4[i]; return ret; } [Benchmark] public TestStruct2 BenchmarkTestStruct2() { TestStruct2 ret = default; for (int i = 0; i < COUNT; i++) ret += s_arr2[i]; return ret; } } }La ejecución de este punto de referencia da como resultado:
| Método | Significar | Error | Desv.estándar |
|---|---|---|---|
| BenchmarkTestStruct4 | 9.863 us | 0.0706 nosotros | 0.0626 nosotros |
| BenchmarkTestStruct2 | 22.412 nosotros | 0.3100 nosotros | 0.2899 nosotros |
Como puede ver, TestStruct2 es más del doble de lento que TestStruct4 (al menos en mi computadora). Dado que TestStruct2 es esencialmente idéntico a TestStruct4 excepto que tiene menos campos y tiene que hacer menos adiciones, hubiera esperado que, en el peor de los casos, tuviera la misma velocidad que TestStruct4, pero en realidad es más lento. ¿Alguien puede explicar por qué es esto?
La experimentación adicional ha revelado que, si agrego otro flotador o dos de relleno a MyStruct2 (y uso Unsafe.SkipInit para evitar el costo de inicializarlos), entonces el rendimiento mejora para igualar el de MyStruct4. Entonces, supongo que hay algún tipo de problema de alineación con MyStruct2, pero no entiendo qué podría ser específicamente. Pegar el código en SharpLab no revela ninguna diferencia obvia obvia en el ASM (aunque es posible que no entienda el ASM lo suficientemente bien como para detectar algo).
EDITAR: Esto se ejecuta en .NET 5 en Windows 10 de 64 bits.
(Nota: NO estoy interesado en tener una discusión sobre si es prudente escribir los propios tipos de vectores cuando ya hay muchos tipos existentes. Tengo mis razones para hacerlo y están fuera del tema de esta pregunta, que Pregunto por curiosidad académica para entender por qué hay una diferencia de rendimiento tan grande).
EDITAR: según lo solicitado, aquí están los diseños de bytes de las dos estructuras:
Type layout for 'TestStruct4' Size: 16 bytes. Paddings: 0 bytes (%0 of empty space) |===========================| | 0-3: Single X (4 bytes) | |---------------------------| | 4-7: Single Y (4 bytes) | |---------------------------| | 8-11: Single Z (4 bytes) | |---------------------------| | 12-15: Single W (4 bytes) | |===========================| Type layout for 'TestStruct2' Size: 8 bytes. Paddings: 0 bytes (%0 of empty space) |===========================| | 0-3: Single X (4 bytes) | |---------------------------| | 4-7: Single Y (4 bytes) | |===========================|El IL y JIT ASM ciertamente no nos dicen mucho. Estoy pensando que algún comportamiento de CPU/Cache está en juego aquí. ¿Quizás está viendo un costo de cambiar entre el uso de 128 bits y 256 bits de las instrucciones AVX ?
VZEROUPPER : establece la mitad superior de todos los registros YMM en cero. Se utiliza al cambiar entre el uso de 128 bits y el uso de 256 bits.
Si comienza ambas ejecuciones de prueba con un solo TestStruct2 , entonces quizás ambas pruebas obtengan una pequeña penalización de "establecer modo AVX", lo que permitirá que TestStruct2 funcione mejor. Quizás cambiar el orden de las dos pruebas también arrojaría resultados diferentes.
En el caso de la estructura TestStruct4 , el método de sobrecarga del operador '+', las instrucciones de ensamblaje generadas usan registros XMM para almacenar e incrementar el valor, por lo que las instrucciones adicionales se ven así:
00007FFF72084077 vaddss xmm0,xmm0,dword ptr [rdx] 00007FFF7208407B vaddss xmm1,xmm1,dword ptr [rdx+4] 00007FFF72084080 vaddss xmm2,xmm2,dword ptr [rdx+8] 00007FFF72084085 vaddss xmm3,xmm3,dword ptr [rdx+0Ch] Agradable y ordenado. Ahora esto es lo que se genera para TestStruct2 :
00007FFF6FE2B3EA vmovss xmm0,dword ptr [rsp+20h] 00007FFF6FE2B3F0 vaddss xmm0,xmm0,dword ptr [rdx] 00007FFF6FE2B3F4 vmovss xmm1,dword ptr [rsp+24h] 00007FFF6FE2B3FA vaddss xmm1,xmm1,dword ptr [rdx+4] 00007FFF6FE2B3FF vmovss dword ptr [rsp+20h],xmm0 00007FFF6FE2B405 vmovss dword ptr [rsp+24h],xmm1Aquí, las instrucciones de ensamblaje del método de sobrecarga del operador '+' no almacenan los valores en los registros XMM, sino en una memoria, por lo que hay una sobrecarga adicional: al principio mueve el valor inicial de la memoria a XMM, y al final end mueve el valor modificado de vuelta a la memoria.
No está muy claro por qué sucede, pero se parece mucho a una falla del compilador para optimizar correctamente el código. Para resolver este problema en particular, puede cambiar el tipo de campo de float a double , luego se optimizará y el rendimiento será esencialmente el mismo. O, si cambiar el tipo no es una opción, la solución sería, como mencionó, agregar un campo ficticio.