• No se han encontrado resultados

Biblioteca de números grandes en C++

N/A
N/A
Protected

Academic year: 2020

Share "Biblioteca de números grandes en C++"

Copied!
39
0
0

Texto completo

(1)Ingeniería en Informática Universidad Politécnica de Madrid Escuela Técnica Superior de Ingenieros Informáticos PROYECTO FIN DE CARRERA. Biblioteca de números grandes en C++. Autor: Director:. José Osvaldo Suárez Domingos Fernando Pérez Costoya. MADRID, JULIO 2018.

(2) Índice 1. Introducción 1.1. Motivación y origen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.2. Objetivos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .. 2 2 2. 2. Estado de la cuestión. 3. 3. Implementación 3.1. Lenguaje a usar . . . . . . . . . . . . . . 3.2. Representación de los números . . . . . 3.3. Estructura en memoria . . . . . . . . . . 3.4. Sumador básico . . . . . . . . . . . . . . 3.5. Suma . . . . . . . . . . . . . . . . . . . 3.6. Resta . . . . . . . . . . . . . . . . . . . 3.7. Opuesto, incremento y decremento en 1 3.8. Multiplicador básico . . . . . . . . . . . 3.9. Multiplicación . . . . . . . . . . . . . . . 3.9.1. Multiplicación clásica . . . . . . 3.9.2. Multiplicación de Karatsuba . . 3.10. División y módulo . . . . . . . . . . . . 3.10.1. Division larga . . . . . . . . . . . 3.10.2. Division larga en bloques . . . . 3.11. Imprimir los números en decimal . . . . 3.11.1. Algoritmo simple . . . . . . . . . 3.11.2. Algoritmo “divide y vencerás” . 3.12. Otras operaciones . . . . . . . . . . . . .. . . . . . . . . . . . . . . . . . .. . . . . . . . . . . . . . . . . . .. . . . . . . . . . . . . . . . . . .. . . . . . . . . . . . . . . . . . .. . . . . . . . . . . . . . . . . . .. . . . . . . . . . . . . . . . . . .. . . . . . . . . . . . . . . . . . .. . . . . . . . . . . . . . . . . . .. . . . . . . . . . . . . . . . . . .. . . . . . . . . . . . . . . . . . .. . . . . . . . . . . . . . . . . . .. . . . . . . . . . . . . . . . . . .. . . . . . . . . . . . . . . . . . .. . . . . . . . . . . . . . . . . . .. . . . . . . . . . . . . . . . . . .. . . . . . . . . . . . . . . . . . .. . . . . . . . . . . . . . . . . . .. . . . . . . . . . . . . . . . . . .. . . . . . . . . . . . . . . . . . .. 6 6 6 9 12 14 15 16 18 20 20 22 25 25 25 28 28 28 30. 4. Pruebas y resultados. 31. 5. Conclusiones y lı́neas futuras. 35. 1.

(3) 1.. Introducción. La aritmética de números grandes, también llamada aritmética de precisión arbitraria o múltiple precisión, es la realización de operaciones aritméticas en un computador (tanto de números enteros como fraccionarios) con tamaños de operandos mayores que el tamaño de palabra de este. Los datos manejados tı́picamente por computadores oscilan entre 8 y 64 bits de precisión, y sus unidades aritmético-lógicas (por sus siglas en inglés, ALU) son, en general, capaces de operar con estos tamaños en un solo paso. Varios lenguajes de programación (Lisp, Python, Perl, Haskell, Ruby) tienen soporte nativo para números grandes que implementan mediante vectores de longitud variable [1]. Un uso muy común de los números grandes es la criptografı́a de clave asimétrica, cuyos algoritmos emplean números de cientos o miles de bits. Por ejemplo, 1024, 2048 o 4096 bits son valores tı́picos en el tamaño de clave del algoritmo criptográfico RSA. Si bien estas longitudes de clave de miles de bits no son obligatorias (el algoritmo puede trabajar con cualquier tamaño), en la práctica son necesarias para evitar ataques de fuerza bruta. Otros usos comunes son los cálculos financieros, el estudio numérico de funciones matemáticas cuando los métodos analı́ticos no son viables (por ejemplo: la función zeta de Riemann), o la simulación y predicción de sucesos fı́sicos (por ejemplo: el seguimiento de partı́culas en el acelerador “LHC” [2]).. 1.1.. Motivación y origen. Experimentando con un algoritmo para predicción de resultados deportivos me encontré con un sistema de ecuaciones mal condicionado y pensé en usar números de coma flotante con precisión mayor que doble o cuádruple bits (el compilador GCC implementa mediante software un tipo de coma flotante de 128 bits float128). Aunque sabı́a de algunas bibliotecas de números grandes decidı́ que querı́a hacer una para probar si podı́a superar a las existentes y que se adaptase a mi gusto [3].. 1.2.. Objetivos. Desarrollar una biblioteca para manejo de números grandes en C++ que cumpla estos requisitos: - Fácil de usar. Mediante la sobrecarga de operadores que permite el lenguaje la sustitución de un tipo de enteros genérico por esta implementación deberı́a ser lo más directa posible. - Razonablemente rápido. La velocidad de ejecución tiene dos vertientes, por un lado la complejidad computacional y por otro, dado un algoritmo, el óptimo aprovechamiento de los recursos. Desde el punto de vista académico es más importante la primera, ası́ que no deberı́an faltar algoritmos más eficientes que las versiones clásicas allá donde sea posible. - Que sirva como base para implementar coma fija y coma flotante. Ambos conceptos implican que el número de bits de cualquier número es fijo. 2.

(4) 2.. Estado de la cuestión. Existen varias implementaciones de números grandes, cada una con sus particularidades [4]: • Boost - Lenguajes soportados: C++ - Licencia: Boost - Tipos de números: enteros, racionales, coma flotante. - Es un conjunto de más de 80 bibliotecas para diversas tareas como, además de números de gran tamaño, procesamiento de imagenes, manejo de sockets, expresiones regulares, multihilo, etc., por lo que llega a ser una dependencia pesada y, a menos que ya se esté usando por sus otras opciones, podrı́a ser preferible usar otra biblioteca para el manejo de números grandes. Usa por debajo GMP/MPFR [5]. • TTMath - Lenguajes soportados: C++ - Licencia: BSD - Tipos de números: enteros, coma flotante. - Biblioteca basada unicamente en plantillas de C++. El tamaño de los números se establece en tiempo de compilacion, y es fijo en memoria, lo que hace que se manejen en la pila de ejecución. Las operaciones básicas se realizan con los algoritmos básicos bien conocidos. Soporta las arquitecturas x86 y x86-64 [6]. • GMP / MPFR - Lenguajes soportados: C, C++, Ada, Perl, Python, ... - Licencia: GPL2 / LGPL3 - Tipos de números: enteros, racionales, coma flotante. - La biblioteca de precision multiple de GNU está nativamente en lenguaje C y tiene interfaces para varios otros lenguajes. Para lograr una gran velocidad de ejecución toma ciertas decisiones de diseño: los números están formados por palabras del tamaño máximo de la máquina, se usan distintos algoritmos para la misma operación dependiendo del tamaño de los números involucrados, y se usa código ensamblador para las zonas “calientes” del código [7]. Implementa el algoritmo de multiplicación Schönhage-Strassen [8], más eficiente en ciertos casos que el algoritmo clásico de la escuela o el de Karatsuba y un algoritmo de división mediante la técnica de “divide y vencerás” [9].. 3.

(5) • CLN - Lenguajes soportados: C++ - Licencia: GPL2 - Tipos de números: enteros, racionales, coma flotante, complejos. - Usa algunas optimizaciones como alojar enteros pequeños en la pila en vez de en la memoria dinámica. También implementa el algoritmo de multiplicación Schönhage-Strassen [10]. • ARPREC - Lenguajes soportados: C++, Fortran 90 - Licencia: LBNL-BSD - Tipos de números: enteros, coma flotante, complejos. - Esta biblioteca toma una decisión de diseño que limita su uso, al establecer la precisión de los números mediante una variable global. Esto hace que no se puedan mezclar operaciones de distinta precisión. Los números se alojan en memoria dinámica [11]. • MAPM - Lenguajes soportados: C, C++ - Licencia: Dominio público. - Tipos de números: Enteros, coma flotante. - La biblioteca tiene un uso de memoria poco eficiente; los números requieren una estructura que ocupa 36 bytes en 64 bits, la cual se aloja en memoria dinámica. A esto se añade el propio número que requiere otra reserva de memoria dinámica [12]. • MPIR - Lenguajes soportados: C, C++, Ada, Perl, Python, ... - Licencia: LGPL3 - Tipos de números: enteros, racionales, coma flotante. - Es un trabajo derivado de la biblioteca de números grandes de GNU (GMP), por tanto comparte varias caracterı́sticas de diseño con GMP. Además de mantener compatibilidad con este, su objetivo principal es desarrollar algoritmos paralelos que ejecuten en procesadores gráficos y procesadores multinúcleo [13].. 4.

(6) • libgcrypt - Lenguajes soportados: C - Licencia: LGPL. Algunos ficheros tienen otra licencia como BSD3, GPL, y varias más, aunque todas ellas parecen ser “Open Source”. - Tipos de números: enteros. - Es una biblioteca criptográfica que implementa sus propios números grandes, con código ensamblador para gran variedad de procesadores: Alpha, AMD64, HP PA-RISC, i386, i586, M68K, MIPS 3, PowerPC, y SPARC [14]. • LibTomMath - Lenguajes soportados: C - Licencia: Dominio público o WTFPL - Tipos de números: enteros. - Comenzó como un necesario reemplazo de la biblioteca MPI, parte de libgcrypt. Está escrita de forma didáctica. • C++ BigInt Class - Lenguajes soportados: C++ - Licencia: GPL2 - Tipos de números: enteros. - El desarrollo de esta biblioteca comenzó en 1995. Representa los números como un vector de tamaño variable. Opera con algoritmos sencillos. Otras bibliotecas son: mbed TLS, JScience, JAS, JLinAlg, Apfloat, InfInt, bigz, ramp, float, fgmp, OpenSSL.. 5.

(7) 3.. Implementación. 3.1.. Lenguaje a usar. Al comenzar a implementar una biblioteca de números grandes surgen inevitablemente cuestiones de diseño; la primera es elegir el lenguaje de programación. He elegido C++ porque lo uso habitualmente para mis proyectos personales. Sus caracterı́sticas generales son: es un lenguaje de propósito general, se basa en el paradigma imperativo, está orientado a objetos, permite programación genérica (con plantillas) y permite el acceso a bajo nivel a la manipulación de memoria [13]. Pero, más concretamente, las caracterı́sticas que me interesan son: - Permite el manejo del computador a bajo nivel, pudiendo insertar código ensamblador, que no solo puede ser útil para exprimir un poco más el rendimiento del procesador, sino para salvar algunas limitaciones de C++. En concreto, el operador de suma no dispone de ningún mecanismo para detectar un posible acarreo, de forma que habrı́a que emular este comportamiento usando más instrucciones de las que serı́an necesarias en una arquitectura que implemente la suma con acarreo (¿hay alguna que no lo haga?). Asimismo, el operador de multiplicación también carece de acceso a la parte alta del resultado. Hay que recordar que una multiplicación de dos números de N bits da como resultado un número de 2N bits. Estas limitaciones se pueden evitar insertando código ensamblador especı́fico para cada arquitectura, conservando, eso sı́, una implementación en C++ puro más ineficiente pero portable. - Es un lenguaje muy usado, y esto hace que existan compiladores para muchas arquitecturas, lo que hace que C++ sea un lenguaje muy portable. Concretamente, el compilador que se usará principalmente en este proyecto, GNU GCC, admite decenas de arquitecturas [15]. Estos compiladores son, en general, bastante buenos optimizando. - El uso de plantillas permite que algunos parametros del manejo de los números sea conocido en tiempo de compilacion, lo que lleva a mejores posibilidades de optimización. - Permite la sobrecarga de operadores, haciendo que la interfaz de los números grandes sea idéntica a la de los tipos predefinidos. Gracias a esto se podrá reutilizar el código existente con cambios mı́nimos.. 3.2.. Representación de los números. Si bien existen implementaciones de números grandes usando cadenas de caracteres, su rendimiento es pobre y el uso de memoria ineficiente. Una implementación solvente pasa por usar la representación nativa de los computadores modernos: binario. La diferencia en espacio es clara, un dı́gito decimal ocupa 8 bits como carácter imprimible,. 6.

(8) mientras que el mismo dı́gito decimal ocupa en binario log2 (10) ∼ 3,32 bits, y en velocidad de ejecución mayor si cabe; un procesador de 64 bits puede sumar dos números de ese tamaño en 1 ciclo de reloj, que representado en decimal son log10 (2)·64 ∼ 19,3 cifras, llevando a esperar que tal operación precisara de decenas de ciclos para completarse si su representación fuera en forma de cadena de caracteres decimales. Usaremos, pues, vectores del máximo tamaño de palabra que permita el computador, o mejor dicho, que permita C++. En principio los números que vamos a representar con este formato son enteros sin signo, aunque posteriormente sea posible usar este mismo esquema con enteros con signo representando los números negativos en complemento a 2[complemento˙a˙dos.] Afortunadamente existen definiciones estándar de tipos de datos numéricos para 8, 16, 32 y 64 bits, con signo (int8 t, int16 t, int32 t, int64 t) y sin signo (uint8 t, uint16 t, uint32 t, uint64 t), de los que nos interesan los números sin signo. En las arquitecturas con tamaños de palabra menores de 64 bits el compilador generará el código que emule las operaciones con estos números más grandes, que es, curiosamente, algo semejante a lo que queremos que haga la biblioteca. Cuando los procesadores almacenan en la memoria principal los registros mayores que un byte usan varias formas distintas respecto al orden de los bytes. Las más usadas son: big endian 1 y little endian 2 (aunque otras aberraciones son también posibles [16]).. Byte 3. En registro. 31. Byte 2 24 23. Byte 1 16 15. Byte 0 8 7. 0. 0. N-1 Byte 3. En memoria. Byte 2. Byte 1. Byte 0. Figura 1: Representación de un dato de 32 bits que se transfiere de un registro a la memoria principal usando una representación big endian. Byte 3. En i emsati g. 31. Byte 2 24 23. Byte 1 16 15. Byte 0 8 7. 0. 0 En r er gi so. N-1 Byte 0. Byte 1. Byte 2. Byte 3. Figura 2: Representación de un dato de 32 bits que se transfiere de un registro a la memoria principal usando una representación little endian. 7.

(9) El orden más usado actualmente es little endian, por ser usado en ordenadores personales, con la arquitectura x86, y dispositivos portátiles con la arquitectura ARM. Los procesadores ARM son capaces de usar ambos ordenamientos, a pesar de lo cual los diseñadores de computadores se adhieren masivamente al little endian. Aunque la forma en la que el computador almacena los palabras en memoria es de poca relevancia, la forma de almacenar las partes de un número grande en un vector de palabras está sujeto a las mismas consideraciones, ¿big endian o little endian?. Debemos tener en cuenta que las operaciones de suma/resta y multiplicación recorren los números desde los dı́gitos más bajos hasta los más altos. Esto por si mismo no implicarı́a una elección del orden, pero por otro lado los procesadores a menudo tienen mecanismos (stream buffers [17]) para predecir los accesos en secuencia a la memoria principal y precargarla en buffers de acceso rápido con el fin de evitar paradas en la ejecución. Los stream buffers, además, suelen detectar el patrón secuencial solo hacia direcciones crecientes, y no hacia las decrecientes. Si por último añadimos que el código resultará más sencillo si se recorren los bucles hacia adelante (de 0 a N − 1) que hacia atrás (de N − 1 a 0), el ganador es little endian. Memoria principal. Direcciones H 1000 H 1004. Inicio. Palabra 0. H 1008. Palabra 1. H 100C. Palabra 2. H 1010. Palabra 3. H 1014. Palabra 4. H 1018. Palabra 5. H 101C H 1020 H 1024. Figura 3: Representación de un número de 192 bits en la memoria principal almacenado en little endian formado por 6 palabras de 32 bits. 8.

