Acerca de una aproximación más rápida de log (x)


10

Había escrito un código hace un tiempo que intentaba calcular sin usar las funciones de la biblioteca. Ayer, estaba revisando el código anterior, e intenté hacerlo lo más rápido posible (y correcto). Aquí está mi intento hasta ahora:log(x)

const double ee = exp(1);

double series_ln_taylor(double n){ /* n = e^a * b, where a is an non-negative integer */
    double lgVal = 0, term, now;
    int i, flag = 1;

    if ( n <= 0 ) return 1e-300;
    if ( n * ee < 1 )
        n = 1.0 / n, flag = -1; /* for extremely small n, use e^-x = 1/n */

    for ( term = 1; term < n ; term *= ee, lgVal++ );
    n /= term;

    /* log(1 - x) = -x - x**2/2 - x**3/3... */
    n = 1 - n;
    now = term = n;
    for ( i = 1 ; ; ){
        lgVal -= now;
        term *= n;
        now = term / ++i;
        if ( now < 1e-17 ) break;
    }

    if ( flag == -1 ) lgVal = -lgVal;

    return lgVal;
}

Aquí estoy tratando de encontrar para que esté justo por encima de n, y luego agrego el valor del logaritmo de , que es menor que 1. En este punto, La expansión Taylor de se puede utilizar sin preocuparse.e a naea log(1-x)nealog(1  x)

Recientemente he crecido el interés en el análisis numérico, y es por eso que no puedo evitar hacer la pregunta, ¿cuánto más rápido se puede ejecutar este segmento de código en la práctica, sin dejar de ser lo suficientemente correcto? ¿Necesito cambiar a otros métodos, por ejemplo, usando una fracción continua, como esta ?

La función proporcionada con la biblioteca estándar de C es casi 5.1 veces más rápida que esta implementación.log(x)

ACTUALIZACIÓN 1 : Usando la serie de arctanos hiperbólicos mencionados en Wikipedia , el cálculo parece ser casi 2.2 veces más lento que la función de registro de biblioteca estándar de C. Sin embargo, no he comprobado exhaustivamente el rendimiento, y para grandes cantidades, mi implementación actual parece REALMENTE lenta. Si puedo administrar, quiero verificar tanto la implementación de errores como el tiempo promedio para una amplia gama de números. Aquí está mi segundo esfuerzo.

double series_ln_arctanh(double n){ /* n = e^a * b, where a is an non-negative integer */
    double lgVal = 0, term, now, sm;
    int i, flag = 1;

    if ( n <= 0 ) return 1e-300;
    if ( n * ee < 1 ) n = 1.0 / n, flag = -1; /* for extremely small n, use e^-x = 1/n */

    for ( term = 1; term < n ; term *= ee, lgVal++ );
    n /= term;

    /* log(x) = 2 arctanh((x-1)/(x+1)) */
    n = (1 - n)/(n + 1);

    now = term = n;
    n *= n;
    sm = 0;
    for ( i = 3 ; ; i += 2 ){
        sm += now;
        term *= n;
        now = term / i;
       if ( now < 1e-17 ) break;
    }

    lgVal -= 2*sm;

    if ( flag == -1 ) lgVal = -lgVal;
    return lgVal;
}

Cualquier sugerencia o crítica es apreciada.

ACTUALIZACIÓN 2: según las sugerencias que se hacen a continuación, he agregado algunos cambios incrementales aquí, que es aproximadamente 2.5 veces más lento que la implementación estándar de la biblioteca. Sin embargo, esta vez lo he probado solo para enteros , para números más grandes el tiempo de ejecución aumentaría. Por ahora. Todavía no conozco técnicas para generar números dobles aleatorios , por lo que aún no está completamente comparado. Para hacer que el código sea más robusto, he agregado correcciones para los casos de esquina. El error promedio para las pruebas que hice es de alrededor de .1 e 308 4 e - 151e81e3084e15

double series_ln_better(double n){ /* n = e^a * b, where a is an non-negative integer */
    double lgVal = 0, term, now, sm;
    int i, flag = 1;

    if ( n == 0 ) return -1./0.; /* -inf */
    if ( n < 0 ) return 0./0.;   /* NaN*/
    if ( n < 1 ) n = 1.0 / n, flag = -1; /* for extremely small n, use e^-x = 1/n */

    /* the cutoff iteration is 650, as over e**650, term multiplication would
       overflow. For larger numbers, the loop dominates the arctanh approximation
       loop (with having 13-15 iterations on average for tested numbers so far */

    for ( term = 1; term < n && lgVal < 650 ; term *= ee, lgVal++ );
    if ( lgVal == 650 ){
        n /= term;
        for ( term = 1 ; term < n ; term *= ee, lgVal++ );
    }
    n /= term;

    /* log(x) = 2 arctanh((x-1)/(x+1)) */
    n = (1 - n)/(n + 1);

    now = term = n;
    n *= n;
    sm = 0;

    /* limiting the iteration for worst case scenario, maximum 24 iteration */
    for ( i = 3 ; i < 50 ; i += 2 ){
        sm += now;
        term *= n;
        now = term / i;
        if ( now < 1e-17 ) break;
    }

    lgVal -= 2*sm;

    if ( flag == -1 ) lgVal = -lgVal;

    return lgVal;
}

