5 veces más líneas, 400 veces más rápido

Xochitl está a veces bajo ataque. En general son ataques idiotas que tratan de entrar por SSH usando combinaciones de usuario/clave del tipo “root/root” o “user1/user1”; evidentemente eso casi nunca funciona, y además esos ataques son automáticamente detenidos después de tres intentos fallidos con denyhosts.

Esos ataques no me dan problemas; me dan problemas los ataques dirigidos específicamente contra mi blog y/o galería en línea. No porque alguna vez hayan logrado nada (los mantengo actualizados); el problema es que a veces generan una cantidad tal de solicitudes que Apache comienza a sobrecargar MySQL, la base de datos queda trabada, y Apache entonces se queda atorado sirviendo páginas. Si los atacantes solicitan muchísimas solicitudes a la vez, esto causa que MySQL quede atorado con cientos de consultas en su cola, y por lo tanto que Apache quede atorado con cientos (o miles) de páginas que quieren ser servidas.

Como Apache trata de no tirar conexiones, y cada una de ellas utiliza procesador, esto hace que el CPU de Xochitl de repente se encuentre utilizado al 117%. Aquí es donde debo mencionar que Xochitl es una pobre Pentium 4 a 2.40 Ghz; es posible (y de hecho probable) que la mayoría de los teléfonos celulares inteligentes que han salido este año sean más rápidos (y tengan más memoria) que Xochitl.

De todo lo anterior no es esta entrada.

Esta entrada es acerca de una situación que encontré mientras buscaba qué poder hacer para aliviar a la pobre de Xochitl. La más sencilla es ver qué IPs están solicitando más conexiones HTTP, y agregarlas a /etc/hosts.deny (teniendo cuidado de no negarme acceso a mí y mis máquinas, o a los robots rastreros de Google). Suele funcionar; sobre todo considerando que estos “ataques” (la verdad ya no sé si son ataques o sólo lectores ligeramente stalkeadores de mi blog/álbum) no ocurren muy seguido.

Así que hice un programita que leyera los logs (o bitácora, si quisiera usar español correcto) de acceso de Apache, sacara las IPs, y contara cuántas veces aparece cada una. Como lo primero que aparece en cada línea es la IP solicitante seguida de un espacio, con el siguiente comando obtengo todas las IPs que solicitan páginas a Apache:

cat /var/log/apache2/access_log | cut -d " " -f 1

Hasta ahí vamos bien; ahora, ¿cómo saco de ahí cuántas veces se repite una IP?, porque sabiendo eso ya puedo saber cuáles IP solicitan un número ridículo de conexiones. Siendo, como soy, programador, escribí un programita que hiciera esto por mí. Lo escribí en Python, porque lo quería rápido, y esto me salió:

#!/usr/bin/env python

import sys

if __name__ == '__main__':
    ips = {}
    for line in sys.stdin.readlines():
        line = line.strip()
        if line in ips.keys():
            ips[line] = ips[line] + 1
        else:
            ips[line] = 1
    for ip in ips.keys():
        print('%d: %s' % (ips[ip], ip))

Esas son 14 líneas de Python, incluyendo el shebang y dos líneas en blanco. El programa lee línea a línea la entrada estándar, y usa un hash table para ir contando cada aparición de una IP.

Muy contento con mi programa lo corrí… y el maldito programa corrió, y corrió, y corrió, y siguió corriendo. Al minuto lo detuve, incrédulo de que pudiera ser tan endiabladamente lento. Lo revisé, lo puse a imprimir resultados intermedios, y el resultado era el mismo: es lentísimo.

Estúpido Python.

Me subí las mangas y lo reescribí en C, usando glib porque no me iba a a poner a escribir mi propio hash table (been there, done that). Esto me salió:

#include <stdio.h>
#include <string.h>
#include <glib.h>

typedef struct _integer integer;

struct _integer {
        int n;
};

static integer*
integer_new(int n) 
{
        integer* i = g_new(integer, 1);
        i->n = n;
        return i;
}

