Página principal
Artículos y trucos
Catálogo de productos
Ejemplos y descargas
Mis libros
Cursos de formación
Investigación y desarrollo
Libros recomendados
Mis páginas favoritas
Acerca del autor
 
En colaboración con Amazon
 
Intuitive Sight

Expansión en línea en C#

Expansión en línea es mi traducción de inlining, una técnica de optimización que en la plataforma .NET es responsabilidad del módulo que traduce las instrucciones en IL, el lenguaje intermedio, a código nativo. A pesar del título rimbombante, es una técnica muy fácil de explicar: consiste en sustituir determinadas llamadas a métodos por las instrucciones ejecutables de la implementación de estos métodos.

INLINING

Para explicarnos mejor, veamos un ejemplo muy sencillo:

public class ListaClientes: IEnumerable {
  private ArrayList listaInterna = new ArrayList();

  public int Count {
    get { return listaInterna.Count; }
  }
  public Cliente this[int index] {
    get { return (Cliente) listaInterna[index]; }
  }
  public int Add(Cliente value) {
    return listaInterna.Add(value);
  }
  IEnumerator IEnumerable.GetEnumerator() {
    return ((IEnumerable) listaInterna).GetEnumerator();
  }
}

La clase anterior permite definir una lista de clientes, con una funcionalidad bastante básica; se supone que la clase Cliente está definida en algún otro lugar. Nuestra implementación de la lista se basa en una instancia privada de la clase ArrayList (un equivalente aproximado del TList de Delphi). ArrayList permite trabajar con cualquier objeto: internamente maneja referencias de tipo object. Por lo tanto, nuestra fina capa de código sirve para que la lista sólo acepte objetos de la clase Clientes, y para que devuelva objetos de este tipo.


Este tipo de trucos dejará de ser necesario a partir de la versión 2 de .NET, gracias a la introducción de los tipos genéricos.

El problema de ListaClientes es que nos obliga siempre a ejecutar una llamada a método adicional, en comparación con lo que sucedería si utilizáramos ArrayList directamente. Las llamadas a métodos son inevitablemente costosas. Entre otras cosas, en dependencia de la "cercanía" de los dos puntos dentro del código, puede que la llamada obligue a vaciar la caché de código del procesador.

Observe también que el problema no se limita a métodos como Add, sino que también se presenta cuando utilizamos la propiedad Count o el indizador de la clase. Teóricamente, el problema podría agravarse porque las propiedades en .NET se implementan siempre como métodos de acceso. En Delphi "clásico", por el contrario, podíamos declarar propiedades como la siguiente:

type
  MiClase = class
  private
    FPropiedad: Integer;
  public
    property Propiedad: Integer read FPropiedad write FPropiedad;
  end;

En C#, la declaración tendría que escribirse así:

class MiClase {
  private int propiedad;
  public int Propiedad {
    get { return propiedad; }
    set { propiedad = value; }
  }
}

Aunque no se puede deducir del código anterior, C# crea métodos internos para la propiedad, a partir de las instrucciones asociadas a las cláusulas get y set. ¿Significa esto que las propiedades en C# están condenadas a ser ineficientes? ¡Nada de eso! El código IL generado por el compilador de C# siempre incluirá una instrucción call para ejecutar los métodos correspondientes. Pero el módulo encargado del JIT (just-in time translation), puede eliminar esa llamada y sustituirla por las instrucciones pertenecientes al método original.

EXPANSION EN LINEA EN .NET

La técnica de expansión en línea es bastante antigua. El ejemplo más conocido es quizás el de C++, en el que podemos decidir cuáles funciones o métodos serán expandidos durante la compilación. Hay dos formas de hacerlo:

  1. Incluyendo la implementación de la función en un fichero de encabezamiento (.h, .hpp).
  2. Utilizando la directiva inline en la declaración de la función.

En este modelo, es el programador quien decide cuáles funciones "merecen" ser expandidas y cuáles no, y el compilador es el responsable de la expansión. Como detalle, el compilador puede ignorar, si lo considera apropiado, una petición de expansión. Esto puede suceder si la función a expandir es demasiado compleja, según criterios internos del compilador.

¿Es bueno el modelo de inlining de C++? Evidentemente, no:

  1. El programador no siempre es capaz de decidir si le conviene expandir una función o no. Es complicado predecir el número de bytes que ocupará la expansión, o el coste adicional que puede implicar el mantenimiento de variables locales y parámetros. La expansión en línea debe decidirse de forma automática, sin intervención humana.
  2. Si estamos diseñando una clase para incluir en una biblioteca, el modelo de C++ nos hace perder oportunidades de expansión. Supongamos que la implementación de la función foo es relativamente grande. Si una aplicación utiliza esta función varias veces, está muy claro que la expansión en línea sería muy mala idea. Pero si la función se utiliza sólo una vez, las cosas cambian, y puede que la expansión sea provechosa. Eso sí, habría que sopesar el coste añadido del mantenimiento de variables locales y parámetros.
  3. Un problema muy particular de C++ es que, para poder utilizar una biblioteca de clases compilada por separado, tenemos también que tener a mano los ficheros .h o .hpp con las declaraciones, para disponer también de las funciones declaradas inline mediante la técnica de incluir sus implementaciones en estos ficheros.