Respuestas:


17

Esta no es realmente una respuesta autorizada , más una lista de problemas que creo que deberías considerar, y no he probado tu código.

log2.15.1

f(x)doublen12

n1.7976e+308term=infn=11017nterm *= e709.78266108405500745

1030000

Sospecho que con un poco de esfuerzo puede sacrificar parte de esa solidez por el rendimiento, por ejemplo, restringiendo el rango de argumentos o devolviendo resultados ligeramente menos precisos.

3. El rendimiento de este tipo de código puede depender mucho de la arquitectura de la CPU en la que se ejecuta. Es un tema profundo e involucrado, pero los fabricantes de CPU como Intel publican guías de optimización que explican las diferentes interacciones entre su código y la CPU en la que se está ejecutando. El almacenamiento en caché puede ser relativamente sencillo, pero cosas como la predicción de ramales, el paralelismo a nivel de instrucción y los bloqueos de canalización debido a dependencias de datos son difíciles de ver con precisión en el código de alto nivel, pero son muy importantes para el rendimiento.

x~y~=f~(x~)y=f(x~)¿preciso?). Esto no es lo mismo que mostrar que la serie Taylor converge, debido a la presencia de errores de redondeo de punto flotante.

4.5. Una buena manera de probar la precisión de una función no probada sería evaluarla en cada uno de los cuatro mil millones (menos si está haciendo la reducción de argumentos correctamente, como aquí) flotantes de precisión simple, y comparar los errores con el registro estándar de libm. Toma un poco de tiempo, pero al menos es completo.

5. Como sabes desde el principio la precisión de los dobles, no tienes que tener un bucle ilimitado: el número de iteraciones se puede calcular por adelantado (probablemente sea aproximadamente 50). Use esto para eliminar ramas de su código, o al menos establezca el número de iteraciones por adelantado.

También se aplican todas las ideas habituales sobre el desenrollado de bucles.

6. Es posible utilizar técnicas de aproximación distintas a las series de Taylor. También hay series de Chebyshev (con la recurrencia de Clenshaw), aproximaciones de Pade y, a veces, métodos de búsqueda de raíces como el método de Newton cada vez que su función se puede refundir como la raíz de una función más simple (por ejemplo, el famoso truco sqrt ).

Las fracciones continuas probablemente no serán demasiado grandes, porque implican división, que es mucho más costosa que las multiplicaciones / sumas. Si nos fijamos en _mm_div_ssal https://software.intel.com/sites/landingpage/IntrinsicsGuide/ , la división tiene una latencia de 13-14 ciclos y el rendimiento de 5-14, dependiendo de la arquitectura, en comparación con 3-5 / 0,5-1 para multiplicar / agregar / madd. Entonces, en general (no siempre) tiene sentido tratar de eliminar las divisiones tanto como sea posible.

Desafortunadamente, las matemáticas no son una guía tan buena aquí, porque las expresiones con fórmulas cortas no son necesariamente las más rápidas. Las matemáticas no penalizan las divisiones, por ejemplo.

x=m×2em12<m1exfrexp

