miércoles, mayo 10, 2006

Intento fallido

Después de haber publicado la entrada anterior en la bitácora (En busca del tiempo perdido) y el correspondiente truco en mi página (Hell will freeze), me di cuenta de que había una posibilidad de transformar llamadas virtuales en llamadas "normales", sin necesidad de generar o modificar código a la medida... y sin necesidad de provocar una explosión combinatoria en el número de clases necesarias. Por supuesto, me sentí como un idiota por haber publicado el planteamiento de un problema aparentemente abierto sin percatarme de que existía una solución casi trivial.
Pero no cante aún victoria, amigo. He hecho un ensayo, para comprobar que la solución realmente funcionaba y... ¡fiasco!: el entorno de ejecución no parece efectuar la optimización necesaria. He mirado el código IL, el código nativo generado por el compilador just in time, y para estar completamente seguro, he medido los tiempos de ejecución.
De todos modos, creo que es bueno que le cuente en qué consistía la cura maravillosa que no funciona. Es un ejemplo, a pesar de todo, del tipo de oportunidades que ofrecen los tipos genéricos de .NET... porque mi solución consistía en utilizar, precisamente, genericidad.
XSight RT utiliza continuamente una interfaz llamada IShape. Voy a mostrarle solamente un método de ella, y voy a omitir parámetros y valor de retorno para simplificar la explicación:
public interface IShape
{
void ShadowTest();
}
Todas las figuras básicas (esferas, cubos, cilindros) se representan como clases que implementan IShape. Estas clases siempre se declaran "selladas" (sealed), para que al impedir derivar nuevas clases a partir de ellas, los compiladores puedan aplicar determinadas optimizaciones.
Hay más clases que implementan IShape. Tenemos, por ejemplo, las transformaciones euclideanas clásicas, como la rotación. La clase Rotate, en realidad, usa IShape por partida doble. Primero, implementa esta interfaz. Además, Rotate debe indicar cuál es el objeto que debe rotar. Naturalmente, esto se implementa mediante una variable llamada original, declarada con el tipo IShape:
public sealed class Rotate : IShape
{
private IShape original;
// ...
void IShape.ShadowTest() {
// Se rota un rayo en sentido inverso...
// y se llama a ShadowTest sobre "original".
original.ShadowTest();
}
}
La llamada al método ShadowTest de original es la que me gustaría optimizar. Es necesaria en el caso general, pero cuando se sabe a ciencia cierta que la escena está formada por una rotación que se aplica a un cubo, se podría sustituir la llamada virtual por una llamada a una ubicación fija en el segmento de código.
Mi idea consistía en convertir Rotate en una clase genérica:
public sealed class Rotate<T> : IShape
where T: class, IShape
{
private T original; // ¡ATENCION!
// ...
void IShape.ShadowTest() {
// ...
original.ShadowTest();
}
}
Para el compilador de C#, la variable original, aunque ahora su tipo es parámetro de tipo T, puede seguir apuntando alegremente a una esfera, a un cubo, o a cualquier clase que implemente IShape. Por lo tanto, cuando se genera código IL, la llamada conflictiva a ShadowTest sigue siendo una llamada virtual.
Ahora bien, el compilador JIT, es decir, el que traduce a código nativo al ejecutar, tiene más información a su alcance, y puede mejorar el código final que se ejecutará. Volvamos a la escena del cubo rotado. El compilador JIT tendrá que encargarse de instanciar el tipo genérico Rotate<T> y generar el tipo cerrado Rotate<Cube>. En esta clase cerrada generada en ejecución por .NET, la variable original es ahora de tipo Cube... ¡y esta es una clase sellada! El compilador JIT podría optimizar la llamada a ShadowTest porque ahora sabe a ciencia cierta que el código que se ejecutará es la versión de ShadowTest implementada por la clase Cube.
... pero el compilador JIT no optimiza esa llamada. Es lo que acabo de comprobar experimentalmente. No veo motivo alguno por el que no se pueda proceder así, e imagino que en alguna futura versión de .NET, el JITter podrá llegar a estas profundidades. De momento, todo indica que no lo hace.
He aprendido algo más gracias al experimento. En XSight RT, la funcionalidad básica de las figuras geométricas se expresa mediante un tipo de interfaz, IShape. Pero también podría haber definido los métodos y propiedades comunes por medio de una clase base abstracta, de la que deberían descender directa o indirectamente todas las demas clases de figuras. Lo que he averiguado, sin embargo, es que las llamadas a estos métodos comunes virtuales son más lentas cuando se usa la técnica de la clase base abstracta que cuando se utiliza un tipo de interfaz. En realidad, ¡son casi tres veces más lentas! La verdad es que me ha sorprendido tanta diferencia, por lo que pienso seguir investigando este misterio.

Etiquetas: ,

7 Comments:

Blogger PabloNetrix said...

Pues quizás sea un buen tema que plantearle a algún miembro del equipo de desarrollo de .NET de estos que tienen blogs, éste de la "lentitud" en llamadas a métodos virtuales implementados en clases abstractas. ¿Seguro que será cosa del JIT y no del propio CLR? Sé que va a sonar casi a insulto ;) pero, ¿Has tenido en cuenta los retrasos en la ejecución del ensamblado que el JIT produce en esa "primera" compilación? ¿Está el ensamblado en la GAC?

