Algoritmos y especificaciones
Feb 19, 2020
En este artículo quiero hablar sobre 2 temas en los que creo que la mayoría de los programadores solemos fallar: Algoritmos y Especificaciones.
Me voy a ayudar de una experiencia real, ocurrida ya hace un tiempo:
Desde hace ya 4 años mantengo un nodo multimoneda (Bitcoin, Bitcoin Cash y Litecoin) llamado Knuth (a.k.a. Bitprim). En noviembre de 2017 Bitcoin Cash hizo su primer cambio de protocolo luego de su nacimiento en agosto del mismo año. Mi trabajo en ese momento era actualizar el código de nuestro nodo para que soporte los cambios de protocolo. Desde aquel momento que quiero escribir este artículo, pero… por alguna o varias razones no lo hice en ese momento, lo estoy haciendo ahora.
El cambio más importante de fue en el Algoritmo de Ajuste de Dificultad, en adelante DAA (del inglés Difficulty Adjustment Algorithm).
Aquí la descripción del algoritmo.
No quiero entrar en detalles acerca del concepto de dificultad ni del DAA. Para ello puede referirse a: Difficulty.
Lo que me interesa son los puntos 2 y 3 de la descripción del DAA:
Ambos apuntan a la nota al pie [2]
:
La especificación del algoritmo apunta a su implementación, en una función llamada GetSuitableBlock. Aquí su código:
Lo que hace el algoritmo es básicamente crear una secuencia de 3 elementos (array), la ordena de menor a mayor y retorna el segundo elemento.
La complejidad en tiempo de este algoritmo es:
- Caso mejor: 0 swaps, 3 comparaciones
- Caso peor: 2 swaps, 3 comparaciones
- Caso promedio: 7/6 swaps, 3 comparaciones; asumiendo una distribución uniforme de los datos de entrada.
Ahora, vuelva a observar con detenimiento el algoritmo. Se está creando un array (usando los datos de entrada), para luego ordenarlo ascendentemente y retornar el elemento del medio. Este es un algoritmo conocido y se llama mediana, en particular, mediana de 3 elementos.
La mediana es un algoritmo de selección. A diferencia de los algoritmos de ordenamiento (inplace), los algoritmos de selección no deberían mutar los datos de entrada, sino, retornar uno de ellos.
Aquí les dejo un boceto del algoritmo mediana de 3, en C++
:
O si prefiere la versión inline del algoritmo:
Dejo el análisis del código para el lector, para el vago: lo que hace el algoritmo es simplemente seleccionar el elemento del medio entre a
, b
y c
, haciendo de cuenta que los 3 estuviesen ordenados ascendentemente. Esto lo hace sin mutar ni reordenar los datos de entrada.
La complejidad en tiempo de median_3
es:
- Caso mejor: 0 swaps, 2 comparaciones
- Caso peor: 0 swaps, 3 comparaciones
- Caso promedio: 0 swaps, 8/3 comparaciones; asumiendo una distribución uniforme de los datos de entrada.
Ahora, podríamos usar nuestro nuevo algoritmo en la función GetSuitableBlock
original:
Mucho más breve y entendible, ¿no?.
Antes de seguir, tenemos que corregir un problema: no sabemos si el Ordenamiento Natural especificado en la clase CBlockIndex
está dado por el timestamp del bloque (atributo nTime
).
Necesitamos una versión de median_3
que acepte una forma de comparar especificado por el usuario: necesitamos que acepte una relación de preorden total estricta (strict weak ordering relation, para más información consulte aquí).
Ahora sí, podemos implementar correctamente GetSuitableBlockNewVersion
, comparando por nTime
:
Nos queda un último problema por resolver. Hagamos una pequeña prueba del algoritmo original y del nuevo:
El código anterior imprime:
Lo que estamos intentando probar con el código anterior es la estabilidad de ambos algoritmos. Nuestro algoritmo median_3 es estable lo que quiere decir que el orden de los elementos equivalentes se preserva (para más información consulte aquí).
Para demostrarlo con datos, vamos a utilizar el ejemplo anterior, en el cual tenemos los siguientes datos de entrada para nuestros algoritmos:
Donde el primer elemento de cada par es el identificador del bloque nHeight
y el segundo elemento es el timestamp nTime
.
Note que el nTime
de los primeros 2 elementos es igual.
Si ordenamos la secuencia anterior por nTime
usando un algoritmo de ordenamiento estable, como por ejemplo Merge sort nos quedaría algo así:
Note que el elemento del medio es el que tiene nHeight = 1
. Lo cual indica que nuestro algoritmo se comportó de manera estable pero no así el algoritmo original usado en el DAA de Bitcoin Cash.
En mi primera implementación de DAA en el nodo Bitprim usé un código similar a median_3
el cual también era estable, dado que no había verificado el código de la especificación, yo había asumido erróneamente que también era estable.
Luego esto provocó errores en tiempo de ejecución de nuestro nodo ante un ajuste de dificultad. No se daba siempre, pero hubo un caso en particular en el que lo pudimos detectar. Luego de varias horas de debugging pude detectar que el problema era que el algoritmo usado por mí no era compatible con el “especificado” en DAA.
Por lo tanto, tuve que “corregir” mi algoritmo para hacerlo no-estable de la misma forma que el de la especificación.
En realidad, si mal no recuerdo, en la primera versión de la especificación de DAA no se mencionaba al código de GetSuitableBlock
, sino que decía que se calculaba la mediana de 3 elementos. Como la implementación de la mediana fue “incorrecta” tuvieron que adaptar la especificación para que se condiga con el código.
Tenga en cuenta, que una vez que el código de un nodo Bitcoin o de cualquier criptomoneda está en funcionamiento, una modificación en su comportamiento introduce incompatibilidades con versiones anteriores y produce los denominados forks. Por lo que una vez que el código está corriendo, se trata de no cambiarlo. Por esta razón es por la que se tuvo que adaptar la especificación en vez de corregir el código.
Antes de terminar, hagamos una comparación de ambos algoritmos, GetSuitableBlock
vs. median_3
:
median_3
no efectúa ningún swap,GetSuitableBlock
puede efectuar entre 0, 7/6 o 2 swaps, innecesariamente. (Eficiencia)GetSuitableBlock
crea un array innecesariamente. (Eficiencia)median_3
realiza 2, 8/3 o 3 comparaciones,GetSuitableBlock
realiza siempre 3 comparaciones. (Eficiencia)median_3
es estable,GetSuitableBlock
no lo es.median_3
es lo que cualquiera espera de un algoritmo que calcule la mediana de 3 elementos. (Correctitud)
Y ahora sí, para finalizar, algunas conclusiones:
El autor de la especificación de DAA podría haber optado por usar un algoritmo conocido y “estándar”, pero no lo hizo. Es más, quizás lo peor de todo esto es que la especificación hace referencia al código. El código no debe ser nunca especificación. El código debe ser creado a partir de una especificación. Por lo que si una especificación hace referencia a código, no existe dicha especificación.
¡Saludos!
Share