static char*
read_line(FILE* file)
{
        char line[4096];
        int i = 0;
        line[i] = (char)0;
        int c;
        while (TRUE) {
                c = fgetc(file);
                if (c == EOF || c == NEW_LINE)
                        return strdup(line);
                line[i++] = c;
                line[i] = char(0);
        }
        return strdup(line);
}

void
print_key_value(char* key, integer* value, gpointer user_data)
{
        printf("%d: %s\n", value->n, key);
}

int
main(int argc, char* argv[])
{
        GHashTable* h;
        h = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
        do {
                char* line = read_line(stdin);
                if (!strcmp(line, "")) {
                        free(line);
                        continue;
                }
                char* key;
                integer* value;
                if (g_hash_table_lookup_extended(h, line, &key, &value)) {
                        value->n++;
                } else {
                        value = integer_new(1);
                        g_hash_table_insert(h, line, value);
                }
        } while (!feof(stdin));
        g_hash_table_foreach(h, print_key_value, NULL);
        g_hash_table_destroy(h);
        return 0;
}

Esas son 65 líneas en C, incluyendo la definición medio redundante de una estructura integer porque no quise usar las macros GINT_TO_POINTER y GPOINTER_TO_INT. No es elegante.

Ya que tuve mis dos versiones, según yo, equivalentes, las corrí ambas. La salida que producen es idéntica, así que me parece que sí son equivalentes. La versión en Python tarda más o menos 1 minuto 58 segundos (más/menos dos segundos, en todas las ocasiones en que lo corrí). La versión en C tarda 0.285 segundos, consistentemente debajo de 0.290. Esto para una bitácora de 95,080 líneas, de 12 Megabytes de tamaño.

La versión en C es unas 5 veces más larga en líneas que la de Python (de hecho 4.333, pero no importa), además de que las líneas tienen más caracteres; y sin embargo tarda (en tiempo de ejecución) del orden de 400 veces menos.

Ahí está el código si alguien quiere tratar de mejorar el resultado en Python. Yo estoy sumamente decepcionado; creí que las hash tables de Python estaban decentemente optimizadas: estoy usando cadenas como llaves al fin y al cabo. Y lo peor es que la versión en C ni siquiera me tomó mucho más tiempo en escribir.

Actualización: Gracias a Omar, ya vi que estaba cometiendo un errosote al buscar la llave en la hash table de Python; no tenía porqué buscarla en .keys() cuando puedo hacerlo directamente en la tabla. Con la sugerencia de Omar, el código Python es solamente el doble de lento que el código C. Lo que de hecho tiene sentido.

