ようへいの日々精進XP

よかろうもん

dateutil の parser よりも速く日付を解析する試み

これは

qiita.com

初老丸 Advent Calendar 2017 7 日目の記事になる予定です.

tl;dr

お仕事にて, とある関数に dateutil の parser で日付の解析処理を追加したら, 追加する前よりも関数が遅くなったという話しを聞いて, へーって思って調べてみたことをメモってみる.

やりたかったこと

以下のように, 文字列で入ってくる ISO8601 フォーマットの日付から「年」と「月」と「日」をピリオド区切りで欲しかった.

2015-08-13T23:39:43.945958Z

2015.08.13

として取得したかった.

dateutil の parser で実装した場合

多分, 以下のような感じで書けば意図した出力が得られるはず.

>>> from dateutil import parser
>>> ts = '2015-08-13T23:39:43.945958Z'
>>> parser.parse(ts).strftime("%Y.%m.%d")
'2015.08.13'

Pythonインタプリタで実行すると, ほぼ一瞬で終了する.

では, この処理を 10000 回実行したらどうなるのか...Jupyter で試してみると以下のような結果となった.

ふむ. 10000 回で 283 マイクロ秒なので, そんなに遅くないと思うけど...

オレオレパーサーを実装してみる

10000 回で 280 マイクロ秒だけど, 塵も積もればなんとやらになりかねないので, これをオレオレパーサーで解決したのが今回. 見よう見まねで, 以下のように書いてみた.

def OrenoDatetimeParser(ts):
    length = len(ts)
    if length == 27:
        us = int(ts[20:26])  # microseconds を取得
        dt = datetime.datetime(
            int(ts[0:4]),    # %Y
            int(ts[5:7]),    # %m
            int(ts[8:10]),   # %d
            int(ts[11:13]),  # %H
            int(ts[14:16]),  # %M
            int(ts[17:19]),  # %s
            us,              # %f
        )
        return dt.strftime("%Y.%m.%d")
    return parser.parse(ts).strftime("%Y.%m.%d")

これをインタプリタで実行すると, 先程と同様に一瞬で終了する.

>>> import datetime
>>> from dateutil import parser
>>> def OrenoDatetimeParser(ts):
...     length = len(ts)
...     if length == 27:
...         us = int(ts[20:26])  # microseconds を取得
...         dt = datetime.datetime(
...             int(ts[0:4]),    # %Y
...             int(ts[5:7]),    # %m
...             int(ts[8:10]),   # %d
...             int(ts[11:13]),  # %H
...             int(ts[14:16]),  # %M
...             int(ts[17:19]),  # %s
...             us,              # %f
...         )
...         return dt.strftime("%Y.%m.%d")
...     return parser.parse(ts).strftime("%Y.%m.%d")
...
>>> ts = '2015-08-13T23:39:43.945958Z'
>>> OrenoDatetimeParser(ts)
'2015.08.13'

そして, 先程同様に jupyter 上で 10000 回実行してみる.

ほう. 先程の 280 マイクロ秒に対して 9 マイクロ秒ということで, 30 倍程の速度差が確認された.

なんでやろ

そもそも関数の呼び出す数が違った

cProfile を使って, それぞれの関数がどんな処理をしているか見てみることにした.

>>> import cProfile
>>> from dateutil import parser, tz, zoneinfo
>>> ts = '2015-08-13T23:39:43.945958Z'
>>> cProfile.run('parser.parse(ts).strftime("%Y.%m.%d")')
         563 function calls in 0.012 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.012    0.012 <string>:1(<module>)
        1    0.000    0.000    0.000    0.000 __init__.py:49(normalize_encoding)
        1    0.000    0.000    0.001    0.001 __init__.py:71(search_function)
... 略 ...
        5    0.000    0.000    0.000    0.000 {min}
       11    0.000    0.000    0.000    0.000 {setattr}
        1    0.000    0.000    0.000    0.000 {sum}

563 回関数が呼ばれている.

オレオレパーサーの場合は...

>>> import datetime
>>> from dateutil import parser
>>> def OrenoDatetimeParser(ts):
...     length = len(ts)
...     if length == 27:
...         us = int(ts[20:26])  # microseconds を取得
...         dt = datetime.datetime(
...             int(ts[0:4]),    # %Y
...             int(ts[5:7]),    # %m
...             int(ts[8:10]),   # %d
...             int(ts[11:13]),  # %H
...             int(ts[14:16]),  # %M
...             int(ts[17:19]),  # %s
...             us,              # %f
...         )
...         return dt.strftime("%Y.%m.%d")
...     return parser.parse(ts).strftime("%Y.%m.%d")
...
>>> ts = '2015-08-13T23:39:43.945958Z'
>>> cProfile.run('OrenoDatetimeParser(ts)')
         5 function calls in 0.000 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.000    0.000 <stdin>:1(OrenoDatetimeParser)
        1    0.000    0.000    0.000    0.000 <string>:1(<module>)
        1    0.000    0.000    0.000    0.000 {len}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}
        1    0.000    0.000    0.000    0.000 {method 'strftime' of 'datetime.date' objects}

5 回しか関数が呼ばれていない真実.

dateutil のソースコードを見てみる

github.com

このあたりがメインの処理になるのかなと思ったり. ざっとしか見ることが出来ないけど, 文字列で入ってきた日時のデータを分割して(715 行目あたり)それぞれを解析しているように見える.

...
        res = self._result()
        l = _timelex.split(timestr)         # Splits the timestr into tokens
...

実際に解析しているのはこのあたりっぽい.

以上

dateutil の parser は遅いのか

  • 遅いというわけではない
  • 様々なフォーマットの日時の文字列をよしなに解析する為の処理が含まれているので, オーバーヘッドは大きいんぢゃないかんと思われる
  • オレオレパーサーは dateutil.parser のような解析処理は行っておらず, 単純な文字列分割を行っているので dateutil.parser と比べて速く見える

ということで

  • 限定的な用途であれば, オレオレパーサーを書いても良いとは思うけど...
  • ご利用は計画的に