Saludos

miércoles, mayo 10, 2006 10:27:00 a. m.  
Blogger Ian Marteens said...

Uff... es un poco más complicado que eso. No se puede hablar de lentitud, sino de posibilidad de optimización desaprovechada. Y habría que saber entonces cuál sería el coste de una optimización de este tipo. Te menciono un problema que veo, de entrada: ahora, la implementación de Rotate<ClaseNoSellada> y Rotate<ClaseSellada> son iguales, y eso permite compartir código nativo entre las dos implementaciones. Si se optimiza el segundo caso, se necesitan dos copias ligeramente diferentes del código nativo. Ya estamos aumentando la huella en memoria, la frecuencia de fallos de páginas, y naturalmente, el JITter tardaría también más en hacer su trabajo. ¿Estaría justificado este trabajo adicional? Probablemente no, para el caso general. Ten en cuenta que el ejemplo del ray tracer es muy raro. ¿Cuántas aplicaciones se beneficiarían de un sistema de generación dinámica de código como la que describía en el post original?

Respecto a la llamada con diferentes costes, este es el código generado cuando IShape es una clase base abstracta:

mov ecx,dword ptr [esi+4]
mov eax,dword ptr [ecx]
call dword ptr [eax+38h]

Cuando IShape es un tipo de interfaz:

mov ecx,dword ptr [esi+4]
call dword ptr ds:[00970024h]

El código IL está generado con la optimización activa, y no se han desactivado las optimizaciones JIT (VS no funciona así por omisión: hay que configurarlo para poder ver el código nativo "real" que se generará para la aplicación final). Está claro que, a pesar de tratarse de llamadas indirectas en ambos casos (el argumento no es una constante numérica que indica una dirección, sino la dirección de una zona en memoria que contiene la dirección adonde hay que saltar), el segundo caso exige menos tiempo de ejecución. Es incluso posible que la propia CPU "aprenda" qué demonios hay en DS:[00970024h], pueda predecir el salto y afecte menos el contenido de la caché.

Pero ahora mismo no estoy en condiciones de explicar por qué se generan dos secuencias tan diferentes. Todo el código, además, reside en un mismo ensamblado ejecutable.

miércoles, mayo 10, 2006 2:52:00 p. m.  
Blogger PabloNetrix said...

cita:
"este es el código generado cuando IShape es una clase base abstracta:

mov ecx,dword ptr [esi+4]
mov eax,dword ptr [ecx]
call dword ptr [eax+38h]

Cuando IShape es un tipo de interfaz:

mov ecx,dword ptr [esi+4]
call dword ptr ds:[00970024h]
"

hmmmm.........

¿Y no será que según el tipo de objeto de la instancia, éste se crea en la pila (stack) o en el Heap? Supongo yo que es posible que de esa manera el compilador "sepa" de antemano a qué dirección apunta el puntero en el caso de la interfaz, lo que le permite meter dicha dirección "hardcoded" en el ensamblado.

He encontrado esto, igual sirve de algo.

Saludos

miércoles, mayo 10, 2006 5:34:00 p. m.  
Blogger PabloNetrix said...

Ah, y esto otro.


Saludos

jueves, mayo 11, 2006 12:32:00 a. m.  
Blogger Ian Marteens said...

¿Y no será que según el tipo de objeto de la instancia, éste se crea en la pila (stack) o en el Heap?

No: se trata de referencias todo el tiempo. Lo de la "no optimización" lo doy por asumido: de todos modos, el rendimiento de la versión 1 a la 2 ha mejorado espectacularmente.

Pero sigo sin entender la otra diferencia. No es tampoco una alerta de bug, ni nada grave: yo siempre he sido partidario de, en este tipo de sistemas basados en contenedores polimórficos, definir la funcionalidad básica mediante interfaces, no mediante clases abstractas. La interfaz "obliga a menos". Además, te deja más libertad para organizar la jerarquía de clases como te apetezca, dedicándola entonces a "heredar código" cuando sea posible. Lo único a señalar: en las recomendaciones de Microsoft para la CLR sigue apareciendo que se usen las interfaces en muy contados casos.

La confusión metodológica viene dada por el uso de interfaces a través de fronteras de aplicaciones, como en el remoting. En un sistema como COM+, un mandamiento ineludible es que, una vez definida una interfaz, jamás de los jamases debe ser modificada. Eso está bien... pero ¿quién me va a decir a mí lo que puedo hacer o no con las interfaces que define para consumo propio?

Lo que voy a hacer respecto a este problema de la llamada virtual es crear una jerarquía pequeña, con cuatro o cinco clases, y subir el ejemplo para quien quiera echarle un vistazo. Conozco un par de personas que están muy puestas con la CLR, e intentaré darles un toque.

jueves, mayo 11, 2006 5:10:00 p. m.  
Anonymous Anónimo said...

..Y lo bien que haces en sentirte un idiota....

martes, mayo 16, 2006 9:25:00 p. m.  
Blogger Ian Marteens said...

Sí, en efecto: pregúntale a tu puta madre lo bien que babeo encima de ella.

miércoles, mayo 17, 2006 12:55:00 a. m.  

Publicar un comentario

<< Home