19 comentarios sobre “5 veces más líneas, 400 veces más rápido

  1. La versión en Python es lenta porque hace muchísimo trabajo extra. :) La línea
    if line in ips.keys() itera sobre todas las ips's previamente encontradas y las compara con línea de una por una! (En Python 3 keys devuelve un generador, en Python 2 devuelve una lista, en ningún caso devuelve un conjunto o hash table con un 'in' de tiempo constante.) Cámbiala por if line in ips , o sea, borra keys(), y debería ser mucho más rápido.

    1. Mucho mejor; gracias Omar. Con tu sugerencia, el tiempo se redujo a 0.540 segundos; casi el doble que la versión en C, pero ya no me siento decepcionado. Sabía que tenía que estar haciendo algo mal; no era posible que sí fuera 400 veces más lento.

    1. Fíjate que está interesante; mi versión read_line apesta horriblemente. Usando getline, casi se triplica la velocidad de mi código: tarda alrededor de 0.090 segundos en terminar, y además se reduce su tamaño a 48 líneas. Entonces el código en C es 3 veces más grande que el código en Python (un poquito más, de hecho), y más de 5 veces más rápido.

  2. Una última modificación; ya entrado en negocios, tiré a la basura la estructura integer y utilicé GINT_TO_POINTER y GPOINTER_TO_INT. La velocidad no cambia realmente (sigue siendo igual), pero el tamaño en líneas se reduce a 34, que es poquito más del doble. Así que la relación termina siendo: 2 veces más líneas, 5 veces más rápido.

    La versión final en C:

    #include < stdio.h >
    #include < string.h >
    #include < glib.h >
    
    void
    print_key_value(char* key, gpointer value, gpointer user_data)
    {
            printf("%d: %s", GPOINTER_TO_INT(value), key);
    }
    
    int
    main(int argc, char* argv[])
    {
            size_t n;
            char line[4096];
            GHashTable* h;
            h = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
            do {
                    getline(&line, &n, stdin);
                    if (!strcmp(line, ""))
                            continue;
                    char* key;
    	        int value;
                    if (g_hash_table_lookup_extended(h, line, &key, &value)) {
                            value++;
                    } else {
                            value = 1;
                            g_hash_table_insert(h, strdup(line), GINT_TO_POINTER(value));
                    }
            } while (!feof(stdin));
            g_hash_table_foreach(h, print_key_value, NULL);
            g_hash_table_destroy(h);
            return 0;
    }
  3. No, estaba usando mal getline. Ya usándola bien, resulta que mi read_line no estaba tan mal: con getline se tarda 0.280 segundos más o menos, lo que la hace básicamente igual a mi implementación. Eso sí, el número de líneas sí se reduce: 37 (me faltaban unas cosas para usar bien getline).

    Así que en resumen C es el doble de grande, y el doble de rápido, más o menos. Lo cual no sé si me sorprende exactamente.

    La versión final del código en C:

    #include < stdio.h >
    #include < stdlib.h >
    #include < string.h >
    #include < glib.h >
    
    void
    print_key_value(char* key, gpointer value, gpointer user_data)
    {
            printf("%d: %s", GPOINTER_TO_INT(value), key);
    }
    
    int
    main(int argc, char* argv[])
    {
            size_t n = 4096;
            char* line = malloc(sizeof(char) * n);
            GHashTable* h;
            h = g_hash_table_new_full(g_str_hash, g_str_equal, free, NULL);
            do {
                    getline(&line, &n, stdin);
                    if (!strcmp(line, ""))
                            continue;
                    char* key;
    	        int value;
                    if (g_hash_table_lookup_extended(h, line, &key, &value)) {
                            value++;
    		        g_hash_table_insert(h, strdup(line), GINT_TO_POINTER(value));
                    } else {
                            value = 1;
                            g_hash_table_insert(h, strdup(line), GINT_TO_POINTER(value));
                    }
            } while (!feof(stdin));
            free(line);
            g_hash_table_foreach(h, print_key_value, NULL);
            g_hash_table_destroy(h);
            return 0;
    }
  4. ¿Qué tal funciona Counter de la biblioteca estándar de Python?

    #!/usr/bin/env python2
    
    import sys
    from collections import Counter
        
    if __name__ == '__main__':
        for (cnt, ip) in Counter(sys.stdin).iteritems():
            print("%d: %s" % (ip, cnt))

    Eso es Python 2, creo que lo que tu pusiste es válido en 2 o 3, así que no sé cual usas. En Python 3 las últimas dos líneas serían:

    for (cnt, ip) in Counter(sys.stdin).items():
        print("%d: %s" % (ip, cnt), end='')
    
      1. Pero al menos es un poco más corto. :)
        (Y, también un poco más explícito acerca de que hace, se podría argumentar: en lugar de implementar un contador, que el lector tiene que leer para verificar que hace lo normal, esta versión declara claramente sus intenciones al usar Counter.)

    1. Porque hacer aritmética en shell apesta, y porque shell mismo apesta.

      Lo pensé, pero si te das cuenta no es trivial; o al menos no me pareció a mí trivial, mientras que el programa en Python sí lo era. Que se me hubiera olvidado cómo verificar rápido si un hash tiene o no una llave es otra cosas ;)

      El programa en C también es trivial, gracias a glib. Pero si tienes idea de cómo hacerlo trivialmente en shell, y razonablemente rápido, me encantaría verlo.

        1. Mira pues. Sí funciona; es más lento que Python (y por lo tanto que C), pero sin duda es el más sencillo de todos. Y fíjate que sí busqué cómo hacer que uniq contara, pero de alguna manera se me pasó -c. Gracias.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *