jueves, enero 03, 2008

El objeto activo

¿Qué se supone que tiene que pasar cuando se ejecute este código?
using System;
public
Program = class
public
method Test: Boolean => Self <> nil;
        static method Main;
begin
var Instance: Program := nil;
Console.WriteLine(Instance.Test);
end;
end
end

Janie's got a gunLa respuesta breve es que se imprimirá False.
La respuesta larga tiene que ver con uno de los objetivos de Freya: experimentar con la eficiencia del lenguaje, y en este caso, entra en escena el código IL que se genera para la llamada a un procedimiento. Hay dos de estos códigos: call y callvirt. El segundo de ellos se utiliza, evidentemente, para ejecutar métodos virtuales, pero curiosamente, también puede ocuparse de métodos no virtuales, como es el caso de nuestro método Test. En tal caso, la diferencia respecto a call es que, al generarse el código nativo final, callvirt añade una verificación del puntero de instancia. Si este es nulo, excepción al canto.
Resulta que callvirt es utilizado deliberadamente por C# para casi todas las llamadas a métodos de instancia, sean virtuales o no. Esta es la forma de satisfacer la especificación del lenguaje, que exige que rueden cabezas si se pasa un nulo como objeto activo a un método de instancias. Es también interesante que, en contadas ocasiones, C# puede optimizar la llamada si sabe a ciencia cierta que el puntero no será nulo. ¿Cuándo puede tener esta certeza? Por ejemplo, cuando el objeto pasado se acaba de construir. Un método ejecutado sobre una expresión new no necesita esta verificación.
Freya, por el contrario, siempre usa call para métodos no virtuales, con lo que logra una ganancia en velocidad mínima, pero notable, sobre C#. Hay una ganancia también en el tamaño del código nativo, que se traduce en un uso más eficiente de la memoria, y a la larga, en más velocidad: la comprobación de no nulidad la ejecuta quien llama, por lo que se repite innumerables veces a lo largo del código. El coste: la especificación de Freya no exige que la instancia activa sea no nula. De ahí que tengan sentido ejemplos como el mostrado.
La pregunta es, naturalmente, si es peligrosa esta "relajación". No lo es, por supuesto:
  1. Si se pasa una referencia nula como objeto activo a un método que no lo permite, la excepción se va a producir, de todos modos, en cuanto el objeto activo intente tocar algún campo o método de instancia. Es decir: el error se detectará, aunque algo más tarde.
  2. En realidad, la no nulidad del objeto activo puede verse como una precondición en el contrato del método, en los lenguajes que la exigen: se supone que el software correctamente escrito nunca violará la precondición. Pero en ese caso, si en el software final se mantiene la verificación, ¡estaremos comprobando una y otra vez si se produce lo imposible!
A mediano plazo, Freya deberá incorporar aserciones especiales sobre la nulidad de campos y parámetros. Esto traerá algunas novedades interesantes al lenguaje. Piense un momento: si digo que el campo C, de tipo Object, no puede ser nulo, ¿cómo vamos a tratar el hecho de que el CLR lo inicialice precisamente como nulo? La clave está en añadir inicializadores de campos a los constructores, al estilo C++:
// En una clase Stack[X]...
constructor(Capacity: Integer) :
Items(new Array[X](Capacity)),
Inherited
begin
end
;
El CLR permite que estas inicializaciones de campos se ejecuten incluso antes de la llamada al constructor de la clase base: es así, de hecho, como se ejecutan los actuales inicializadores de campos de C# y Freya. La diferencia es que, con el nuevo recurso, los inicializadores de campos podrán utilizar los parámetros del constructor, algo que ahora mismo es imposible.
Por cierto, el último listado puede abreviarse a lo siguiente:
constructor(Capacity: Integer) :
Items(new Array[X](Capacity));
La llamada al constructor por omisión heredado se asume, y el bloque de instrucciones, al estar vacío, se puede omitir. Un constructor paralelo, sin parámetros, se programa en Freya de este modo:
constructor : Self(128);
Las instancias especiales base y this de C# se sustituyen en Freya por los identificadores especiales Inherited y Self.

... quien haya depurado una aplicación .NET en código nativo, se habrá encontrado con instrucciones aparentemente absurdas como ésta, justo antes de una llamada a rutina:
cmp byte ptr [rax],0
Se trata de la verificación de no nulidad, en este caso, en código de 64 bits. En un procesador de 32 bits, he visto esta variante:
cmp byte ptr [eax],eax
Al parecer, la diferencia debe estar en el tamaño de ambas instrucciones. Pero en ambos casos, el principio es el mismo: si EAX o RAX tienen un puntero no nulo, la instrucción hará una comparación rápida e inofensiva. En caso contrario, la memoria a la que apunta el registro estará en la zona prohibida, y el propio procesador levantará la liebre... quiero decir, la excepción.

Por último, la posibilidad de simular el traspaso de un objeto activo nulo la dan los métodos de extensión:
public static class StringExtender
{
public static bool IsEmpty(this string s)
{
return string.IsNullOrEmpty(s);
}
}
Si se puede lograr por medio de métodos de extensión, ¿qué hay de malo en permitirlo para los métodos de instancia?

Etiquetas: , ,

jueves, febrero 15, 2007

Peephole optimization

Compiler information
La imagen pertenece al diálogo de información sobre la compilación de Hammer, el entorno de pruebas para Freya. Como puede ver, he implementado un peephole optimizer. Esto es: un optimizador que trabaja sobre el código final generado, que en este caso, es el código intermedio de .NET. El optimizador analiza la cadena de códigos ejecutables, en pequeños fragmentos, y aplica ciertas reglas de reescritura preprogramadas. Por ejemplo, un branch to return es un salto incondicional que va a parar a un retorno de método. Estos saltos se eliminan, sustituyéndolos directamente por un retorno de método: así se ahorra espacio, y el tiempo que consumiría el salto.
Para ser justos, el compilador normalmente genera código de buena calidad, de modo que este paso de optimización no produce cambios espectaculares. Además, el código IL no permite muchas transformaciones, al tratarse de la simulación de una máquina de pila. En una máquina de pila, cada operación es, en cierto sentido, insensible a las operaciones que la siguen o preceden; sólo importa el contenido de la pila. De todos modos, ya he detectado unas cuantas oportunidades de mejorar el código en esta parte de la compilación.
En cualquier caso, este módulo era necesario: el generador de código IL que ofrece Reflection.Emit no es gran cosa, pues obliga a indicar explícitamente el tamaño de los códigos de saltos. Si un salto en IL se produce a una instrucción que queda a menos de 127 bytes, se puede usar un código de salto especial para que la instrucción, incluyendo el desplazamiento del salto, quepa en dos bytes. En caso contrario, ocuparía cinco bytes. El generador predefinido no ayuda para nada en la decisión del tamaño del salto. El módulo de optimización de Freya es el encargado de tomar estas decisiones, de manera automática.

Etiquetas: ,

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: ,