Rendimiento de llamada virtual “directa” vs. llamada de interfaz en C #

Este punto de referencia parece mostrar que llamar a un método virtual directamente en la referencia del objeto es más rápido que llamarlo en la referencia a la interfaz que implementa este objeto.

En otras palabras:

interface IFoo { void Bar(); } class Foo : IFoo { public virtual void Bar() {} } void Benchmark() { Foo f = new Foo(); IFoo f2 = f; f.Bar(); // This is faster. f2.Bar(); } 

Viniendo del mundo de C ++, habría esperado que ambas llamadas se implementaran de manera idéntica (como una simple búsqueda de tabla virtual) y que tuvieran el mismo rendimiento. ¿Cómo implementa C # las llamadas virtuales y qué es este trabajo “extra” que aparentemente se realiza al llamar a través de una interfaz?

— EDITAR —

De acuerdo, las respuestas / comentarios que obtuve hasta ahora implican que hay una desreferencia de doble puntero para la llamada virtual a través de la interfaz versus solo una desreferencia para la llamada virtual a través del objeto.

Entonces, ¿podría alguien explicar por qué es necesario? ¿Cuál es la estructura de la tabla virtual en C #? ¿Es “plano” (como es típico para C ++) o no? ¿Cuáles fueron las concesiones de diseño que se hicieron en el diseño del lenguaje C # que conducen a esto? No digo que este sea un diseño “malo”, simplemente tengo curiosidad de por qué fue necesario.

En pocas palabras, me gustaría entender qué hace mi herramienta debajo del capó para poder usarla de manera más efectiva. Y agradecería que no obtuviera más tipos de respuestas “no debería saber eso” o “usar otro idioma”.

— EDITAR 2 —

Para dejar en claro que no estamos tratando con algún comstackdor de optimización JIT aquí que elimine el despacho dynamic: modifiqué el punto de referencia mencionado en la pregunta original para instanciar una clase o la otra aleatoriamente en tiempo de ejecución. Como la instanciación ocurre después de la comstackción y después de la carga del ensamblaje / JITing, no hay forma de evitar el despacho dynamic en ambos casos:

 interface IFoo { void Bar(); } class Foo : IFoo { public virtual void Bar() { } } class Foo2 : Foo { public override void Bar() { } } class Program { static Foo GetFoo() { if ((new Random()).Next(2) % 2 == 0) return new Foo(); return new Foo2(); } static void Main(string[] args) { var f = GetFoo(); IFoo f2 = f; Console.WriteLine(f.GetType()); // JIT warm-up f.Bar(); f2.Bar(); int N = 10000000; Stopwatch sw = new Stopwatch(); sw.Start(); for (int i = 0; i < N; i++) { f.Bar(); } sw.Stop(); Console.WriteLine("Direct call: {0:F2}", sw.Elapsed.TotalMilliseconds); sw.Reset(); sw.Start(); for (int i = 0; i < N; i++) { f2.Bar(); } sw.Stop(); Console.WriteLine("Through interface: {0:F2}", sw.Elapsed.TotalMilliseconds); // Results: // Direct call: 24.19 // Through interface: 40.18 } } 

— EDIT 3 —

Si alguien está interesado, así es como mi Visual C ++ 2010 presenta una instancia de una clase que hereda múltiples clases:

Código:

 class IA { public: virtual void a() = 0; }; class IB { public: virtual void b() = 0; }; class C : public IA, public IB { public: virtual void a() override { std::cout << "a" << std::endl; } virtual void b() override { std::cout << "b" << std::endl; } }; 

Depurador:

