Cuando los milisegundos cuentan: conversión de hexadecimal a bytes y viceversa

El progreso de la industria y la ciencia de la computación coloca a nuestra disposición cada día un número mayor de herramientas y frameworks de alto nivel, que nos permiten concentrar nuestros esfuerzos en la modelización y conceptualización de las aplicaciones que desarrollamos. Ello sin duda es muy bueno, recuerdo que hace veinte años para construir un software había que estar al tanto de hasta el último pixel de la interfaz.  Sin embargo, percibo que los nuevos programadores, excepto quizás en algunos círculos académicos han ido perdiendo todo aquel bagaje de pequeños y útiles algoritmos de la época en que el hardware obligaba a optimizar el código desde la misma concepción.

El desarrollo de aplicaciones servidores que atienden miles o millones de clientes simultáneamente obliga a rescatar aquellos “trucos” en aras de un uso óptimo de los recursos de hardware. Su empleo puede marcar la diferencia por ejemplo entre un servidor que responde y uno “muerto”.  A continuación les dejo un ejemplo en C# que ilustra el planteamiento anterior, se trata de convertir una cadena de caracteres hexadecimales al correspondiente arreglo de bytes y viceversa.

Dada la cadena:

string hex = “AABBCCDDEEFF00112233445566778899”;

construiremos una función ConvertHexStringToByteArray que retorne el arreglo de bytes:

{byte[16]}    [0]: 170    [1]: 187    [2]: 204    [3]: 221    [4]: 238    [5]: 255    [6]: 0    [7]: 17    [8]: 34    [9]: 51    [10]: 68    [11]: 85    [12]: 102    [13]: 119    [14]: 136    [15]: 153

y otra ConvertByteArrayToString que tomando como parámetro el arreglo retorne la misma cadena en hex.

Usando las primitivas de C# quedarían:

public static byte[] ConvertHexStringToByteArray_Method1(string hex)
        {
            if (hex.Length % 2 != 0)
                throw new Exception("La cadena no puede ser impar");

            byte[] result = new byte[hex.Length / 2];
            for (int i = 0; i < hex.Length / 2; i++)
            {
                result[i] = byte.Parse(hex.Substring(2 * i, 2), System.Globalization.NumberStyles.HexNumber);
            }
            return result;
        }

        public static string ConvertByteArrayToHexString_Method1(byte[] b)
        {
            StringBuilder sb = new StringBuilder(b.Length * 2);

            for (int i = 0; i < b.Length; i++)
            {
                sb.Append(b[i].ToString("X2"));
            }
            return sb.ToString();
        }

Pero sucede que en la práctica realizar miles o millones de iteraciones de estas funciones resulta extremadamente costoso debido principalmente a la implementación de la función Parse de .NET y a la conversión de un carácter a hexadecimal usando ToString(“X2”).  Las versiones a continuación, basadas en técnicas simples de la aritmética binaria, cumplen la misma funcionalidad y resultan cientos de milisegundos más eficientes que las originales.

public static string ConvertByteArrayToHexString_Method2(byte[] b)
        {
            char[] c = new char[b.Length * 2];
            byte curByte;

            for (int y = 0, x = 0; y < b.Length; ++y, ++x)
            {
                curByte = ((byte)(b[y] >> 4));
                c[x] = (char)(curByte > 9 ? curByte + 0x37 : curByte + 0x30);
                curByte = ((byte)(b[y] & 0xF));
                c[++x] = (char)(curByte > 9 ? curByte + 0x37 : curByte + 0x30);
            }

            return new string(c);
        }

        public static byte[] ConvertHexStringToByteArray_Method2(string hex)
        {
            if (hex.Length % 2 != 0)
                throw new Exception("La cadena no puede ser impar");

            byte c1, c2;
            byte[] result = new byte[hex.Length / 2];
            int l = hex.Length;

            for (int i = 0, cur = 0; i < l; i++, cur++)
            {
                c1 = (byte)hex[i];
                if (c1 > 0x60) c1 -= 0x57;
                else if (c1 > 0x40) c1 -= 0x37;
                else c1 -= 0x30;
                c2 = (byte)hex[++i];
                if (c2 > 0x60) c2 -= 0x57;
                else if (c2 > 0x40) c2 -= 0x37;
                else c2 -= 0x30;
                result[cur] = (byte)((c1 << 4) + c2);
            }
            return result;
        }

Estos son los resultados obtenidos en un mini benchmarking de 10000 iteraciones con ambos métodos:

Byte 2 Hex: Method 1: 469 ms – Method 2: 100 ms

Hex 2 Byte: Method 1: 44 ms – Method 2: 5 ms

Finalmente, tampoco se trata de sacrificar claridad por optimización, al menos en las primeras etapas del desarrollo pero, cuando los milisegundos cuentan …