Inicio > Desarrollo, Tutorial > C#–Cómo escribir código realmente rápido

C#–Cómo escribir código realmente rápido

Vuelvo tras una larga ausencia con un tema con el que llevo luchando el último año: optimizar código para mejorar la respuesta a los usuarios tanto a nivel de servidor como de aplicación usando incluso menos recursos que antes. Y si piensas en distribuir tu aplicación en la nube, incluso puede que ahorres dinero.

Cualquiera que me conozca sabe que mi obsesión es cómo exprimir las máquinas al máximo. Y por “exprimir” no significa “tenerlas echando humo” haciendo cosas, sino más bien al contrario: que un solo servidor pueda ejecutar las mismas funcionalidades pero con un consumo mínimo de CPU y memoria. Y en C#, mínimos cambios en los programas pueden acelerarlos de forma más que notable.

No hay que pensar en segundos… toca hacerlo en milisegundos

Es lo que veo muchísimas veces hablando con otros programadores que no ven ningún problema en su código: prácticamente hagas lo que hagas hoy en día con un ordenador, si sólo lo haces una vez, es virtualmente instantáneo. Escriben código, ejecutan el programa de prueba y violà! el resultado en “menos de un segundo”. El problema es que cuando eso mismo hay que hacerlo decenas de miles de veces por minuto (o segundo!) la cosa se complica: lo que antes tardaba “menos de un segundo” ahora tarda horas. Y es donde las micro-optimizaciones o los “refactorings” complejos (que veremos como parte final de este artículo) muestran sus resultados: limando no segundos, sino milisegundos a cada operación se obtienen beneficios simplemente espectaculares en el rendimiento de nuestros programas.

Importante: hay cosas que no se pueden optimizar: hay que rehacerlas

Y no se nos deben caer los anillos por ello. Puede que algo simplemente sea lento. Muy lento. Excesivamente lento. Y es muy probable que no tenga por qué serlo y la “optimización” no sea la respuesta, sino más bien un rediseño. Y no pasa nada: es lo que he hecho durante el último año. Si estás leyendo esto pensando en que vas a encontrar una pauta de programación que apliques “en dos líneas” a tu código y que de repente funcione deprisa, deja de leer ya: si, algo ganarás, pero no mucho. Pero sí hay infinidad de cosas a saber para mejorar de forma más que notable la velocidad y el rendimiento de nuestros actuales programas sin tener que tirarlos enteros y empezarlos de cero.

El truco? Optimizar bloque por bloque, función por función, y no pensando en que “quede más bonito”, sino que quede “más rápido” (lamentablemente en muchos casos no es compatible lo uno con lo otro, pero recuerda: comenta tu código). Y siempre: usa el Profiler de Visual Studio!

Pero vamos a un ejemplo real…

new() y el recolector de basura

O “garbage collector” como muchos lo conocemos. Nos permite olvidarnos de la gestión de memoria, de acordarnos de liberar los objetos que ya no usamos, pero con un precio: reservar memoria es “caro”, pero es que la gestión de la recogida de basura es carísima. Y eso que cada generación de la máquina virtual .NET mejora particularmente en eso, en la reserva y la limpieza de la memoria.

Por supuesto no estoy diciendo que crear objetos con new() sea malo, en absoluto. El problema, particularmente con C# y otros lenguajes gestionados, es que hay montones de new()’s implícitos en infinidad de operaciones que podemos evitar. Menos reservas de memoria, más velocidad. Menos objetos que gestionar, mucha más velocidad todavía. El otro problema es asumir que el coste de hacer new() es cero: nada más lejos de la realidad (creo que sólo hay otra operación más cara que reservar memoria: lanzar una excepción, pero eso es otra historia).

Un ejemplo simple pero práctico: típico programa que recibe registros en texto separados por comas. Cuantas veces habéis visto (o escrito) algo como esto:

static int[] Decode(string data)
{
  string[] dataFragments = data.Split(new char[] { ',' });
  List dataValues = new List();
  foreach (string fragment in dataFragments)
  {
    dataValues.Add(Int32.Parse(fragment));
  }
  return (dataValues.ToArray());
}

Alguien ve algo “raro”? No?

Algo tan simple como ese new char[] { ‘,’ } tan inocente es responsable de una merma en la velocidad de la función.

Si, esta función se ejecuta en muy, muy poco tiempo – si sólo se llama una vez. El problema es si hay que llamarla miles de veces. Miles de veces crearemos un array en memoria, de un carácter de capacidad, pero toca buscar memoria, asignarla, inicializarla… Una vez no pasa nada… un par de millones de veces y la cosa se complica.