(10) Hay otra razón menos importante para almacenar las palabras del vector en little endian; si quisieramos optar por un vector de, por ejemplo, 32 bits, y lo almacenásemos en big endian, pero con el ordenamiento de los bytes (impuesto por la arquitectura) en little endian, su representación en memoria serı́a distinta a la que tendrı́a si las palabras fueran de, por ejemplo, 64 bits. Se tratarı́a de dos variaciones posibles del concepto de middle endian.. 3.3.. Estructura en memoria. Más allá del ordenamiento de las palabras, las posibles estructuras de los números en memoria se pueden resumir en dos: tamaño variable (o dinámico) y tamaño fijo (o estático). Cada uno tiene sus ventajas y desventajas. En general el tamaño variable parece más ventajoso cuanto mayores son los números manejados porque aprovecha mejor la memoria al usar solo la mı́nima necesaria para cada número. Esto es una verdad a medias: a los números se accede a través de punteros, de forma que se consume la memoria del número, la memoria de un puntero a este y la memoria de un dato que indique el tamaño del número (esta última podrı́a almacenarse junto al propio número). De esta forma, mientras que un número de tamaño fijo puede desperdiciar mucho espacio en forma de “ceros por la izquierda”, con tamaño variable no sucede. El diseño de tamaño variable permite que cualquier número crezca sin más restricciones que la memoria del sistema, sin embargo el diseño de tamaño fijo requiere que ese tamaño sea conocido en el momento de compilar, es decir, deberá estar indicado explı́citamente en el código fuente. Ası́, la coexistencia de muchos números de tamaño fijo durante la ejecución del programa implica que todos ellos usan el tamaño máximo que se espera que alcance alguno de ellos. Los números de tamaño variable se almacenarı́an una parte en la pila de ejecución (el puntero y tal vez el tamaño) y otra en el montı́culo o heap. Las operaciones con tamaño variable dan lugar a resultados de tamaños diversos, requiriendo la continua reserva y desalojo de fragmentos de memoria, lo que podrı́a producir fragmentación; las operaciones con tamaño fijo no sufren este problema y tienen una bonificación añadida respecto al uso de la memoria caché del procesador: ofrecen mejor localidad espacial al evitar una indirección para acceder al vector. Para implementar por software otros números como el estándar IEEE-754 [18] o la coma fija puede resultar conveniente el tamaño fijo.. 9.

