Benchmarks de lxml, urllib2 y pycurl

21 03 2009

Llevo como mes y medio jugando con Python y la verdad me gusta. Para los que no estan famirializados con Python pues basta mencionar que urllib2 es una libreria para recuperar el contenido de una pagina Web. BeautifulSoup es un parser HTML/XML para lo que se conoce como screen-scraping, es decir analizar un contenido (en este caso texto) palabra por palabra para extraer la informacion que nos interesa. lxml viene siendo como urllib2 y BeautifulSoup en una sola libreria excepto que lxml nos permite utilizar XSLT y Selectores CSS para realizar el scraping. PyCurl en cambio, es una interfaz (o binding) de Python a la libreria de C libcurl. Ya que hablamos de bindings vale la pena mencionar,para entender el porque de este benchmark, que lxml es, a su vez, binding de libxml2 y libxslt. Los bindings son una interfaz, es decir, el modo de acceder a los servicios a librerias de C, generalmente, se trata de librerias del sistema operativo por lo que en teoria se supone que son mucho más rapidas quelas librerias escritas en python puro (o en java o ruby o cualquier otro lenguaje distinto a C/C++).

En resumen, urllib2 es libreria de python puro para realizar peticiones (requests) HTTP, lxml es binding de C para tratar XML/HTML (pero no para tratar requests HTTP), PyCurl es un binding de C para hacer peticiones HTTP (pero no para parsear el resultado). La pregunta entonces es, ¿cual combinacion ofrece el mejor rendimiento (performance)?. La pregunta me ha surgido mientras realizaba un ejercicio del libro Programming Collective Intelligence de Toby Seagaran el cual recomiendo (enlace enGoogle Books), por lo que el codigo original que utilizo es codigo del libro que adapte a los diferebtes casos de prueba.

Los benchmarks son: urllib2 con BeautifulSoup, solucion pura de lxml y PyCurl con lxml para parsear la pagina. Hay quemencionar que una de las grandes ventajas de PyCurl es utilizar su derivado CurlMulti ya que este permite realizar varias conexiones concurrentes lo cual es mucho más eficiente para este benchmark, sin embargo, el benchmark utiliza su modo normal el cual es secuencial ya que el propósito del benchmark es ver cual libreria es más rápida recuperando una pagina Web, extrayendo sus enlaces (<a>) y luego recuperando cada enlace para repetir el proceso, en escencia: un crawler/spider.


Primero, estos son los imports que necesitaremos:

import urllib2
from BeautifulSoup import *
from urlparse import urljoin
import pycurl
from lxml import etree, html
from StringIO import StringIO
import sys

urllib2, urlparse, StringIO y, por supuesto, sys son parte del core de Python (yo utilizo la version 2.5 en Ubuntu). las demas librerias se pueden obtener todas con apt-get (tambien cuentan con un instalador para Windows en sus respectivas paginas Web). Ahora definimos la primera función, la cual utiliza urllib2 + BeautifulSoup:

def crawl(pages, depth):
        '''A partir de una lista de páginas, hacemos un crawling completo
        hasta la profundidad dada'''
        for i in range(depth):
            newpages=set()
            for page in pages:
                try:
                    c=urllib2.urlopen(page)
                except:
                    print 'Could not open %s' % page
                    continue

                soup=BeautifulSoup(c.read())

                links=soup('a')
                print 'found %d links on %s' % (len(links), page)
                for link in links:
                    if 'href' in dict(link.attrs):
                        url=urljoin(page, link['href'])
                        if url.find("'")!=-1:
                            print "' found on %s" % url
                            continue
                        url=url.split('#')[0] # remove location part
                        if url[0:4]=='http':
                            newpages.add(url)
            pages=newpages