Esa es la típica situación que mejora simplemente con un mínimo cambio en el programa:

private static readonly char[] splitChars = new char[] { ',' };
static int[] Decode(string data)
{
  string[] dataFragments = data.Split(splitChars);
  List dataValues = new List();
  foreach (string fragment in dataFragments)
  {
    dataValues.Add(Int32.Parse(fragment));
  }
  return (dataValues.ToArray());
}

Tan sencillo como usar una variable estática, inicializada al principio de la ejecución del programa, y eliminamos miles y miles de reservas de memoria. Para comprobarlo, vamos a escribir un programa de pruebas que ejecute las dos funciones un millón de veces, para comprobar si hay alguna diferencia:

public static void Run()
{
  Console.WriteLine("Código inicial en ejecución.");

  string record = @"23,51,181,47,193,119,651,45,213,194,413,188";
  var start = DateTime.Now;

  int sum = 0;
  for (int dd = 0; dd < 1000000; dd++)
  {
    var dataElements = Decode(record);
    sum = 0;
    for (int zz = 0; zz < dataElements.Length; zz++)
      sum += dataElements[zz];
  }

  GC.Collect();
  var span = DateTime.Now - start;
  Console.WriteLine("Suma: " + sum + " - Tiempo total: " + span.ToString());
}

Este programa simplemente decodifica el mismo registro 1 millón de veces y, por hacer algo, simplemente suma los valores de los números (así verificamos que todas las variantes devuelven los mismos resultados). Ahora ejecutamos el programa en versión Release con las dos versiones de la función:

Base y primera optimización

Simplemente por evitar esa reserva de memoria nos ahorramos 140 milisegundos: un 5% del tiempo total (he calculado el ahorro sobre varias ejecuciones – nunca tarda lo mismo). No está mal para una sola línea modificada.