8. Compare su logcon el login libmo openlibm(por ejemplo: https://github.com/JuliaLang/openlibm/blob/master/src/e_log.c ). Esta es, con mucho, la forma más fácil de descubrir lo que otras personas ya han descubierto. También hay versiones especialmente optimizadas de libm fabricantes específicos de CPU, pero generalmente no tienen su código fuente publicado.

Boost :: sf tiene algunas funciones especiales, pero no las básicas. Sin embargo, puede ser instructivo mirar la fuente de log1p: http://www.boost.org/doc/libs/1_58_0/libs/math/doc/html/math_toolkit/powers/log1p.html

También hay bibliotecas aritméticas de precisión arbitraria de código abierto como mpfr, que pueden usar algoritmos diferentes que libm debido a la mayor precisión requerida.

9. La precisión y estabilidad de los algoritmos numéricos de Higham es una buena introducción de nivel superior para analizar errores de algoritmos numéricos. Para los algoritmos de aproximación en sí mismos, la Teoría de la aproximación Práctica de aproximación de Trefethen es una buena referencia.

10. Sé que esto se dice con demasiada frecuencia, pero los proyectos de software razonablemente grandes rara vez dependen del tiempo de ejecución de una función pequeña que se llama una y otra vez. No es tan común tener que preocuparse por el rendimiento del registro, a menos que haya perfilado su programa y se haya asegurado de que sea importante.


26414e15

1.13e13term

 1e8

1
k=11071lnk

2
frexp x=m×2elnx=eln2+lnm

5

La respuesta de Kirill ya tocó una gran cantidad de temas relevantes. Me gustaría ampliar algunos de ellos basados ​​en la experiencia práctica de diseño de la biblioteca de matemáticas. Una nota por adelantado: los diseñadores de la biblioteca matemática tienden a usar todas las optimizaciones algorítmicas publicadas, así como muchas optimizaciones específicas de la máquina, no todas las cuales se publicarán. El código frecuentemente se escribirá en lenguaje ensamblador, en lugar de usar código compilado. Por lo tanto, es poco probable que una implementación sencilla y compilada logre más del 75% del rendimiento de una implementación de biblioteca matemática de alta calidad existente, suponiendo conjuntos de características idénticas (precisión, manejo de casos especiales, informes de errores, soporte de modo de redondeo).

explogerfcΓ

La precisión generalmente se evalúa en comparación con una referencia (de terceros) de mayor precisión. Las funciones de precisión simple de argumento único se pueden probar fácilmente exhaustivamente, otras funciones requieren pruebas con vectores de prueba aleatorios (dirigidos). Claramente, uno no puede calcular resultados de referencia infinitamente precisos, pero la investigación sobre el Dilema del Creador de tablas sugiere que para muchas funciones simples es suficiente calcular una referencia con una precisión de aproximadamente tres veces la precisión objetivo. Ver por ejemplo:

Vincent Lefèvre, Jean-Michel Muller, "Los peores casos para el redondeo correcto de las funciones elementales en doble precisión". En Actas, 15º Simposio IEEE sobre aritmética informática , 2001,111-118). (preimpresión en línea)

En términos de rendimiento, uno debe distinguir entre la optimización de la latencia (importante cuando se observa el tiempo de ejecución de las operaciones dependientes) y la optimización del rendimiento (relevante cuando se considera el tiempo de ejecución de las operaciones independientes). En los últimos veinte años, la proliferación de técnicas de paralelización de hardware como el paralelismo a nivel de instrucción (por ejemplo, procesadores superescalares, fuera de orden), el paralelismo a nivel de datos (por ejemplo, instrucciones SIMD) y el paralelismo a nivel de hilo (por ejemplo, hiper-threading, procesadores de múltiples núcleos) ha llevado a un énfasis en el rendimiento computacional como la métrica más relevante.

log(1+x)=p(x)log(x)=2atanh((x1)/(x+1))=p(((x1)/(x+1))2)p

La operación fusionada de adición múltiple ( FMA ), introducida por primera vez por IBM hace 25 años, y ahora disponible en todas las arquitecturas de procesadores principales, es un componente fundamental de las implementaciones modernas de la biblioteca matemática. Proporciona reducción de errores de redondeo, brinda protección limitada contra la cancelación sustractiva y simplifica enormemente la aritmética doble-doble .

C99log()C99fma()233

#include <math.h>

/* compute natural logarithm

   USE_ATANH == 1: maximum error found: 0.83482 ulp @ 0.7012829191167614
   USE_ATANH == 0: maximum error found: 0.83839 ulp @ 1.2788954397331760
*/
double my_log (double a)
{
    const double LOG2_HI = 0x1.62e42fefa39efp-01; // 6.9314718055994529e-01
    const double LOG2_LO = 0x1.abc9e3b39803fp-56; // 2.3190468138462996e-17
    double m, r, i, s, t, p, f, q;
    int e;

    m = frexp (a, &e);
    if (m < 0.70703125) { // 181/256
        m = m + m;
        e = e - 1;
    }
    i = (double)e;

    /* m in [181/256, 362/256] */

#if USE_ATANH
    /* Compute q = (m-1) / (m+1) */
    p = m + 1.0;
    m = m - 1.0;
    q = m / p;

    /* Compute (2*atanh(q)/q-2*q) as p(q**2), q in [-75/437, 53/309] */
    s = q * q;
    r =             0x1.2f1da230fb057p-3;  // 1.4800574027992994e-1
    r = fma (r, s,  0x1.399f73f934c01p-3); // 1.5313616375223663e-1
    r = fma (r, s,  0x1.7466542530accp-3); // 1.8183580149169243e-1
    r = fma (r, s,  0x1.c71c51a8bf129p-3); // 2.2222198291991305e-1
    r = fma (r, s,  0x1.249249425f140p-2); // 2.8571428744887228e-1
    r = fma (r, s,  0x1.999999997f6abp-2); // 3.9999999999404662e-1
    r = fma (r, s,  0x1.5555555555593p-1); // 6.6666666666667351e-1
    r = r * s;

    /* log(a) = 2*atanh(q) + i*log(2) = LOG2_LO*i + p(q**2)*q + 2q + LOG2_HI*i.
       Use K.C. Ng's trick to improve the accuracy of the computation, like so:
       p(q**2)*q + 2q = p(q**2)*q + q*t - t + m, where t = m**2/2.
    */
    t = m * m * 0.5;
    r = fma (q, t, fma (q, r, LOG2_LO * i)) - t + m;
    r = fma (LOG2_HI, i, r);

#else // USE_ATANH

    /* Compute f = m -1 */
    f = m - 1.0;
    s = f * f;

    /* Approximate log1p (f), f in [-75/256, 106/256] */
    r = fma (-0x1.961d64ddd82b6p-6, f, 0x1.d35fd598b1362p-5); // -2.4787281515616676e-2, 5.7052533321928292e-2
    t = fma (-0x1.fcf5138885121p-5, f, 0x1.b97114751d726p-5); // -6.2128580237329929e-2, 5.3886928516403906e-2
    r = fma (r, s, t);
    r = fma (r, f, -0x1.b5b505410388dp-5); // -5.3431043874398211e-2
    r = fma (r, f,  0x1.dd660c0bd22dap-5); //  5.8276198890387668e-2
    r = fma (r, f, -0x1.00bda5ecdad6fp-4); // -6.2680862565391612e-2
    r = fma (r, f,  0x1.1159b2e3bd0dap-4); //  6.6735934054864471e-2
    r = fma (r, f, -0x1.2489f14dd8883p-4); // -7.1420614809115476e-2
    r = fma (r, f,  0x1.3b0ee248a0ccfp-4); //  7.6918491287915489e-2
    r = fma (r, f, -0x1.55557d3b497c3p-4); // -8.3333481965921982e-2
    r = fma (r, f,  0x1.745d4666f7f48p-4); //  9.0909266480136641e-2
    r = fma (r, f, -0x1.999999d959743p-4); // -1.0000000092767629e-1
    r = fma (r, f,  0x1.c71c70bbce7c2p-4); //  1.1111110722131826e-1
    r = fma (r, f, -0x1.fffffffa61619p-4); // -1.2499999991822398e-1
    r = fma (r, f,  0x1.249249262c6cdp-3); //  1.4285714290377030e-1
    r = fma (r, f, -0x1.555555555f03cp-3); // -1.6666666666776730e-1
    r = fma (r, f,  0x1.999999999759ep-3); //  1.9999999999974433e-1
    r = fma (r, f, -0x1.fffffffffff53p-3); // -2.4999999999999520e-1
    r = fma (r, f,  0x1.555555555555dp-2); //  3.3333333333333376e-1
    r = fma (r, f, -0x1.0000000000000p-1); // -5.0000000000000000e-1

    /* log(a) = log1p (f) + i * log(2) */
    p = fma ( LOG2_HI, i, f);
    t = fma (-LOG2_HI, i, p);
    f = fma ( LOG2_LO, i, f - t);
    r = fma (r, s, f);
    r = r + p;
#endif // USE_ATANH

    /* Handle special cases */
    if (!((a > 0.0) && (a <= 0x1.fffffffffffffp1023))) {
        r = a + a;  // handle inputs of NaN, +Inf
        if (a  < 0.0) r =  0.0 / 0.0; //  NaN
        if (a == 0.0) r = -1.0 / 0.0; // -Inf
    }
    return r;
}

(+1) ¿Sabe si las implementaciones comunes de código abierto (como openlibm) son tan buenas como pueden ser, o pueden mejorarse sus funciones especiales?
Kirill el

1
@Kirill La última vez que miré las implementaciones de código abierto (hace muchos años), no estaban explotando los beneficios de FMA. Cuando IBM Power e Intel Itanium eran las únicas arquitecturas que incluían la operación, ahora el soporte de hardware es omnipresente. Además, las aproximaciones de tabla más polinomios eran lo último en tecnología en ese momento, ahora las tablas están desfavorecidas: el acceso a la memoria da como resultado un mayor uso de energía, pueden (y lo hacen) interferir con la vectorización, y el rendimiento computacional ha aumentado más que el rendimiento de la memoria resultando en un potencial impacto negativo en el rendimiento de las tablas.
njuffa
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.