Esta función es, para efectos prácticos, la misma que encontramos es en libro. En resumen, la primera parte obtiene la pagina con urllib2, luego se parsea con BeautifulSoup y se extraen sus links. Luego se itera cada link obteniendo el ‘href‘ de cada uno, se utiliza urljoin para convertir los links relativos en absolutos, si el link apunta hacia un anchor (contiene #), se extrae la parte del anchor. Por ultimo se añade la URL obtenida al set newpages y asignamos el valor de newpages a pages de modo que la siguiente iteracion utilice las URLS que acabamos de encontrar. Ahora la versión lxml pura:

def lxmlCrawl(pages, depth):
    for i in range(depth):
        newpages=set()
        for page in pages:
            try:
                doc = html.parse(page).getroot()
                doc.make_links_absolute()
            except:
                print 'Could not open %s' % page
                continue

            links=doc.cssselect('a')
            print 'found %d links on %s' % (len(links), page)
            for link in links:
                if 'href' in dict(link.attrib):
                    url = link.attrib['href']
                    if url.find("'")!=-1:
                        print "' found on %s" % url
                        continue
                    url=url.split('#')[0] # remove location part
                    if url[0:4]=='http':
                        newpages.add(url)
            pages=newpages

esta versión es muy similar a la anterior excepto que (1) la peticion y el parseo se hace en un solo paso:

doc = html.parse(page).getroot()

(2) lxml nos da un metodo para convertir los links relativos a absolutos:

doc.make_links_absolute()

y (3) utilizamos un selector de CSS (‘a’) para obtener los links. Ahora la versión PyCurl (modo sencillo) + lxml para parsear el resultado:

def pycurlCrawl(pages, depth):
    print depth
    if depth>0:
        queue=[]
        newpages=set()
        for page in pages:
            try:
                c = pycurl.Curl()
                parser = PyCurlParser(page);
                queue.append(parser)
                c.setopt(c.URL, page)
                c.setopt(c.WRITEFUNCTION, parser.curl_body_callback)
                c.perform()
            except:
                print 'Could not open %s' % page
                continue
            c.close

        for p in queue:
            newpages.update(p.parseHTML())
        print newpages
        pycurlCrawl(newpages,depth-1)

class PyCurlParser:
    def __init__(self, url):
        self.url = url
        self.contents = ''
        self.newpages = set()

    def curl_body_callback(self, buf):
        self.contents = self.contents + buf

    def parseHTML(self):
        parser = etree.HTMLParser()
        tree   = etree.parse(StringIO(self.contents), parser)
        links = tree.getroot().xpath('//a')
        print 'found %d links on %s' % (len(links), self.url)
        for link in links:
            if 'href' in dict(link.attrib):
                url=urljoin(self.url, link.attrib['href'])
                print url
                url=url.split('#')[0] # remove location part
                if url[0:4]=='http':
                    self.newpages.add(url)
                    print 'added url: %s' % url
        return self.newpages

esta versión es la mas larga y compleja y no es en vano, libcurl es una libreria muy potente, es asíncrona y no es precisamente sencilla, por lo que su binding hereda esa complejidad. Se compone de 2 partes principales: (1) la funcion pycurlCrawl y (2) la clase PyCurlParser.

pycurlCrawl se encarga de realizar las peticiones a cada página asignandole una instancia de PyCurlParser a cada peticion de curl, por último cada instancia es encolada (se agrega a queue). La cola y la clase PyCurlParser son necesarias porque PyCurl al ser asíncrona utiliza callbacks, pero cada peticion tarda distinto tiempo, es decir, algunas peticiones reciben archivos HTML de mayor tamaño que otras y algunas puede incluso que fallen (pero no fallan de inmediato, primero tiene que vencerse el TIMEOUT del request). Esto crea un problema de sincronización ya que la sentencia

c.setopt(c.WRITEFUNCTION, curl_body_callback)

dice a Curl que escriba el resultado de la peticion (es decir, el contenido de la página) a la funcion curl_body_callback la cual simplemente asigna el contenido de la página a la variable contents. Es importante distinguir que contents se llena con un buffer buf que es lo que envia Curl con su funcion c.WRITEFUNCTION. Esto se dá porque si la página es muy grande o la conexión muy lenta, necesitaremos varios chunks (buffers) para llenar todo el contenido de la página. El resultado de todo esto es que necesito asegurarme que cada peticion tenga su propia variable contents para que el bucle de pycurlCrawl siga enviando peticiones mientras cada instancia de PyCurlParser espera que su callback finalice. Como no sé cuanto pueda tardar, la forma más fácil (a mi modo de ver) es encolar todas las instancias de PyCurlParser para que, una vez finalizadas todas las peticiones, proceder a parsear la página y extraer sus enlaces (links), esto se hace con la función PyCurlParser.parseHTML. A diferencia de la version de lxml pura, aqui etree.HTMLParser() para parsear la página a partir de la variable tipo string contents para lo que hace falta StringIO que básicamente es convertir un string en un stream. Tambien, a diferencia de la versión de lxml puro, en esta versión utilizo una expresión XPath (‘//a’) en lugar de un selector CSS para obtener todos los enlaces de cada página. La última diferencia importante es que pycurlCrawl, a diferencia de sus versions para urllib2 y lxml puro, no usa un bucle for para controlar la profundidad del crawler sino que lo hace a traves de recursión

pycurlCrawl(newpages,depth-1)

esto tambien es una consecuencia del funcionamiento asíncrono de PyCurl.

Por último necesitamos una función que se encargará de medir el rendimiento de cada función. Para ello utilizamos la libreria de Python, timeit:

if __name__=='__main__':
    from timeit import Timer
    iter = int(sys.argv[1]) if len(sys.argv)==2 else 1
    ul = Timer("crawl(['http://kiwitobes.com/wiki/Pirates_of_Silicon_Valley.html'],2)",\
    "from __main__ import crawl")
    u = ul.timeit(iter)
    lx = Timer("lxmlCrawl(['http://kiwitobes.com/wiki/Pirates_of_Silicon_Valley.html'],2)",\
    "from __main__ import lxmlCrawl")
    x = lx.timeit(iter)
    pc = Timer("pycurlCrawl(['http://kiwitobes.com/wiki/Pirates_of_Silicon_Valley.html'],2)",\
    "from __main__ import pycurlCrawl")
    p = lx.timeit(iter)
    print "Completed in: urlib: %f lxml: %f pycurl: %f" % (u,x,p)

Para finalizar solo queda mencionar que la URL http://kiwitobes.com/wiki/ pertenece al blog de Toby Seagaran y es basicamente una copia estática (o sea que nadie la edita) de Wikipedia. Muchos enlaces en la url que utilizo de prueba fallaran, pero esto me da una aproximación más realista a lo que encontraria un crawler en condiciones reales. Para realizar el benchmark, se pone todo junto en un mismo archivo (compare.py) y desde linea de comando ejecutas:

python compare.py [iteraciones]

El argumento iteraciones es opcional, debe ser un numero entero positvo (si se omite se utiliza iteraciones=1) y es el numero de veces que se ejecutará cada función antes imprimir los resultados. La razón para hacer esto es que al ejecutarlo únicamente 1 vez el resultado de cada ejecución variaba demasiado, es decir, una vez crawl era la más rápida, a la siguiente era lxmlCrawl la más veloz, etc. por lo que decidí usar varias iteraciones y luego obtener un promedio sencillo de forma que los resultados sean estadisticamente más confiables.

Al ejecutarlo con 10 iteraciones obtuve el siguiente resultado:

Completed in: urlib: 991.923360 lxml: 742.943314 pycurl: 751.931897

es decir:

urllib: 99.192sec. lxml: 74.294sec.y pycurl: 75.193sec.

en promedio por cada ejecución. Se aceptan sugerencia de mejora de código y si alguien obtiene resultados distintos agradeceria que los ponga en los comentarios.