El primer punto nos demuestra que la decisión de expandir una función no debe ser responsabilidad del programador. Pero los restantes dos puntos sugieren que tomar esa decisión durante la compilación no es lo mejor. ¿Qué nos queda? Nos quedan el enlazador... y el JITter, en tiempo de ejecución. La plataforma .NET soporta estas dos posibilidades. En estos momentos, la optimización en tiempo de enlace es una opción que está disponible solamente para aplicaciones creadas en C++ with Managed Extensions. Como en estos momentos estoy usando solamente C#, voy a concentrarme en la optimización efectuada por el JITter.

Cuando .NET carga una aplicación con instrucciones IL, la traducción de estas instrucciones a código nativo ejecutable no tiene lugar de golpe. Inicialmente, sólo se traduce la función Main que sirve como raíz del programa. Cada vez que se traduce código, las llamadas a métodos se convierten en llamadas a "falsos" métodos que, en realidad, activan la traducción del método que se va a ejecutar a código nativo. De esta manera, se propaga la traducción a partir del árbol de llamadas cuya raíz es Main. Por supuesto, una vez traducido un método, se actualizan las llamadas a éste para que ya no tengan que pasar por el JITter.

La explicación anterior es solamente aproximada, porque no todos los detalles de este proceso son públicos, y porque está muy claro que la técnica empleada puede variar en las sucesivas versiones del CLR. En particular, las condiciones exigidas por el JITter para transformar una llamada a función en una expansión en línea no son del todo conocidas. He aquí algunos de los detalles que se conocen:

  1. Si la aplicación es ejecutada por un debugger, como sucede en Visual Studio al pulsar F5, se desactiva globalmente el algoritmo de expansión, para facilitar la depuración.
  2. La longitud del código de la función es decisiva. Si esta supera cierto umbral, alrededor de los 32 bytes, se descarta el inlining.
  3. No deben existir saltos "complicados" en la función a expandir. Solamente se admiten los saltos generados por la instrucción if/then/else. Si una función utiliza while o switch, no se considera candidata al inlining. Tampoco se intenta la expansión de métodos que incluyan las instrucciones try/catch o try/finally.
  4. Se descartan también aquellos métodos que tengan parámetros de tipo estructura (struct).
  5. Si se trata de una llamada a un método virtual, no hay expansión. En teoría, ésta sería posible si el método no ha sido redefinido en alguna clase derivada; de hecho, la llamada virtual podría convertirse entonces en una llamada "directa", como mínimo. Pero de momento, el JIT de tiempo de ejecución no parece tener en cuenta este caso especial.
  6. Si la clase a la que pertenece el método que vamos a llamar desciende de la clase MarshalObjectByRef, no se produce la expansión en línea.

El último punto es quizás el más interesante: MarshalByRef es la clase base de todas las clases que admiten llamadas remotas. Y la más popular de ellas es Form, la clase base de los formularios de Windows Forms. Para ser más exactos, las clases Component y Control son descendientes de MarshalObjectByRef. La principal consecuencia es que no tendremos inlining para propiedades y métodos definidos dentro de controles y formularios.

PRECOMPILACION CON NGEN.EXE

Veamos ahora una curiosa implicación de la técnica de inlining del CLR. Cuando a un programador le hablan por primera vez de .NET y el CLR, casi siempre le alivia saber que existe una aplicación, ngen.exe, que permite precompilar en código nativo cualquier aplicación en código IL. La sombra de la Java Virtual Machine y su desastroso rendimiento planea sobre las mentes de toda una generación de programadores.

Por más que aseguremos al programador novato que el CLR de .NET es completamente diferente, éste asentirá, pero en el fondo de su corazón esperará a la mínima oportunidad para recurrir a ngen. ¿Es aconsejable utilizar esta herramienta? Depende. Si se trata de una aplicación cliente, principalmente las de tipo interactivo, puede ser provechoso precompilar la aplicación a código nativo. A pesar de su eficiencia, el JIT en tiempo de ejecución puede retrasar un poco la ejecución por primera vez de una sección del programa. En una aplicación interactiva, de interfaz gráfica, se busca minimizar los tiempos de espera por parte del cliente.

Sin embargo, si se trata de una aplicación que se va a ejecutar en el lado servidor, no se aconseja el uso de ngen. Por una parte, pierde importancia el posible retardo creado por la traducción a código nativo en tiempo de ejecución... porque una vez que la aplicación esté en memoria, allí quedará para atender peticiones remotas. Lo mismo puede aplicarse a las aplicaciones de servicio, independientemente de su ubicación en la topología de red.

Pero el argumento principal contra el uso de ngen en este tipo de aplicaciones es que el código traducido en tiempo de ejecución por el JITter, puede ser mucho más eficiente que el código generado por ngen. ¿Sorprendente? Sin embargo, la explicación es sencilla: la ejecución de ngen tiene lugar sobre un ensamblado cada vez. Mientras no se sepa a ciencia cierta todo el código que tendrá que cargar finalmente la aplicación, no podremos aplicar muchas de las posibles optimizaciones. Por esta causa, la técnica de inlining de ngen es menos "agresiva" que la llevada a cabo por el JITter en tiempo de ejecución.