Pero vayamos un poco más allá. Hay otra reserva de memoria al final de la función, al hacer una conversión a array (return(dataValues.ToArray()). Si devolvemos directamente el objeto dataValues, sin conversión, qué pasa? Cambiemos

static int[] Decode(string data)
{
  ...
  return (dataValues.ToArray());
}

por

static List<int> Decode(string data)
{
  ...
  return (dataValues);
}

y ajustamos un poco la función de prueba para que use la propiedad Count en lugar de la propiedad Length de los arrays. El resultado:

StringOptimizationRun2

Otros 50 milisegundos que hemos limado al tiempo original. Y todavía no hemos trabajado mucho para un ahorro del 7%.

Poco más se puede rascar, verdad? Pues no… aún queda mucho por hacer.

Empieza el refactoring

Hasta ahora hemos simplemente cambiado cosas de sitio o hemos eliminado alguna reserva de memoria. Cuando ese proceso no da para más, toca pensar cómo cambiar la estructura del código para que sea más eficiente.

El siguiente responsable de reservas de memoria es el objeto List<int> que usamos para almacenar los valores mientras se leen del registro de entrada. Y si en lugar de eso usamos un array permanente para devolver los valores? Vamos con el refactoring, y aquí es donde tenemos que poner un límite a los datos.

Hasta ahora nuestro código podía soportar cantidades arbitrarias de elementos en un registro, pero es necesario realmente? O podemos poner un límite de, digamos 100 elementos? 200? 500 quizá?

Cambiemos la función para que quede como sigue:

private static readonly char[] splitChars = new char[] { ',' };
static List Decode(string data)
{
  string[] dataFragments = data.Split(splitChars);
  List dataValues = new List();

  foreach (string fragment in dataFragments)
  {
    dataValues.Add(Int32.Parse(fragment));
  }
  return (dataValues);
}

Si os fijáis la función espera ahora un array de enteros que rellenará con los valores que obtenga del registro, devolviendo como resultado el número de elementos que ha leído. Otro poco más de refactoring en la función de pruebas para que quede como sigue:

int[] values = new int[512];
int sum = 0;
for (int dd = 0; dd < 1000000; dd++)
{
  var elementCount = Decode(record,values);
  sum = 0;
  for (int zz = 0; zz < elementCount; zz++)
    sum += values[zz];
}

La función reserva primero un único array de enteros que usa para todas las llamadas a la función. Da igual que el array sea local a la función o miembro de la clase: sólo se crea uno y se reutiliza continuamente. Si ejecutamos el programa ahora con la nueva versión obtenemos lo siguiente:

StringOptimizationRun3

Ahora sí se ve una mejora realmente sólida del rendimiento, del orden nada menos que del 20% con respecto al código original.

Pero se puede ir mucho, muchísimo más lejos.

El siguiente responsable de la velocidad de la función es el Split() que hacemos de la cadena. Split tiene que fabricar internamente un List<String> para almacenar cada cadena que va truncando, reservar cada objeto String de cada elemento separado, inicializar los contenidos (sacando copias de fragmentos de la cadena original), agregarlo a la colección para finalmente invocar el método ToArray() y devolvernos el array de cadenas. Y una vez que terminamos la función, todo ese trabajo se destruye y pasa al recolector de basura. Y todo eso destruye el rendimiento, cada reserva de memoria y copias por la RAM machacan los contenidos del caché del procesador, volviéndolo prácticamente una inutilidad. Los procesadores modernos son felices leyendo memoria secuencial, y el mero concepto de crear miles de objetos desperdigados por la memoria del PC va en detrimento del rendimiento.

Pero qué se puede hacer en ese caso?

Optimización final

Pues en este caso, nada tan facil como no usar lo que parece obvio. Vamos a pensar en el registro que nos dan no como en una cadena que sólo podemos utilizar por medio de otras funciones, creando copias, fragmentándola, sino como un simple array de caracteres. Porque, curiosamente, lo es.

La versión final de la función recorre la cadena de caracteres uno por uno: si el caracter es un dígito lo acumula, si el caracter es una “coma” completa la acumulación y añade el valor al array de enteros del resultado, haciendo simplemente operaciones aritméticas simples, con las que el procesador es feliz (por cierto: si sabes C++, te sentirás como en casa):

static int Decode(string data,int[] values)
{
  int charCount = data.Length;
  int valueIndex = 0;
  int acc = 0;
  for (int dd=0;dd<charCount;dd++)
  {
    char c = data[dd];
    if ((c >= '0') && (c <= '9'))
    {
      acc *= 10;
      acc += (c & 0xF);
      continue;
    }
    if (c == ',')
    {
      values[valueIndex++] = acc;
      acc = 0;
      continue;
    }
    throw new Exception("Invalid character in data.");
  }
  // Añadir el último valor
  values[valueIndex++] = acc;

  return (valueIndex);
}

Tampoco ha sido para tanto… pero veamos cuanto hemos ganado:

StringOptimizationRun4

No, no es una broma: de un proceso que tardaba 2,5 segundos en ejecutarse se ha quedado al final en 100 milisegundos. 25 veces más rápido que el original.

Y el truco simplemente consiste en optimizar el código en bloques funcionales de tamaños como el que acabamos de ver, que permiten concentrarse en un problema concreto, determinar los requerimientos exactos y escribir un código que haga “eso” y “nada más que eso”.

Pensando en la nube

Y si realmente te estás planteando hospedar tu flamante proyecto en la nube, empezar pensando en las optimizaciones como las que acabas de ver te puede ahorrar un dineral, en éste caso concreto en el costo de máquinas virtuales, o en caso de máquinas físicas poder emplear PCs mucho más económicos con el mismo rendimiento. Pero hay que ser metódico, no puedes decirte a tí mismo “bueno, ya optimizaré al final”, porque quizá, cuando llegue el momento, descubras que el “refactoring” requiere de una reescritura completa. Y créeme, llegado ese momento ya es tarde…

Por cierto, otra pequeña optimización de la que no he hablado: cuantos componentes de .NET tenéis referidos en la sección References de vuestros proyectos? Seguro que los usáis todos? Quizá puedas eliminar alguno de ellos o más de los que crees. Y una DLL que no se carga (junto con sus propias dependencias) es otro ahorro de recursos.

El proyecto completo con todas las versiones de optimización podéis descargarlo aquí. Y podéis comprobar qué referencias usa. Sólo una: System.DLL.

Anuncios
Categorías:Desarrollo, Tutorial Etiquetas: , , ,
  1. Fernando
    18/Ene/2017 en 11:40 am

    El vinculo ^para descarga del proyecto ya no funciona, :(..podrias actualizarlo por favor…muy buen articulo

    • 22/Ene/2017 en 11:33 am

      Hola Fernando!! Gracias por el aviso – el link ya está corregido y ya puedes descargar el proyecto.

  1. No trackbacks yet.

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

Conectando a %s

A %d blogueros les gusta esto: