Pythonで実行時間とメモリの測定をする

しばらくベンチマークコードを書いてなくてすっかり忘れていたので、メモ書きです。今回は例題として、yahooのこのページをBeautifulSoupとlxmlでのスクレイピング比較をしてみる事にしました。比較対象の関数は以下の通りです。どちらのコードも入力・出力ともに同じなので、どちらが実行時間やメモリ使用量が少ないのかを知りたくなりますね。

# BeautifulSoup
def scrape_with_bs(html):
  from BeautifulSoup import BeautifulSoup  
  soup = BeautifulSoup(html)
  rows = soup.find('table', attrs={'class':'channel9'}).findAll('tr')
  channels = rows[0].findAll('td', attrs={'class':'station'})
  programs = rows[1].findAll('td', attrs={'class':'turnup'})
  res = []
  for ch, prog in zip(channels, programs):
    res.append((ch.find('span').string, ch.find('a').string, prog.find('a').string))
  return res

# lxml
def scrape_with_lxml(html):
  import lxml.html
  root = lxml.html.fromstring(html.decode('utf-8'))
  rows = root.xpath('//table[@class="channel9"]/tr')
  channels = rows[0].xpath('td[@class="station"]')
  programs = rows[1].xpath('td[@class="turnup"]')
  res = []
  for ch, prog in zip(channels, programs):
    res.append((ch.xpath('span/text()')[0], \
          ch.xpath('descendant::a/text()')[0], \
          prog.xpath('descendant::a/text()')[0]) )
  return res

ちなみに上記の関数から得られる配列をprettyprintすると、以下の様な出力が得られます。各チャンネルごとに、今放送中の番組を取得しています。

[
    [
        "アナログ1ch", 
        "NHK総合", 
        "ニュース"
    ], 
    [
        "アナログ3ch", 
        "NHK教育", 
        "ハーバード白熱教室@東京大学「イチローの年俸は..."
    ], 
    [
        "アナログ4ch", 
        "日本テレビ", 
        "真相報道 バンキシャ!"
    ], 
    [
        "アナログ6ch", 
        "TBS", 
        "THE世界遺産「皇帝たちの地下宮殿」〜..."
    ], 
    [
        "アナログ8ch", 
        "フジテレビ", 
        "笑顔がごちそう ウチゴハン"
    ], 
    [
        "アナログ10ch", 
        "テレビ朝日", 
        "ドライブ A GO!GO!「群馬格安温..."
    ], 
    [
        "アナログ12ch", 
        "テレビ東京", 
        "TOKYO ..."
    ], 
    [
        "アナログ14ch", 
        "TOKYO MX", 
        "芸術史と芸術理論( 10)第1回◇放送..."
    ]
]


さて、いきなり結論になりますが、書いたコードが以下になります。時間計測にtimeit、メモリ計測にguppyをそれぞれ使用しています。

#!/usr/bin/python
# -*- coding: utf-8 -*-

import sys
import timeit
from guppy import hpy
from prettyprint import pp

# BeautifulSoup
def scrape_with_bs(html):
  from BeautifulSoup import BeautifulSoup  
  soup = BeautifulSoup(html)
  rows = soup.find('table', attrs={'class':'channel9'}).findAll('tr')
  channels = rows[0].findAll('td', attrs={'class':'station'})
  programs = rows[1].findAll('td', attrs={'class':'turnup'})
  res = []
  for ch, prog in zip(channels, programs):
    res.append((ch.find('span').string, ch.find('a').string, prog.find('a').string))
  return res

# lxml
def scrape_with_lxml(html):
  import lxml.html
  root = lxml.html.fromstring(html.decode('utf-8'))
  rows = root.xpath('//table[@class="channel9"]/tr')
  channels = rows[0].xpath('td[@class="station"]')
  programs = rows[1].xpath('td[@class="turnup"]')
  res = []
  for ch, prog in zip(channels, programs):
    res.append((ch.xpath('span/text()')[0], \
          ch.xpath('descendant::a/text()')[0], \
          prog.xpath('descendant::a/text()')[0]) )
  return res

# load target html
def load():
  import urllib
  url = 'http://tv.yahoo.co.jp/listings/realtime/'
  html = urllib.urlopen(url).read()
  return html.decode('euc_jp', 'ignore').encode('utf-8')

def main():
  func_name = 'scrape_with_lxml' if 'lxml' in sys.argv else 'scrape_with_bs'

  # load data to scrape
  html = load()

  # measure time 
  setup = \
"""
from __main__ import load, %s
html=load()
""" % func_name
  timer = timeit.Timer('%s(html)' % func_name, setup)

  # print result
  print '%s result %f' % (func_name, timer.timeit(10))
  hp = hpy()
  print hp.heap()

if __name__ == '__main__': main()

また実行結果は以下の通りです。実行時間とメモリ使用量がとれていますね。本筋から外れますが、なんと実行速度に100倍近い差が出てます。メモリ使用量でもlxmlの方が少ないので、流石にlxmlの方が早いかな位で思ってたんですが、ここまで違うとは思いませんでした。lxml使える場合はlxml一択な雰囲気です。

$ python bm_test.py bs    # まずはBeautifulSoup
scrape_with_bs result 29.445319
Partition of a set of 96041 objects. Total size = 4762520 bytes.
 Index  Count   %     Size   % Cumulative  % Kind (class / dict of class)
     0  24263  25  1831800  38   1831800  38 str
     1  10210  11   785056  16   2616856  55 tuple
     2  50129  52   601548  13   3218404  68 int
     3    490   1   256760   5   3475164  73 dict (no owner)
     4    157   0   226268   5   3701432  78 dict of module
     5   2420   3   164560   3   3865992  81 types.CodeType
     6   2338   2   140280   3   4006272  84 function
     7    296   0   131680   3   4137952  87 dict of type
     8    296   0   128908   3   4266860  90 type
     9    209   0   104524   2   4371384  92 dict of class
<112 more rows. Type e.g. '_.more' to view.>

$ python bm_test.py lxml  # 次にlxml
scrape_with_lxml result 0.399702
Partition of a set of 62306 objects. Total size = 4553468 bytes.
 Index  Count   %     Size   % Cumulative  % Kind (class / dict of class)
     0  26190  42  1991864  44   1991864  44 str
     1  10939  18   480916  11   2472780  54 tuple
     2    498   1   272472   6   2745252  60 dict (no owner)
     3    135   0   227796   5   2973048  65 dict of module
     4    459   1   199124   4   3172172  70 type
     5    459   1   184068   4   3356240  74 dict of type
     6   2599   4   176732   4   3532972  78 types.CodeType
     7   1836   3   149512   3   3682484  81 unicode
     8   2491   4   149460   3   3831944  84 function
     9    184   0   122528   3   3954472  87 dict of class
<137 more rows. Type e.g. '_.more' to view.>

以上でとりあえずBenchmarkが取れます。ところが今までは不満に感じなかったのですが、流石にtimeitは不便すぎると思います。文字列で測定対象のコード渡すなんてインデント崩れてコードは汚くなるし、比較も簡単に出来ません。幾ら何でもこれはないです。Perlで言うところのtimetheseやcmptheseは無いの?と思って調べてみたら、Benchmarkerモジュールが見つかりました。これを使えばcmptheseのノリで実行時間の測定、比較ができます。

使い方は簡単で、withブロックの中に測定したいコードを書いて、print_compared_matrixを呼ぶだけです。

from benchmarker import Benchmarker

# 測定対象のコードなどは上記と同じです。

def cmp_with_benchmark():
  html = load()
  
  bm = Benchmarker()
  # BeautifulSoupの測定コード
  with bm('BeautifulSoup'):
    scrape_with_bs(html)
  # lxmlの測定コード
  with bm('lxml'):
    scrape_with_lxml(html)

  bm.print_compared_matrix()

すると以下の様な結果が得られます。これこそ欲しかった結果ですね。コードもすっきりしていますし、比較も簡単です。ついでにメモリの測定についてももう少し便利なモジュールが無いか探してみたのですが、見つかりませんでした。ご存知の方がおられましたら教えて下さい。

                                  utime     stime     total      real
BeautifulSoup                     5.200     0.183     5.383     3.242
lxml                              0.100     0.033     0.133     0.086
-------------------------------------------------------------------------------
                          real      [01]     [02]
[01] BeautifulSoup      3.242s        -    -97.4%
[02]          lxml      0.086s   3691.9%       - 

長くなりましたが、以下がまとめになります。

  • 実行時間測定にtimeitは使いづらいのでbenchmarker
  • メモリ使用量測定にguppy。でももっと良いモジュールないの?
  • lxml速すぎワロタ

なお100倍近い差が出ている上記測定結果はPython2.5.4上でのもので、Python2.6.1だとBeautifulSoupとlxmlの差は15倍程度でした。Python2.7.x、Python3.xではもっと速くなっているかもしれません。

Leave a Reply

Your email address will not be published. Required fields are marked *