 c {...} C IA {...} IA __vfptr 0x00157754 const C::`vftable'{for `IA'} * [0] 0x00151163 C::a(void) * IB {...} IB __vfptr 0x00157748 const C::`vftable'{for `IB'} * [0] 0x0015121c C::b(void) * 

Múltiples punteros de tabla virtual son claramente visibles, y sizeof(C) == 8 (en comstackción de 32 bits).

Los…

 C c; std::cout << static_cast(&c) << std::endl; std::cout << static_cast(&c) << std::endl; 

..huellas dactilares…

 0027F778 0027F77C 

… indicando que los punteros a diferentes interfaces dentro del mismo objeto apuntan a diferentes partes de ese objeto (es decir, que contienen direcciones físicas diferentes).

Creo que el artículo en http://msdn.microsoft.com/en-us/magazine/cc163791.aspx responderá a sus preguntas. En particular, consulte la sección Mapa de Interfaz Vtable y Mapa de Interfaz , y la siguiente sección sobre Despacho Virtual.

Probablemente sea posible para el comstackdor JIT resolver las cosas y optimizar el código para su caso simple. Pero no en el caso general.

 IFoo f2 = GetAFoo(); 

Y GetAFoo se define como devolver un IFoo , entonces el comstackdor JIT no podrá optimizar la llamada.

Aquí se muestra cómo se ve el desarmado (Hans está en lo cierto):

  f.Bar(); // This is faster. 00000062 mov rax,qword ptr [rsp+20h] 00000067 mov rax,qword ptr [rax] 0000006a mov rcx,qword ptr [rsp+20h] 0000006f call qword ptr [rax+60h] f2.Bar(); 00000072 mov r11,7FF000400A0h 0000007c mov qword ptr [rsp+38h],r11 00000081 mov rax,qword ptr [rsp+28h] 00000086 cmp byte ptr [rax],0 00000089 mov rcx,qword ptr [rsp+28h] 0000008e mov r11,qword ptr [rsp+38h] 00000093 mov rax,qword ptr [rsp+38h] 00000098 call qword ptr [rax] 

Probé tu prueba y en mi máquina, en un contexto particular, el resultado es al revés.

Estoy ejecutando Windows 7 x64 y he creado un proyecto de aplicación de consola Visual Studio 2010 en el que he copiado su código. Si comstack el proyecto en modo Debug y con el objective de la plataforma como x86, la salida será la siguiente:

Llamada directa: 48.38
A través de la interfaz: 42.43

En realidad, cada vez que ejecuta la aplicación, obtendrá resultados ligeramente diferentes, pero las llamadas a la interfaz siempre serán más rápidas. Supongo que dado que la aplicación se comstack como x86, será ejecutada por el sistema operativo a través de WOW.

Para una referencia completa, a continuación se muestran los resultados para el rest de la configuración de comstackción y las combinaciones de objectives.

Modo de lanzamiento y objective x86
Llamada directa: 23.02
A través de la interfaz: 32.73

Modo de depuración y objective x64
Llamada directa: 49.49
A través de la interfaz: 56.97

Modo de lanzamiento y objective x64
Llamada directa: 19.60
A través de la interfaz: 26.45

Todas las pruebas anteriores se realizaron con .Net 4.0 como plataforma de destino para el comstackdor. Al cambiar a 3.5 y repetir las pruebas anteriores, las llamadas a través de la interfaz siempre fueron más largas que las llamadas directas.

Entonces, las pruebas anteriores complican bastante las cosas ya que parece que el comportamiento que descubriste no siempre está sucediendo.

Al final, con el riesgo de molestarlo, me gustaría agregar algunas ideas. Muchas personas agregaron comentarios de que las diferencias de rendimiento son bastante pequeñas y en la progtwigción del mundo real no deberían preocuparse por ellas y estoy de acuerdo con este punto de vista. Hay dos razones principales para ello.

El primero y el más publicitado es que .Net fue construido en un nivel superior para permitir a los desarrolladores centrarse en los niveles más altos de las aplicaciones. Una base de datos o una llamada de servicio externo es miles o, a veces, millones de veces más lenta que la llamada al método virtual. Tener una buena architecture de alto nivel y concentrarse en el gran rendimiento de los consumidores siempre traerá mejores resultados en las aplicaciones modernas en lugar de evitar las referencias de doble puntero.

El segundo y más oscuro es que el equipo de .Net construyendo el marco en un nivel superior ha introducido una serie de niveles de abstracción que el comstackdor just-in-time podría usar para optimizaciones en diferentes plataformas. Cuanto mayor sea el acceso que le den a las capas inferiores, más podrán optimizar los desarrolladores para una plataforma específica, pero menos podrán hacer los comstackdores de tiempo de ejecución por los demás. Esa es la teoría al menos y es por eso que las cosas no están tan bien documentadas como en C ++ con respecto a este asunto en particular.

Creo que el caso de función virtual pura puede usar una tabla de función virtual simple, ya que cualquier clase derivada de la Bar implementación de Foo simplemente cambiaría el puntero de función virtual a Bar .

Por otro lado, al llamar a una función de interfaz IFoo: Bar no pudo hacer una búsqueda en algo como la tabla de funciones virtuales de IFoo , porque cada implementación de IFoo no necesita implementar neccesemente otras funciones ni interfaces que Foo . Entonces, la posición de entrada de la tabla de funciones virtuales para Bar de otra class Fubar: IFoo no debe coincidir con la posición de entrada de la tabla de funciones virtuales de Bar en la class Foo:IFoo .

Por lo tanto, una llamada de función virtual pura puede confiar en el mismo índice del puntero de función dentro de la tabla de funciones virtuales en cada clase derivada, mientras que la llamada de interfaz debe buscar primero este índice.

La regla general es: las clases son rápidas. Las interfaces son lentas

Esa es una de las razones de la recomendación “Construir jerarquías con clases y usar interfaces para el comportamiento dentro de la jerarquía”.

Para los métodos virtuales, la diferencia puede ser leve (como 10%). Pero para los métodos y campos no virtuales, la diferencia es enorme. Considera este progtwig.

 using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Text; using System.Threading.Tasks; namespace InterfaceFieldConsoleApplication { class Program { public abstract class A { public int Counter; } public interface IA { int Counter { get; set; } } public class B : A, IA { public new int Counter { get { return base.Counter; } set { base.Counter = value; } } } static void Main(string[] args) { var b = new B(); A a = b; IA ia = b; const long LoopCount = (int) (100*10e6); var stopWatch = new Stopwatch(); stopWatch.Start(); for (int i = 0; i < LoopCount; i++) a.Counter = i; stopWatch.Stop(); Console.WriteLine("a.Counter: {0}", stopWatch.ElapsedMilliseconds); stopWatch.Reset(); stopWatch.Start(); for (int i = 0; i < LoopCount; i++) ia.Counter = i; stopWatch.Stop(); Console.WriteLine("ia.Counter: {0}", stopWatch.ElapsedMilliseconds); Console.ReadKey(); } } } 

Salida:

 a.Counter: 1560 ia.Counter: 4587