Привет! В первой и второй частях я поделился историей создания python библиотеки convtools (кратко: позволяет декларативно описывать преобразования данных, из которых генерируются python функции, реализующие заданные преобразования), сейчас расскажу об ускорении частных случаев datetime.strptime и datetime.strftime, а также о том интересном, что встретилось в datetime модуле по дороге.

strftime: datetime/date -> str

Для начала сделаем замеры базового варианта форматирования даты/даты и времени:

from datetime import datetime

dt = datetime(2023, 8, 1)
assert dt.strftime("%b %Y") == "Aug 2023"

# In [2]: %timeit dt.strftime("%b %Y")
# 1.21 µs ± 4.02 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)

Взглянув на код выше, а также осмотрев исходники strftime, можно найти следующие проблемы:

  • на каждом запуске функции strftime интерпретатор делает разбор строки формата даты с нуля (не используя каких-либо промежуточных наработок с предыдущих итераций).

  • дата предварительно превращается в timetuple, с которым уже может работать time.strftime. Но т.к. timetuple содержит все компоненты даты и времени, то для его создания интерпретатор проделал лишнюю работу, общупав часы, минуты, секунды и микросекунды, которые в данном конкретном случае нас совершенно не интересовали.

Теперь давайте проверим насколько быстро могла бы работать самая узколобая функция, которая реализует почти такое же форматирование дат (почти, т.к. тут игнорируется разное название месяцев в зависимости от локали):

from datetime import datetime

MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']

def ad_hoc_func(dt):
  return f"{MONTH_NAMES[dt.month - 1]} {dt.year:04}"

dt = datetime(2023, 8, 1)
assert ad_hoc_func(dt) == "Aug 2023"

# In [11]: %timeit ad_hoc_func(dt)
# 258 ns ± 1.4 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)

Получили ускорение в ~ 4.7 раза и постановку задачи для convtools - уметь динамически генерировать узкоспециализированные конвертеры под заданный формат даты.

До того, как мы осмотрим результат, сделаю некоторые замечания:

  • dt.strftime("%Y-%m-%d") использовать неоптимально. Лучше использовать dt.date().isoformat() для datetime и dt.isoformat() для date (ускорение 5.5x и 6.4x соответственно) — учтено в реализации.

  • dt.strftime("%Y"): по форматированию года документация не делает каких-либо оговорок (как минимум для python 3.4+), а вот CPython bugtracker делает (#57514) - под linux glibc python не делается zero padding (на маке и linux musl делается). Можно вылечить вот так dt.strftime("%4Y"), но сломаем для остальных — учтено в реализации.

  • многие коды формата, такие как %a, %b, %c, %p зависят от локали, установленной в системе (например: Sunday, Monday, …, Saturday для en_US и Sonntag, Montag, …, Samstag для de_DE) -- реализована только часть таких кодов, при встрече неподдерживаемого, используется встроенная strftime.

from convtools import conversion as c

ad_hoc_func = c.format_dt("%b %Y").gen_converter()
assert ad_hoc_func(dt) == "Aug 2023"

# In [32]: %timeit ad_hoc_func(dt)
# 274 ns ± 1.28 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)

По дороге немного потеряли, но все же имеем ускорение в 4.4 раза от базового варианта. Чтобы посмотреть, какой код был сгенерирован под капотом, просто запускаем с debug=True(try-except обвязка сбрасывает сгенерированный код в tmp в случае ошибки ради красивых traceback-ов и нормальной отладки):

In [34]: c.format_dt("%b %Y").gen_converter(debug=True)
def converter(data_, *, __v=__naive_values__["__v"], __datetime=__naive_values__["__datetime"]):
    try:
        return f"{__v[data_.month - 1]} {data_.year:04}"
    except __exceptions_to_dump_sources:
        __convtools__code_storage.dump_sources()
        raise

Out[34]: <function _convtools.converter(data_, *, __v=['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], __datetime=<class 'datetime.datetime'>)>

strptime: str -> datetime

Повторим шаги выше для datetime.strptime, но немного срезая углы. Забегая вперед, отмечу, что оптимизировать работу с кодами формата, которые зависят от локали, мы не будем.

from datetime import datetime

assert datetime.strptime("12/31/2020 12:05:54 PM", "%m/%d/%Y %I:%M:%S %p") == datetime(2020, 12, 31, 12, 5, 54)

# In [3]: %timeit datetime.strptime("12/31/2020 12:05:54 PM", "%m/%d/%Y %I:%M:%S %p")
# 4.6 µs ± 5.08 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)

Осмотрев исходники, находим кэш для скомпилированных регулярных выражений, локи для доступа к нему, в целом все хорошо, но все же сравним с узкоспециализированным кодом.

from datetime import datetime
from convtools import conversion as c

ad_hoc_func = c.datetime_parse("%m/%d/%Y %I:%M:%S %p").gen_converter()
assert ad_hoc_func("12/31/2020 12:05:54 PM") == datetime(2020, 12, 31, 12, 5, 54)

# In [44]: %timeit ad_hoc_func("12/31/2020 12:05:54 PM")
# 1.29 µs ± 11.1 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)

Имеем прирост по скорости в 3.6 раза - ощутимо. Посмотрим на код, сгенерированный под капотом:

In [46]: c.datetime_parse("%m/%d/%Y %I:%M:%S %p").gen_converter(debug=True)
def converter(data_, *, __v=__naive_values__["__v"], __datetime=__naive_values__["__datetime"]):
    try:
        match = __v.match(data_)
        if not match:
            raise ValueError("time data %r does not match format %r" % (data_, """%m/%d/%Y %I:%M:%S %p"""))
        if len(data_) != match.end():
            raise ValueError("unconverted data remains: %s" % data_string[match.end() :])
        groups_ = match.groups()
        i_hour = int(groups_[3])
        ampm_h_delay = 12 if groups_[6].lower() == """pm""" else 0
        return __datetime(int(groups_[2]), int(groups_[0]), int(groups_[1]), i_hour % 12 + ampm_h_delay, int(groups_[4]), int(groups_[5]), 0)
    except __exceptions_to_dump_sources:
        __convtools__code_storage.dump_sources()
        raise

Out[46]: <function _convtools.converter(data_, *, __v=re.compile('(1[0-2]|0[1-9]|[1-9])/(3[0-1]|[1-2]\\d|0[1-9]|[1-9]| [1-9])/(\\d{4})\\ (1[0-2]|0[1-9]|[1-9]):([0-5]\\d|\\d):(6[0-1]|[0-5]\\d|\\d)\\ (am|pm)', re.IGNORECASE), __datetime=<class 'datetime.datetime'>)>

Цена

За все приходится чем-то платить, в случае с convtools это время на кодогенерацию и компиляцию конвертеров:

In [47]: %timeit c.format_dt("%b %Y").gen_converter()
54.8 µs ± 118 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)

In [48]: %timeit c.datetime_parse("%m/%d/%Y %I:%M:%S %p").gen_converter()
99.7 µs ± 67.3 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)

Поэтому до того, как использовать сей функционал, нужно осмыслить, подпадает ли Ваш случай, под один из нижеперечисленных:

  1. формат даты статичен (известен на момент написания кода) и Вы можете единожды вызвать gen_converter где-то глобально и далее его использовать

  2. формат даты динамический, но сгенерированный конвертер будет использован для обработки, скажем, хотя бы 1K (тысячи) дат.

Заключение

Вышеописанный функционал далеко не единственное, что предлагает библиотека convtools, более подробно Вы можете ознакомиться с ней по ссылкам ниже:

Буду благодарен за отзывы, идеи в дискуссиях на Github.

Комментарии (0)