(11) Este serı́a un ejemplo del tipo de datos para tamaño variable (dinámico):. struct big_uint { // Puntero al vector de bloques . uint64_t * vec ; // Numero de bloques actuales . unsigned int length ; };. Y este serı́a un ejemplo para tamaño fijo (estático):. template < unsigned int Length > struct big_uint { // Vector de bloques . uint64_t vec [ Length ]; };. Es reseñable que el tamaño estático usa un parámetro de plantilla que permite instanciar números de distintos tamaños.. 10.

(12) Tras valorar estas cuestiones la estructura escogida ha sido el tamaño fijo. Aquı́ se muestra el tipo de datos usado sin incluir los métodos de clase:. template < unsigned int Precision = 128 , typename Base_Type = uint64_t > class big_uint { public : static const unsigned int precision = Precision ; using base_type = Base_Type ; private : static const base_type zero = 0; static const base_type one = 1; static const base_type all = ~ zero ; static const unsigned int bits_per_block = sizeof ( base_type ) * 8; static const unsigned int blocks = precision % bits_per_block == 0 ? precision / bits_per_block : precision / bits_per_block + 1; static const unsigned int head_bits = blocks * bits_per_block - precision ; static const base_type head_mask = head_bits == 0 ? ~ static_cast < base_type >(0) : ( one << ( bits_per_block - head_bits )) - 1; // Contenido del numero . base_type data [ blocks ]; };. En lugar de especificar el número de enteros simples que forman el vector se indica el número de bits de precisión. Además es posible cambiar el tipo básico que forma el vector por enteros de otro tamaño, no necesariamente 64 bits. A pesar de que operar con el tamaño de palabra de la máquina es más eficiente puede ser de utilidad disponer de otros tamaños para detectar fallos en el código debidos a desbordamientos en operaciones con palabras. 11.

(13) 3.4.. Sumador básico. En un procesador se logran sumas de varios digitos replicando varias veces un mismo elemento básico sumador de bits; recibe dos sumandos y un acarreo y produce un resultado y un nuevo acarreo. Este concepto se aplica igualmente con cualquier otra base mayor que 2, como en nuestro caso, donde se manejan las palabras del computador como si fueran los dı́gitos básicos. Por ejemplo, sumando dos palabras de 32 bits y un acarreo de 1 bit se obtiene un resultado de 32 bits y otro acarreo de 1 bit. An. Bn. An. Bn. 32. Cn. Sumador. Cn-1. Cn. 32. Sumador. Cn-1. 32. Rn. Rn. Figura 4: Comparación de un sumador de 1 bit y otro de 32 bits. An y Bn son las cifras n-ésimas de los numeros A y B que se van a sumar, Cn−1 y Cn son los acarreos de entrada y de salida y Rn el resultado de la suma. El siguiente pseudocódigo realiza la suma con acarreo de dos bloques a y b: function suma básica(a, b, acarreo) r ←a+b overf low ← (r < a) ∨ (r < b) if acarreo = 1 then r ←r+1 if acarreo = 1 ∧ r = 0 then acarreo ← 1 else if overf low then acarreo ← 1 else acarreo ← 0 return r, acarreo El desbordamiento de los enteros sin signo no es un problema en C++; sigue las reglas de la aritmética módulo 2N . [19].. 12.

(14) El código resultante serı́a ası́:. template < typename T > T addc ( T a , T b, T & carry ) { T r = a + b; overflow = r < a || r < b ; if ( carry ) r ++; carry = overflow || ( carry && r == 0) return r ; }. Esta es la misma operación con palabras de 32 bits mediante ensamblador x86 para el compilador GCC. El dialecto del código es intel.. template < > inline uint32_t add_carry ( uint32_t a , uint32_t b , uint32_t & carry ) { asm volatile ( " add  al ,  0 xff   \ n \ t " // SR [ C ] <= RAX [0] " adc  %[a ] ,  %[b ]\ n \ t " // a <= a + b + SR [ C ] " mov  rax ,  0     \ n \ t " // RAX [0 .. 63] <= 0 " setc  al        \ n \ t " // RAX [0] <= SR [ C ] : [a]"+r"(a), [ carry ] " + a " ( carry ) // RAX : [b]"r"(b) : " cc " ); return a ; }. - "asm volatile" significa que el compilador no hará desaparecer el código en sus intentos de optimización. 13.

(15) - Los dos puntos “:”separan las declaraciones de variables de salida, entrada y registros modificados para que el compilador lo tenga en cuenta. - (a) y (carry) seran, en principio, variables de salida, que si son nombradas dentro del código recibirán los alias [a] y [carry]. - El modificador "+r" hace dos cosas; "r" indica que se espera que el valor de (a) esté cargado en un registro cuando se use por primera vez, y "+r" indica que además (a) será una variable de entrada y salida. El modificador "+a" de (carry) hace lo mismo, pero además requiere que el registro usado sea RAX. Por tanto ambas variables son de entrada y salida. - (b) es una variable de entrada que deberá estar cargada en un registro (modificador "r") y será nombrada usando el alias [b]. - "cc" indica que como resultado de la ejecución del fragmento de código ensamblador el registro de estado será modificado como efecto colateral. Mediante la suma básica de dı́gitos se construyen algunas de las operaciones elementales como la suma, la resta y la multiplicación. Cabe plantearse el uso de instrucciones SIMD (single instruction, multiple data) para aumentar el rendimiento de las operaciones, sin embargo la implementación de estas en la arquitectura x86 no facilita el cálculo de los acarreos ni su propagación, de modo que no se obtiene mejora alguna: ni para sumar en paralelo los bloques de dos números, ni para sumar varios pares de números en paralelo.. 3.5.. Suma. La suma de números grandes se consigue repitiendo iterativamente la suma de cada par de dı́gitos (en este caso son palabras del tamaño elegido) de la posición n junto con el acarreo de la operación n − 1, comenzando por el dı́gito menos significativo, el 0. El acarreo inicial es 0.. An-1. A2. A1. A0. Bn-1. B2. B1. B0. ADC. Cn-1. An-1+Bn-1+Cn-1. C3. ADC. A2+B2+C2. C2. ADC. A1+B1+C1. C1. ADC. C0 = 0. A0+B0+C0. Figura 5: Suma binaria mediante suma de bloques (palabras). 14.

(16) El pseudocódigo correspondiente al esquema: function suma((a), (b), N )  (a) y (b) son vectores de bloques de bits (enteros sin signo)  N es el número de bloques de cada vector acarreo ← 0 for i = 0, i < N do s, c ← suma básica(ai , bi , acarreo) ri ← s acarreo ← c return (r) Hay que tener en cuenta que en C++ el desbordamiento de las operaciones aritméticas se considera comportamiento indefinido, y significa que el estándar de C++ no obliga a un resultado concreto, lo que conlleva que la portabilidad del código no está asegurada y hace necesario comprobar y, posiblemente, adaptar el código en cada arquitectura en la que se vaya a usar. Esto se relaciona también con el hecho de que el lenguaje no ofrece una forma directa de obtener el acarreo de una suma. Para resolver estos problemas se puede emular el cálculo de los resultados, siendo más costoso computacionalmente, o se puede insertar código ensamblador. Lo mejor es hacer las dos cosas; la primera con vistas a la portabilidad, y la segunda al rendimiento, siempre que sea posible.. 3.6.. Resta. Para la resta binaria he usado el método del complemento a dos, pues permite efectuar la resta reutilizando la operación de suma. El complemento a dos de un número binario puede considerarse como el opuesto de ese número, y ası́ se efectúa la resta sumando el minuendo con el opuesto del sustraendo. El cálculo del complemento a dos del sustraendo consiste en invertir todos sus bits y posteriormente sumar 1 al resultado. Muy convenientemente podemos conseguir estos dos pasos con el operador de negación binaria de C++ (~) e iniciando la cadena de sumas con el acarreo igual a 1.. 15.

(17) An-1. A2. A1. A0. Bn-1. B2. B1. B0. NOT ADC. NOT Cn-1. An-1+Bn-1+Cn-1. C3. ADC. A2+B2+C2. NOT C2. ADC. A1+B1+C1. NOT C1. ADC. C0 = 1. A0+B0+C0. Figura 6: Resta binaria mediante suma de bloques (palabras). Nótese el acarreo inicial 1 para hacer el complemento a dos del sustraendo. El pseudocódigo correspondiente al esquema: function resta((a), (b), N )  (a) y (b) son vectores de bloques de bits (enteros sin signo)  N es el número de bloques de cada vector acarreo ← 1  El acarreo inicial es 1 for i = 0, i < N do not b ← not(bi ) s, c ← suma básica(ai , not b, acarreo) ri ← s acarreo ← c return (r). 3.7.. Opuesto, incremento y decremento en 1. A partir de la resta se implementa el opuesto P de un número N como P = 0 − N . Para evitar accesos innecesarios a la memoria el número 0 será simplemente un solo registro para cada bloque de la operación.. 16.

(18) A. + 12n. +B. NOT. +n. NOT. +3 D. C12n. C=. A0+ 12n0C12n. +A. NOT. +3 D. CB. A0+ B0CB. +3 D. NOT Cn. A0+ n0Cn. +3 D. CA- -n. A0+ A0CA. Figura 7: Cálculo del opuesto mediante la resta a 0.. function opuesto((a), N )  (a) es un vector de bloques de bits (enteros sin signo)  N es el número de bloques de (a)  El acarreo inicial es 1. acarreo ← 1 for i = 0, i < N do not a ← not(ai ) s, c ← suma básica(0, not a, acarreo) ri ← s acarreo ← c return (r). El incremento I de un número N será I = N + 0 + C0 , siendo C0 = 1 el acarreo inicial. Y por otro lado el decremento D será D = N + 0 + C0 , con C0 = 0, es decir, la resta de N − 0 pero sin el ajuste +1 del complemento a dos al negar el número 0.. A2nC. A1. A0. AC. 0. A3 D. A2nC+0+B2nC. B2nC. B=. A3 D. A1+0+B1. B1. A3 D. AC+0+BC. BC. A3 D. A0+0+B0. Figura 8: Cálculo del incremento mediante la suma. 17. B0- -C.

(19) Cuando la operación de incremento o decremento se realize in situ no será necesario recorrer todo el número en el caso de que dejen de propagarse acarreos; en el autoincremento un acarreo Ci = 0 significa que las cifras superiores no van a cambiar, y en el autodecremento se da el mismo caso para el acarreo Ci = 1, pues 0 + 1 = 0. function incremento((a), N )  (a) es un vector de bloques de bits (enteros sin signo)  N es el número de bloques de (a) acarreo ← 1  El acarreo inicial es 1 for i = 0, i < N do s, c ← suma básica(ai , 0, acarreo) ri ← s acarreo ← c return (r). 3.8.. Multiplicador básico. Al igual que en la suma, la multiplicación de dos números requiere repetir una misma operación básica sobre pares de cifras (en este caso serán palabras), y esta operación da como resultado (potencialmente) dos cifras: parte alta y parte baja. Esto se debe a que la multiplicación de dos números de M y N cifras da lugar a un número de al menos M +N −1 cifras, y a lo sumo M +N . Dado que los computadores trabajan con palabras de tamaño fijo deben usar dos palabras para almacenar el resultado de una multiplicación. Nuevamente, como en la suma, el lenguaje C++ no permite acceder a parte de este resultado (la parte alta), teniendo que, o bien emular la operación, o bien usar código ensamblador para recuperar este dato. An. Bn. An. Bn. 32. Hn. Hn. Multiplicador. 32. 32. Multiplicador 32. Rn. Rn. Figura 9: Comparación de un multiplicador de 1 bit y otro de 32 bits. El valor H es la parte alta del resultado. Al igual que en el sumador básico la multiplicación puede recibir un equivalente al acarreo de una operación anterior, ası́ que cabe preguntarse ¿por qué no aparece en el esquema?. En la arquitectura x86 las instrucciones de multiplicación de enteros solo usan 18.

(20) los dos factores, y solo recientemente se han añadido al juego de instrucciones operaciones de multiplicación y suma fusionadas (fused multiply-add, FMA), que además no operan con números enteros. Puesto que esta operación con enteros no es tan común y no está disponible, la operación que he implementado de multiplicación básica es simplemente una interfaz para acceder a la instrucción que de otra forma no está disponible en C++. Para solventar el problema de no tener acceso a la parte alta del resultado puede usarse código ensamblador, tipos de datos de mayor tamaño o emular el cálculo de la parte alta. El siguiente pseudocódigo calcula las partes alta y baja del resultado sin recurrir a tipos de datos de mayor tamaño: function multiplicación básica(a, b) N ← tamaño en bits(a) mascarabaja ← despl izq(1, N2 ) − 1 mascaraalta ← not(mascarabaja )  Parte baja de a a0 ← and(a, mascarabaja ) temp ← and(a, mascaraalta )  Parte alta de a a1 ← despl der(temp, N2 )  Parte baja de b b0 ← and(b, mascarabaja ) temp ← and(b, mascaraalta )  Parte alta de b b1 ← despl der(temp, N2 )  Parte baja del resultado r0 ← 0  Parte alta del resultado r1 ← 0 s, c ← suma básica(r0 , a0 · b0 , 0) r0 ← s r 1 ← r1 + c  Se pierden N2 bits de la parte alta temp ← despl izq(a1 · b0 , N2 ) s, c ← suma básica(r0 , temp, 0) r0 ← s r 1 ← r1 + c  Se pierden N2 bits de la parte alta temp ← despl izq(a0 · b1 , N2 ) s, c ← suma básica(r0 , temp, 0) r0 ← s r 1 ← r1 + c r1 ← r1 + despl der(a1 · b0 , N2 )  Al desplazar el producto a1 · b0 se pierden N2 bits de la parte baja r1 ← r1 + despl der(a0 · b1 , N2 )  Al desplazar el producto a0 · b1 se pierden N2 bits de la parte baja r1 ← r1 + a 1 · b 1 return r0 , r1  Se devuelve una parte baja r0 y una parte alta r1 , ambas de N bits Al igual que en el sumador básico la diferencia entre la emulación de la instrucción y la instrucción de ensamblador en sı́ es enorme, lo que no evita la necesidad de la primera. 19.

(21) Una versión que use código ensamblador para x86 podrı́a ser ası́:. template < > inline uint32_t mul_carry < uint32_t >( uint32_t a , uint32_t b , uint32_t & carry ) { asm volatile ( " mul  %[b ]\ n \ t " : [ carry ] " = d " ( carry ) , // edx [a]"+a"(a) // eax : [b]"r"(b) : " cc " ); return a ; }. 3.9. 3.9.1.. Multiplicación Multiplicación clásica. Una implementación del algoritmo de multiplicación clásica de la escuela es obligatoria, tanto para testear la corrección de otros algoritmos como su velocidad de ejecución. El concepto es idéntico tanto en decimal como en la implementación binaria en hardware y como al operar con bloques: el producto Ai · Bj da lugar a una parte baja Li+j y una alta Hi+j+1 ; se suman los bloques de igual subı́ndice, se propagan los acarreos de la suma y obtenemos ası́ el resultado.. 20.

(22) X. Ai+1. Ai. Bj+1. Bj Ai·Bj. Ai·Bj+1 Ai+1·Bj Ai+1·Bj+1 Ri+j+3. Ri+j+2. Ri+j+1. Ri+j. Figura 10: Fragmento de la multiplicación clásica. Los cuadros en gris son la parte alta del producto de las multiplicaciones parciales de cada par de bloques de los factores A y B.. function multiplicación((a), (b), N )  (a) y (b) son vectores de bloques de bits (enteros sin signo)  N es el número de bloques de (a) for i = 0, i < 2N do  Inicializamos el resultado de tamaño 2N con ceros ri ← 0 for i = 0, i < N do for j = 0, j < N do baja, alta ← multiplicación básica(ai , bj ) ri+j , acarreo ← suma básica(ri+j , baja, 0) ri+j+1 , acarreo ← suma básica(ri+j+1 , alta, acarreo) k←2 while acarreo = 1 do ri+j+k , acarreo ← suma básica(ri+j+k , 0, acarreo) k ←k+1 return r. 21.

(23) Ya que la multiplicación es parte de otras operaciones como la división o la potencia es ventajoso contar con una implementación tipo FMA, o sea, multiplicación y suma fusionadas. 3.9.2.. Multiplicación de Karatsuba. El algoritmo de Karatsuba fue descubierto en 1960 por Anatoli Alekseevich Karatsuba[20], tras asistir a un seminario de Andrei Kolmogorov, en el que este último conjeturaba que el algoritmo de multiplicación clásico, de complejidad O(n2 ), era asintóticamente óptimo. Esto supuso un incentivo para Karatsuba que, siendo un estudiante de 23 años, desarrolló el algoritmo en una semana. La complejidad del mismo es de O(nlog2 3 ), aproximadamente O(n1,585 ). La base del algoritmo es reescribir los factores a multiplicar a y b para que tengan una parte alta y una parte baja: a = a1 · 2n/2 + a0 b = b1 · 2n/2 + b0 ,. donde los factores a y b tienen n bits. El producto ab se puede escribir entonces como: ab = (a1 · 2n/2 + a0 )(b1 · 2n/2 + b0 ) ab = c2 · 2n + c1 · 2n/2 + c0 ,. con c 2 = a 1 b1 c 1 = a 1 b0 + a 0 b1 c 0 = a 0 b0 .. Esta fórmula requiere cuatro multiplicaciones, pero Karatsuba se dio cuenta de que podı́a calcular c1 como: c1 = (a1 + a0 )(b1 + b0 ) − c2 − c0 evitando una de las multiplicaciones. Hay que notar que el cálculo de (a1 + a0 )(b1 + b0 ) podrı́a llevar a un desbordamiento, lo que puede evitarse reescribiendo c1 como: c1 = (a0 − a1 )(b0 − b1 ) + c2 + c0. 22.

(24) n. n. que produce resultados en el intervalo (−2 2 , 2 2 ). El producto (a0 − a1 )(b0 − b1 ) puede dar lugar, no obstante, valores negativos, algo que debe ser tenido en cuenta al realizar los calculos, pues esto requiere un bit adicional. La disminución de la complejidad del algoritmo se debe a que esta reducción de las multiplicaciones (de cuatro a tres) se hace recursivamente mientras sea posible, hasta manejar números suficientemente pequeños. El concepto de “pequeño” depende de cuestiones prácticas pues, aunque en teorı́a el mı́nimo de tamaño para aplicar la recursión son 4 bits, en la práctica un computador de 64 bits calculará a igual velocidad productos de 4 bits que de 64, haciendo que la división recursiva del problema por debajo de ese tamaño sea mucho más ineficiente. Más aún, el algoritmo clásico resulta más rápido con tamaños menores de 20000 bits, tal vez por el menor uso de memoria.. 23.

(25) function karatsuba((a), (b), N )  Bits de la parte baja P ← N2 Q←N −P  Bits de la parte alta if N < umbral then mascara ← despl izq(1, P ) − 1  Parte baja de a a0 ← and(a, mascara)  Parte alta de a a1 ← despl der(a, P )  Parte baja de b b0 ← and(b, mascara)  Parte alta de b b1 ← despl der(b, P )  Llamada recursiva con tamaño P u0 , u1 ← karatsuba(a0 , b0 , P )  Llamada recursiva con tamaño Q w0 , w1 ← karatsuba(a1 , b1 , Q) if a0 < a1 then signoa ← 1 difa ← a1 − a0 else signoa ← 0 difa ← a0 − a1 if b1 < b0 then signob ← 1 difb ← b0 − b1 else signob ← 0 difb ← b1 − b0 d0 , d1 ← karatsuba(difa , difb , Q)  Llamada recursiva con tamaño Q  Suma de tamaño P uw0 , acarreo ← suma acarreo(u0 , w0 , 0)  Suma de tamaño Q uw1 , uwacarreo ← suma acarreo(u1 , w1 , acarreo) signo ← xor(signoa , signob ) if signo = 1 then  Negación para restar en complemento a 2 d0 ← not(d0 ) d1 ← not(d1 ) uwacarreo ← xor(uwacarreo , 1) v0 , acarreo ← suma acarreo(d0 , uw0 , signo) v1 , acarreo ← suma acarreo(d1 , uw1 , acarreo)  Este bit es parte de v vacarreo ← xor(acarreo, uwacarreo )  Con u, v y w ya se puede componer el resultado final temp, acarreo0 ← suma acarreo(u1 , v0 , 0)  Suma de tamaño Q baja ← u0 + despl izq(temp, P )  Suma de tamaño Q temp1 , acarreo1 ← suma acarreo(v1 , w0 , acarreo0 ) temp2 ← w1 + acarreo1 + vacarreo alta ← temp1 + despl izq(temp2 , P ) if P < Q then  Si son distintos su diferencia es 1 alta ← despl izq(alta, 1) + leer bit(baja, P − 1) escribir bit(baja, P − 1, 0) return baja, alta  baja y alta tienen tamaño N 24.

(26) 3.10. 3.10.1.. División y módulo Division larga. La división larga permite dividir dos números obteniendo un dı́gito del cociente en cada paso[21]. Su fama se debe a que es un algoritmo suficientemente sencillo como para hacerlo a mano. Además se obtiene el resto de la división. Este es algoritmo de la división larga sobre enteros binarios obtenida de la Wikipedia[22]: function división(dividendo, divisor) if divisor = 0 then error cociente ← 0 resto ← 0 for i = precision, i > 0 do resto ← despl izq(resto, 1) b ← leer bit(dividendo, i − 1) escribir bit(resto, 0, b) if resto ≥ divisor then resto ← resto − divisor escribir bit(cociente, i − 1, 1) return cociente, resto 3.10.2.. Division larga en bloques. El problema de la versión anterior es su completo desaprovechamiento del paralelismo del computador. No es que el algoritmo sea paralelizable, pero en el caso de que exista una instrucción de división de enteros, esta se puede usar para resolver más dı́gitos en cada paso (en vez de ir de uno en uno). Para encontrar la forma vamos a observar el funcionamiento de la división larga: Queremos calcular 1239931/97, o escrito de otra forma, queremos determinar Q, R ∈ N tal que 1239931 = Q · 97 + R. Comprobamos que el cociente Q debe ser menor que 100000, pues 1239931 < 97 · 100000. La cifra correspondiente a las centenas de millar del cociente es un 0. La primera posicion no nula del cociente resulta ser las decenas de millar, pues 1239931 ≥ 97 · 10000. 25.

(27) Además esta cifra es un 1, ya que 1239931 < 97 · 20000. Ahora sabemos que el cociente Q ≥ 10000, y podemos calcularlo como Q = Q1 + 10000, donde 1239931 − 10000 · 97 = Q1 · 97 + R. Ası́ que ahora queremos calcular 269931/97. Dado que el cociente Q1 < 10000 probamos primero con los millares, entre 1000 y 9000. La cifra resulta ser un 2, pues 269931 ≥ 97 · 2000 y 269931 < 97 · 3000. Nuevamente, sabemos que el cociente Q ≥ 12000, y podemos calcularlo como Q = Q2 + 12000, donde 1239931 − 12000 · 97 = Q2 · 97 + R. Ası́ que ahora queremos calcular 75931/97. Aplicando estos pasos de forma sucesiva llegamos a que Q = 12782 R = 77. En el caso de dividir en base binaria el tanteo de cada cifra del cociente se reduce a probar con el 0 y el 1. Si hiciésemos lo mismo con bases mayores, por ejemplo, base 10, podrı́amos intentar recorrer todo el rango desde 0 hasta 9, pero serı́a muy ineficiente. La mejor forma serı́a, pues, una búsqueda binaria en el intervalo, reduciendo el tamaño del. 26.

(28) mismo a la mitad en cada tanteo. Y esto nos lleva de nuevo a la base binaria. ¿Podemos evitarlo? La idea de usar cifras del tamaño de palabra de la máquina no es para evitar obtener dı́gitos del cociente a una velocidad mı́nima de un bit por iteración, sino delegar esta tarea a la instrucción de división de la máquina, obteniendo tantos bits como nos permita tal instrucción de una sola vez. Sin embargo esto plantea un problema: ni el dividendo ni el divisor caben en los registros (pues son números grandes). Ası́ que nos vemos obligados a usar sólo parte de ellos, los bits más significativos. ¿Cuántos? Primero hay que tener en cuenta que si tenemos un dividendo de M bits y un divisor de N bits el cociente será de M −N . Ahora, queremos que la aproximación del cociente tenga toda la precisión posible, el máximo número de bits posible, pero si usamos un divisor con pocos bits será poco representativo del divisor completo y la aproximación del cociente será mala. Como ejemplo: si usáramos N bits del dividendo y N bits del divisor el resultado tendrı́a 1 bit de precisión. Si usáramos N bits del dividendo y 1 bit del divisor, el bit del divisor siempre serı́a 1, haciendo la operación inútil. Aumentamos, entonces, el número de bits del divisor hasta N2 y ası́ la aproximación del cociente tendrá también N2 bits. function división(dividendo, divisor, N ) cociente ← 0 if divisor = 0 then return error  Bit más significativo del divisor dBM S ← bms(divisor)  Copiamos los primeros N2 bits del d0 ← despl der(divisor, dBM S − N2 ) + 1 divisor y ajustamos al alza while dividendo ≥ divisor do  Bit más significativo del dividendo actual EBM S ← bms(dividendo) E0 ← despl der(dividendo, EBM S − N )  Copiamos los primeros N bits del dividendo  Cociente parcial c ← Ed00 N desplazamiento ← EBM S − dBM S − 2 if desplazamiento ≤ 0 then c ← despl der(c, −desplazamiento) if c = 0 then  Esto puede suceder por ajustar d0 al alza c←1 p ← c · divisor if desplazamiento > 0 then c ← despl izq(c, desplazamiento) p ← despl izq(p, desplazamiento) cociente ← cociente + c dividendo ← dividendo − p resto ← dividendo return cociente, resto. 27.

(29) La complejidad computacional de este algoritmo es la misma que en la versión más sencilla: O(n2 ), más concretamente O(m · n), siendo m la diferencia entre el tamaño en bits del dividendo y el divisor y n el tamaño de los operandos de las operaciones necesarias; restas, desplazamientos, productos. Sin embargo este último algoritmo es más rápido, pues en vez de calcular 1 bit del cociente en cada paso calcula R2 bits, donde R es el tamaño del registro del procesador. Es necesario, eso sı́, disponer de una operación de división entera en el procesador y que esta sea razonablemente veloz.. 3.11. 3.11.1.. Imprimir los números en decimal Algoritmo simple. Mostrar en la pantalla números binarios en formato binario no tiene secreto alguno; usar bases que sean potencias de 2 es bastante sencillo, también. El problema surge cuando una cifra en otra base requiere un número de cifras binarias no entero, por ejemplo en base 10, pues cada cifra usa log2 (10) ∼ 3,32 bits. En este caso se hace necesario usar la división entera para obtener los dı́gitos. Este es el método clásico: function conversión decimal(n) if n = 0 then r ← ‘0’ else r ← cadena vacı́a while n > 0 do cociente, resto ← división(n, 10) caracter ← número a texto(resto) r ← anexar(r, caracter) n ← cociente invertir cadena(r) return r Si se tratase de imprimir un número en decimal de unas 500 cifras el bucle se repetiria 500 veces, disminuyendo el tamaño del dividendo de 1 en 1. Es decir, en la primera iteración el dividendo tendria 500 cifras, en la segunda 499, en la tercera 498, ... Como se realizan n divisiones de tamaño promedio n2 y la complejidad de la división es O(n2 ) podemos estimar que la complejidad del algoritmo es O(n3 ). 3.11.2.. Algoritmo “divide y vencerás”. Esto se puede mejorar con una aproximación tipo “divide y vencerás”. El número total de divisiones seguirá siendo el mismo, pero éstas se harán sobre números más pequeños. El método consiste en dividir el trabajo de calcular los dı́gitos de un número de tamaño N en dos partes iguales (también puede probarse con tres o más), lo que 28.

(30) N. requiere primero calcular el divisor 10 2 y repetir la operación con cada una de las partes, cociente y resto, sucesivamente hasta que los números sean lo suficiente pequeños como para dividirlos con las instrucciones del procesador en vez de las operaciones de manejo de números grandes. function conversión decimal(n)  Guardará el resultado final r ← cadena vacı́a + 1  Número de cifras inicialmente estimado cif ras ← bms(n)·10 32 con dec aux(r, n, cif ras) invertir cadena(r) borrar ceros izq(r) if es vacı́a(r) then r ← ‘0’ return r procedure conversión decimal aux(cadena, n, cif ras) if cif ras > umbral then cif ras0 ← cif2ras cif ras1 ← cif ras − cif ras0  Dividiremos el número en dos partes  Este número puede precalcularse potencia ← 10cif ras1 parte1 , parte0 ← división(n, potencia) for i = 0, i < 2 do ci ← cadena vacı́a conversión decimal aux(ci , partei , cif rasi )  El número partei tiene la mitad de bits que n anexar(cadena, ci ) else for i = 0, i < cif ras do n  Esta división no es de números grandes. cociente ← 10 resto ← n − cociente · 10 caracter ← número a texto(resto) anexar(cadena, caracter) n ← cociente Pasar de un tamaño N a 2N implica duplicar el trabajo de N y añadir una división de tamaño 2N . Por ejemplo, para el tamaño N llamaremos P al número de pasos necesarios para hacer la división de mayor tamaño y Q a los pasos que requiere el resto de operaciones recursivas. De esta forma el tamaño N implica realizar P + Q pasos. Como la complejidad de la división es cuadrática, duplicar el tamaño del problema requiere que la mayor división necesite 4P pasos (ignorando las constantes), mientras que el resto del problema requiere 2(P + Q) pasos; en total, el tamaño 2N requiere 4P + 2P + 2Q.. 29.

(31) Si seguimos duplicando el tamaño se observa esta evolución en el número de pasos: N →P +Q 2N → 4P + 2P + 2Q 4N → 16P + 8P + 4P + 4Q 8N → 64P + 32P + 16P + 8P + 8Q 16N → 256P + 128P + 64P + 32P + 16P + 16Q 32N → 1024P + 512P + 256P + 128P + 64P + 32P + 32Q Multiplicar por X el tamaño del problema hace que algoritmo requiera (2X 2 − X) · P + X · Q pasos, de lo que se deduce que la complejidad del algoritmo es cuadrática (O(n2 )), lo que me sorprende, pues esperaba que fuera O(n2 · log n).. 3.12.. Otras operaciones. Otras operaciones desarrolladas son: • Desplazamiento aritmético. Varios algoritmos de los presentados hacen uso del desplazamiento aritmético y es también una operación muy utilizada por mejorar la velocidad de ejecución de multiplicaciones y divisiones cuando se hacen con potencias de 2. Las rotaciones no están implementadas. • Operadores de bits y máscaras. En una implementación completa de los operadores de enteros de C++ no pueden faltar las operaciones lógicas “o”, “y”, “o exclusivo” y “negación” aplicadas sobre bits. Para facilitar la creación de máscaras y, sobre todo, su uso eficiente se han añadido tres operaciones para consultar/establecer el contenido de un bit indicando su posición y para establecer el contenido de un intervalo de bits (get bit, set bit y set range). • Exponenciación. El algoritmo rápido de conversión a decimal requiere el cálculo de potencias de 10. Como no podı́a ser de otra manera, he usado la exponenciación binaria[23]. También existe un método para exponenciación en módulo. • Conversión de tipos. Es de tres tipos: de entero básico a número grande y viceversa, y de número grande de un tamaño a número grande de otro. Cuando se asigna un número negativo a un número grande se hace extensión de signo para facilitar una futura implementación de números grandes con signo. Además es posible designar el valor de un número grande mediante una cadena de texto en las bases 2, 8, 10 y 16. Ası́mismo las operaciones aritméticas que puedan recibir como segundo argumento un número grande también son capaces de manejar un entero básico sin hacer su conversión, ahorrando uso de memoria y con ello mejorando la velocidad de ejecución. 30.

(32) 4.. Pruebas y resultados. He hecho pruebas para verificar la corrección de los resultados comparando con la biblioteca GMP. Aparte de eso he hecho comparaciones de velocidad de ejecución con las bibliotecas GMP y TTMath. La ejecución se ha hecho en un procesador de la arquitectura x86-64, modelo AMD64 Phenom II X6 1050T de 6 núcleos, aunque todas las pruebas funcionaron en un solo hilo. Cada núcleo tiene 64 KB de caché de instrucciones y 64 KB de datos en el nivel 1 y 512 KB en el nivel 2. Hay una caché de 6 MB compartida por todos los núcleos. La velocidad de reloj era de 2200 MHz. En las siguientes tablas se muestran los resultados. En la columna izquierda están los tamaños de los operandos usados; en las restantes aparece el número de operaciones por segundo alcanzadas.. Tamaño 100 150 200 500 1000 2000 5000 10000 20000 50000 100000 200000 500000 1000000 2000000. Sumas por gueb::big uint 225432958 199155263 156554969 84318126 33714132 19397550 8673320 4506755 2281162 929014 466228 229000 85847 24858 10398. 31. segundo GMP 53330023 54792458 55824660 45609549 34773819 25188555 12136349 7750147 3388096 1607045 772899 172851 61485 9369 3833. TTMath 214277952 198740652 168572022 68353433 29222259 16791500 7963292 4392646 2229634 916195 362699 171036 57555 20409 8392.

(33) Desplazamientos aritméticos por segundo (31 bits a la izquierda) Tamaño gueb::big uint GMP TTMath 100 387631019 54596580 102139294 150 166483149 57057018 81172871 200 142551414 46757756 64764644 500 72155200 41345836 32232417 1000 33634088 30180593 5751876 2000 14245156 20887754 3270858 5000 6438706 9398081 1193211 10000 3360638 5183557 603582 20000 1721137 2771050 303377 50000 695328 1100436 137516 100000 325404 552342 71014 200000 167993 161930 35701 500000 66639 64067 13735 1000000 25171 8572 6537 2000000 13658 4211 2958. Tamaño 100 150 200 500 1000 2000 5000 10000 20000 50000 100000 200000 500000 1000000 2000000. Multiplicaciones gueb::big uint 149375869 72441238 33191837 9660468 2292563 524808 93875 30939 10043 2040 675 219 51 17 5. 32. por segundo GMP TTMath 42877781 55063486 42933961 42855983 20742200 24826570 8871847 2192890 2931087 638847 936794 184753 201664 27112 65988 8894 23241 2956 6425 745 2409 246 905 81 271 24 111 8 53 2.

(34) Divisiones por segundo (el divisor tiene la mitad de bits) Tamaño gueb::big uint GMP TTMath 100: 2373963 12651306 223478978 150: 2148652 12641366 22649528 200: 1903429 9721190 2787253 500: 507155 4699269 1112222 1000: 187525 2624545 349989 2000: 63687 1362301 142015 5000: 13646 370517 34393 10000: 4015 115700 8098 20000: 1120 36117 2673 50000: 187 7941 406 100000: 48 2646 101 200000: 10 932 22 500000: ∼1 269 ∼2 1000000: <1 108 <1 2000000: <1 46 <1 Se puede ver como en las operaciones sencillas como la suma y el desplazamiento aritmético es factible alcanzar la rapidez de una biblioteca de alto nivel como GMP. En tamaños pequeños el uso de tamaño fijo permite al compilador convertir datos variables en constantes y, con ello, desenrollar bucles, intercalar el código de los procedimientos en vez de llamarlos y mantener los datos de trabajo en registros sin necesidad de acceder a la memoria. Todo esto repercute en un mayor rendimiento. También se ve como a partir del tamaño ∼1000 esta ventaja desaparece, y GMP es superior. Probablemente se deba a que la biblioteca GMP dispone de código con bucles desenrollados mientras que el compilador no realiza esta tarea automáticamente con tamaños grandes. Finalmente, cuando el tamaño pasa a ser mayor de ∼100000 bits el rendimiento de GMP vuelve a decaer, lo que puede ser achacable al desbordamiento de la memoria caché de nivel 1. El código de prueba que usan TTMath y gueb::big uint es tal que ası́:. for ( uint64_t i = 0; i < iteraciones ; i ++) c = a + b;. 33.

(35) Mientras que para GMP es ası́:. for ( uint64_t i = 0; i < iteraciones ; i ++) mpz_add (c , a , b );. La variable de destino en el primer caso es siempre la misma, y su memoria asignada, por tanto, también. Para GMP la variable de destino se crea en memoria dinámica y la memoria de esta variable podrı́a ser distinta en dos llamadas consecutivas, sobre todo si se libera la memoria del contenido anterior antes de asignar el nuevo. Esto provocarı́a que GMP estuviera usando la memoria correspondiente a 4 ó más variables, mientras que las versiones de memoria estática usan 3. El lı́mite para contener 3 variables en 64 KB de caché serı́an 65536/3 ∗ 8 ∼ 174762 bits, pero para 4 serı́an 65536/4 ∗ 8 = 131072. De esta forma GMP comenzarı́a a tener fallos de caché al llegar a ese lı́mite, y con variables de 200000 bits tendrı́a muchos mas fallos que las otras dos bibliotecas. En el caso de las multiplicaciones sucede lo mismo para tamaños pequeños, sin embargo al aumentar la disparidad es bastante grande. La biblioteca GMP usa métodos distintos para multiplicar según las dimensiones de los operandos, como los algoritmos de Karatsuba[20], Toom-Cook[24] o Schönhage-Strassen [8], mientras que TTMath y gueb::big uint solo usan Karatsuba. Por último, la división de gueb::big uint está a mucha distancia de tener un rendimiento aceptable.. 34.

(36) 5.. Conclusiones y lı́neas futuras ¿En qué medida se han logrado los objetivos? - Facilidad de uso. Gracias a la conversión automática desde y hacia tipos básicos y la sobrecarga de operadores la interfaz es casi idéntica a la de los tipos básicos. Al estar compuesta la biblioteca únicamente de ficheros de cabeceras se puede integrar en otros proyectos con sencillez, tan solo copiando los ficheros. - Rapidez. Conocer de antemano el tamaño de los números permite al compilador lograr mayor velocidad con números de hasta algo menos de 1000 bits, a partir de lo cual el tamaño dinámico comienza a ganar ventaja. En general, las operaciones son considerablemente rápidas, pero la implementación de la división es varios órdenes de magnitud más lenta que la implementación que uso de referencia (la biblioteca GMP), y requiere más trabajo para mejorar. - Se puede implementar un tipo de datos de números en coma fija o coma flotante con bastante facilidad basándose en esta biblioteca. La estructura compacta en memoria posibilita, por ejemplo, implementar números en coma flotante que sigan el estándar IEEE-754. La biblioteca se puede mejorar o ampliar por las siguientes vı́as: • División por “divide y vencerás”. La división larga en bloques se basa en el uso de una instrucción de división entera mediante hardware, pues es de suponer que ésta sea bastante rápida. ¿Qué ocurre si no hay tal instrucción? ¿Es más rápido el método clásico o una emulación de la división entera usando registros como operandos puede ser de ayuda en la división larga en bloques? Llevando este concepto al extremo, ¿podemos basar la división de números de N bits en la suposición de que ya disponemos de una división de números de N2 bits y usarla con la división larga mejorada? ¿Podemos implementar la división de números de N2 bits mediante una división de números de N4 bits? De aquı́ surge otro método de división de números grandes, aplicando el famoso paradigma de diseño “divide y vencerás”. • Cuadrado más eficiente. Si representásemos los productos parciales de las cifras de una multiplicación en una tabla comprobarı́amos que cuando los dos factores de la multiplicación son iguales la tabla tiene el aspecto de una matriz simétrica. Como casi la mitad de estos cálculos están repetidos pueden ahorrarse y aumentar la velocidad del cálculo del cuadrado de un número, lo que beneficiará a la operación de exponenciación que se basa en el mismo. • Multiplicaciones de Toom-Cook y Schönhage-Strassen. La multiplicación de ToomCook[24] es una generalización del algoritmo de Karatsuba que funciona partiendo los factores en fragmentos de igual tamaño, tipicamente 3 ó más, que son operados recursivamente de forma que se evitan varias multiplicaciones originalmente necesarias. Debido a su sobrecoste en otras operaciones, no resulta práctico con 35.

(37) números relativamente pequeños, para los que la multiplicación clásica o la de Karatsuba son más rápidas. Cuando se llega a un cierto lı́mite, aumentar el número de fragmentos resulta contraproducente y comienza a ser viable otro algoritmo: Schönhage-Strassen[8]. El algoritmo de Schönhage-Strassen concibe la multiplicación clásica de enteros como un producto de convolución[25] y, como tal, se puede calcular transformando el dominio del problema mediante una transformada de Fourier[26], que posibilita calcular la convolución con menos operaciones. Posteriormente hay que hacer la transformación inversa. • Enteros con signo. Las operaciones de resta y opuesto de un número operan en complemento a 2, lo que facilita el paso a manejar enteros con signo. Las operaciones de multiplicación y división requerirı́an una implementación distinta, aunque si tenemos en cuenta su, comparativamente, lenta velocidad de ejecución, un par de cambios de signo antes de operar no tendrı́a un impacto demasiado apreciable en el rendimiento total, haciendo evitable reimplementar estas operaciones. • Tamaño variable. Para tamaños relativamente pequeños (128-1024 bits...) el conocimiento a priori del tamaño del número resulta muy ventajoso para el compilador, pues puede propagar los valores conocidos de estos datos y preparar versiones más rápidas del código para esos tamaños, al ahorrar cálculos durante la ejecución. Cuando el tamaño crece más se hacen patentes otros problemas como el desperdicio de memoria y de tiempo de procesador en números de mucho tamaño que pueden contener temporalmente valores pequeños. La parte alta de estos números contiene ceros que hay que almacenar y actualizar en cada operación aritmética. Aparte de las ventajas y desventajas explicadas antes, los procesadores operan con palabras de tamaño fijo por una razón: simplicidad. Pero una vez que necesitamos superar esos tamaños, y no teniendo instrucciones nativas para operar con tamaños mayores y fijos, ¿seguimos necesitando que sean fijos?. 36.

(38) Referencias [1]. Arbitrary-precision arithmetic: Applications. url: https://en.wikipedia.org/ wiki/Arbitrary-precision_arithmetic#Applications (visitado 27-08-2017).. [2]. Jonathan M. Borwein David H. Bailey. High-Precision Arithmetic in Mathematical Physics. url: http : / / www . mdpi . com / 2227 - 7390 / 3 / 2 / 337 / pdf (visitado 27-08-2017).. [3]. Not invented here. url: https://en.wikipedia.org/wiki/Not_invented_here (visitado 12-06-2018).. [4]. List of arbitrary-precision arithmetic software. url: https : / / en . wikipedia . org / wiki / List _ of _ arbitrary - precision _ arithmetic _ software (visitado 20-08-2017).. [5]. Boost (C++ libraries). url: https://en.wikipedia.org/wiki/Boost_(C%2B% 2B_libraries) (visitado 20-08-2017).. [6]. Christian Kaiser Tomasz Sowa. Frequency asked questions about TTMath. url: http://www.ttmath.org/faq (visitado 20-08-2017).. [7]. GNU Multiple Precision Arithmetic Library. url: https://en.wikipedia.org/ wiki/GNU_Multiple_Precision_Arithmetic_Library (visitado 20-08-2017).. [8]. Schönhage–Strassen algorithm. url: https://en.wikipedia.org/wiki/Sch%C3% B6nhage%E2%80%93Strassen_algorithm (visitado 20-08-2017).. [9]. Divide and Conquer Division. url: https://gmplib.org/manual/Divide-andConquer-Division.html (visitado 20-08-2017).. [10]. Class Library for Numbers. url: https : / / en . wikipedia . org / wiki / Class _ Library_for_Numbers (visitado 20-08-2017).. [11]. David H. Bailey y col. High-Precision Software Directory. url: http://crd.lbl. gov/~dhbailey/mpdist/arprec-2.2.19.tar.gz (visitado 13-08-2017). Michael C. Ring. url: https://github.com/LuaDist/mapm/raw/master/m_apm. h (visitado 20-08-2017).. [12] [13]. MPIR (mathematics software). url: https://en.wikipedia.org/wiki/MPIR_ (mathematics_software) (visitado 20-08-2017).. [14]. Libgcrypt. url: https://en.wikipedia.org/wiki/Libgcrypt (visitado 12-05-2018).. [15]. Status of Supported Architectures from Maintainers’ Point of View. url: https: //gcc.gnu.org/backends.html (visitado 24-08-2017).. [16]. Middle-endian. url: https://en.wikipedia.org/wiki/Endianness#Middleendian (visitado 25-08-2017).. [17]. Stream buffers. url: https://en.wikipedia.org/wiki/Cache_prefetching# Stream_buffers (visitado 26-08-2017).. [18]. IEEE 754. url: https://en.wikipedia.org/wiki/IEEE_754 (visitado 11-09-2017). 37.

(39) [19]. c++11 - Is signed integer overflow still undefined behaviour in C++? url: https: //stackoverflow.com/questions/16188263/is-signed-integer-overflowstill-undefined-behavior-in-c (visitado 04-07-2018).. [20]. Karatsuba algorithm. url: https : / / en . wikipedia . org / wiki / Karatsuba _ algorithm (visitado 29-05-2018).. [21]. División larga. url: https://es.wikipedia.org/wiki/Divisi%C3%B3n_larga (visitado 29-05-2018).. [22]. Integer division with remainder. url: https : / / en . wikipedia . org / wiki / Division _ algorithm # Integer _ division _ (unsigned ) _with _ remainder (visitado 29-05-2018).. [23]. Exponenciación binaria. url: https://es.wikipedia.org/wiki/Exponenciaci% C3%B3n_binaria (visitado 03-07-2018).. [24]. Toom-Cook multiplication. url: https://en.wikipedia.org/wiki/Toom%E2%80% 93Cook_multiplication (visitado 06-07-2018).. [25]. Convolución. url: https://es.wikipedia.org/wiki/Convoluci%C3%B3n (visitado 06-07-2018).. [26]. Transformada rápida de Fourier. url: https : / / es . wikipedia . org / wiki / Transformada_r%C3%A1pida_de_Fourier (visitado 06-07-2018).. 38.

(40)

Referencias

Documento similar