reales - Rendimiento de la llamada virtual "directa" frente a la llamada de interfaz en C#
libro de android studio en español pdf (5)
Este punto de referencia parece mostrar que llamar a un método virtual directamente en una referencia de objeto es más rápido que hacerlo 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();
}
Al venir del mundo de C ++, habría esperado que estas dos llamadas se implementaran de forma idéntica (como una simple búsqueda de tablas virtuales) y tuvieran el mismo rendimiento. ¿Cómo implementa C # las llamadas virtuales y cuál es este trabajo "adicional" que aparentemente se realiza al llamar a través de una interfaz?
--- EDITAR ---
De acuerdo, las respuestas / comentarios que recibí hasta ahora implican que hay una doble referencia para la llamada virtual a través de la interfaz en lugar de una sola referencia 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 de C ++) o no? ¿Cuáles fueron los compromisos de diseño que se hicieron en el diseño de lenguaje C # que condujeron a esto? No estoy diciendo que este es un diseño "malo", simplemente tengo curiosidad por saber 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 respuestas de "no deberías saber eso" o "usar otro idioma".
--- EDITAR 2 ---
Solo para dejar en claro que no estamos tratando con un compilador de optimización JIT que elimina el envío dinámico: modifiqué el punto de referencia mencionado en la pregunta original para crear una instancia de una clase o la otra al azar en tiempo de ejecución. Dado que la creación de instancias ocurre después de la compilación y después de la carga / JIT de ensamblaje, no hay forma de evitar el envío dinámico 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
}
}
--- EDITAR 3 ---
Si alguien está interesado, aquí es cómo mi Visual C ++ 2010 presenta una instancia de una clase que hereda de forma múltiple otras 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 tablas virtuales son claramente visibles, y sizeof(C) == 8
(en una compilación de 32 bits).
Los...
C c;
std::cout << static_cast<IA*>(&c) << std::endl;
std::cout << static_cast<IB*>(&c) << std::endl;
..huellas dactilares...
0027F778
0027F77C
... lo que indica que los punteros a diferentes interfaces dentro del mismo objeto apuntan a diferentes partes de ese objeto (es decir, contienen direcciones físicas diferentes).
Aquí es cómo se ve el desmontaje (Hans es correcto):
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]
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 Interface Vtable Map y Interface Map , y la siguiente sección sobre Virtual Dispatch.
Probablemente sea posible que el compilador JIT resuelva las cosas y optimice 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 compilador JIT no podría optimizar la llamada.
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 la función virtual a Bar
.
Por otro lado, llamar a una función de interfaz IFoo: Bar no pudo realizar una búsqueda de algo como la tabla de funciones virtuales de IFoo
, porque cada implementación de IFoo
no necesita implementar necesariamente otras funciones ni interfaces que Foo
hace. Por lo tanto, la posición de entrada de la tabla de función virtual para la Bar
de otra class Fubar: IFoo
no debe coincidir con la posición de entrada de la tabla de función virtual de la 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 función virtual 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 "Crear 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 métodos y campos no virtuales, la diferencia es enorme. Considere este programa.
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
Probé su prueba y en mi máquina, en un contexto particular, el resultado es en realidad al revés.
Estoy ejecutando Windows 7 x64 y he creado un proyecto de aplicación de consola de Visual Studio 2010 en el que he copiado su código. Si se compila el proyecto en modo de depuración y con el objetivo de la plataforma como x86, el resultado será el siguiente:
Llamada directa: 48.38
A través de la interfaz: 42.43
En realidad, cada vez que se ejecuta la aplicación proporcionará resultados ligeramente diferentes, pero las llamadas de la interfaz siempre serán más rápidas. Asumo que ya que la aplicación está compilada 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 resto de la configuración de compilación y las combinaciones de objetivos.
Modo de lanzamiento y objetivo x86
Llamada directa: 23.02
A través de la interfaz: 32.73
Modo de depuración y destino x64
Llamada directa: 49.49
A través de la interfaz: 56.97
Modo de lanzamiento y objetivo 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 compilador. 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 las cosas, ya que parece que el comportamiento que detectó no siempre está ocurriendo.
Al final, con el riesgo de molestarte, me gustaría agregar algunas ideas. Muchas personas agregaron comentarios de que las diferencias de rendimiento son bastante pequeñas y en la programación del mundo real no debería preocuparse por ellas y estoy de acuerdo con este punto de vista. Hay dos razones principales para ello.
La primera y la más anunciada es que .Net se construyó en un nivel superior para permitir que los desarrolladores se centren en los niveles más altos de 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 de método virtual. Al tener una buena arquitectura de alto nivel y centrarse en el gran rendimiento, los consumidores siempre obtendrán mejores resultados en las aplicaciones modernas en lugar de evitar las dobles referencias de punteros.
El segundo y más oscuro es que el equipo de .Net al construir el marco en un nivel superior ha introducido una serie de niveles de abstracción que el compilador justo a tiempo podría usar para las optimizaciones en diferentes plataformas. Cuanto mayor sea el acceso que otorgarían a las capas inferiores, más podrán los desarrolladores optimizar para una plataforma específica, pero menos podrá hacer el compilador en 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.