Python-оптимизация алгоритма динамического программирования из codechef

Материал из DISCOPAL
Перейти к: навигация, поиск
 
(не показано 20 промежуточных версий этого же участника)
Строка 65: Строка 65:
 
А потом парсить и отдавать результаты генератором.
 
А потом парсить и отдавать результаты генератором.
  
Делаем {{diff|1=digprime.py|20478|такие правки}} и получаем
+
Делаем {{diff| 20478 | такие правки}} и получаем
  
{{oldid|1=digprime.py|2=20468|версию, которая работает абсолютно также}} по результату, возможно чуть медленней перекопированием ввода в память (если померять у себя), но возможно быстрее, чем у них на сервере, с задушенным IO.
+
 
 +
{{oldid|1=digprime.py|2=20468|3=версию, которая работает абсолютно также}} по результату, возможно чуть медленней перекопированием ввода в память (если померять у себя), но возможно быстрее, чем у них на сервере, с задушенным IO.
  
 
В любом случае, сначала надо избавиться от NZEC, потом уже заниматься оптимизацией.
 
В любом случае, сначала надо избавиться от NZEC, потом уже заниматься оптимизацией.
Строка 82: Строка 83:
 
[[File:Python-оптимизация алгоритма динамического программирования из codechef_2022-05-01_03-34-48_image0.png||400px]]
 
[[File:Python-оптимизация алгоритма динамического программирования из codechef_2022-05-01_03-34-48_image0.png||400px]]
  
Обращаю внимание — 10.01 и 4 секунды это не время работы программы! Это сколько
+
Обращаю внимание — 10.01 и 4.01 секунды это не время работы программы!  
 +
Это только таймлимиты (плюс сколько мгновений, пока программу не убили), которые выделены питону (Python 3.6) и скомпилированному питону (PYPY).
 +
 
 +
Хотя обычному питону дается фора, в 2.5 раза времени, выигрыш  PYPY обычно бывает больше.
 +
Есть конечно минусы PYPY — в нем нет numpy, удобных многомерных массивов и эффективных матричных операций, есть модуль «array», который иногда полезен… но в нашем случае, попробуем обойтись без всего этого, используя обычные питоновые списки-вектора, структуры, которые были изначально.
 +
 
 +
А вот что необходимо — начать замерять время выполнения (экзаменационная система вам не поможет — там только да или нет), и профилировать выполнение.
 +
 
 +
У меня древний десктоп нулевых годов, цифры могут отличатся от ваших, если вы повторяете эксперименты, плюс у вас будут другие входные данные, но буду приводить свои данные.
 +
Обычная linux утилита time, где нужно смотреть только «user time» нам вполне подойдет.
 +
 
 +
time python digprime.py < big-samples.txt > big-our-results.txt
 +
 
 +
real    1m8.108s
 +
user    1m6.412s
 +
sys    0m0.351s
 +
 
 +
 
 +
time pypy3 digprime.py < big-samples.txt > big-our-results.txt
 +
 
 +
real    0m4.519s
 +
user    0m4.143s
 +
sys    0m0.143s
 +
 
 +
Впечатляющая разница? Но нам увы, недостаточно.
 +
 
 +
Давайте посмотрим, что «жрет».
 +
Всегда можно сделать стандартное профилирование (разумная сортировка по общезатраченному времени «-s cumulative»)
 +
 
 +
python -m cProfile -s cumulative digprime.py < big-samples.txt >profile-results.txt
 +
 
 +
<tab head=top border=1 sep="spaces" cellspacing=5 class="wikitable">
 +
  ncalls  tottime  percall  cumtime  percall filename:lineno(function)
 +
1    0.000    0.000  103.393  103.393 {built-in method builtins.exec}
 +
1    2.716    2.716  103.393  103.393 digprime.py:2(<module>)
 +
43546528/100000  93.691    0.000  100.071    0.001 digprime.py:19(calculate)
 +
43546528    6.380    0.000    6.380    0.000 {built-in method builtins.len}
 +
</tab>
 +
 
 +
Что видно — огромное количество (43.5M) рекурсивных вызовов функции «calculate», ну и еще там где-то зря дергаются лишний раз «len()».
 +
 
 +
Начинаем оптимизировать, учитываем [https://discuss.codechef.com/t/digprime-editorial/13433 пропущенную эвристику]<ref>что когда у нас «230» → то на второй цифре можно сразу вернуть число десятков, не перебирая глубже</ref>,
 +
делаем  {{diff|20497|правку}}, получаем {{oldid|1=digprime.py|2=20497|3=версию}}, для которой
 +
 
 +
CPython time → 41.203s
 +
Pypy time → 2.722s
 +
Вызовов calculate → 26874678
 +
 
 +
Уберем кстати, хардкодинг, число цифр в константу.
 +
 
 +
делаем  {{diff|20499|правку}}, получаем {{oldid|1=digprime.py|2=20499|3=версию}}, для которой
 +
 
 +
CPython time → 40.610s
 +
Pypy time → 2.749s
 +
Вызовов calculate → 26874678
 +
 
 +
(ничего интересного не достигли)
 +
 
 +
 
 +
Введем глобальную переменную N c длиной текущего числа, уберем перерасчитывание высчитывание длины
 +
внутри функции.
 +
 
 +
 
 +
… делаем  {{diff|20501|правку}}, получаем {{oldid|1=digprime.py|2=20501|3=версию}}, для которой …
 +
 
 +
CPython time → 37.117s
 +
Pypy time → 2.907s
 +
Вызовов calculate → 26874678
 +
 
 +
… чуть ускорили обычный питон, где байткода такие вещи не оптимизирует, но скомпилированный PyPy лучше не стал.
 +
 
 +
Потом в коде видим странные штуки типа «taken | (x == 2) | (x == 3) | (x == 5) | (x == 7)» — ох тыж, считается «бинарное или» вместо логического, а ведь в везде «логическое или» оптимизируется слева направо, т.е. если левый операнд уже «истина», то дальше ничего считать не надо.
 +
 
 +
… делаем  {{diff|20502|правку}}, получаем {{oldid|1=digprime.py|2=20502|3=версию}}, для которой …
 +
 
 +
CPython time → 24.413s
 +
Pypy time → 2.907s
 +
Вызовов calculate → 26874678
 +
 
 +
Большой прорыв по обычному питону, но PyPy3 об этом похоже, догадался сам, тут не помогло.
 +
 
 +
Небольшие правки, вроде убираем ненужные требования о преобразованиях типов
 +
<blockquote>
 +
… делаем  {{diff|20503|правку}}, получаем {{oldid|1=digprime.py|2=20503|3=версию}}, для которой …
 +
</blockquote>
 +
… если и есть экономия, то копеечная.
 +
 
 +
----
 +
 
 +
Пора заняться важным, оптимизацией хвостовой рекурсии.
 +
 
 +
Мы видим, что в рекурсивной функции, в самом начале у нас куча эвристик по выходу из этой функции… так может ее сразу не вызывать в рекурсивных вызовах? А в первом вызове они не сработают.
 +
 
 +
Начинаем, переносить каждое условие по одному
 +
<blockquote>
 +
… делаем  {{diff|20504|правку}}, получаем {{oldid|1=digprime.py|2=20504|3=версию}}, для которой …
 +
</blockquote>
 +
… экономия начинает появлятся, хотя во времени на уровне колебаний измерения, но вот количество рекурсивных вызовов уменьшилось:
 +
 
 +
CPython time → 24.718s
 +
Pypy time → 2.413s
 +
Вызовов calculate → 25324715
 +
 
 +
Переносим «эвристику с десятками»
 +
<blockquote>
 +
… делаем  {{diff|20505|правку}}, получаем {{oldid|1=digprime.py|2=20505|3=версию}}, для которой …
 +
</blockquote>
 +
радикально уменьшились рекурсивные вызовы (хотя внутри функции теперь больше работы), но время тоже падает
 +
 
 +
CPython time → 21.763s
 +
Pypy time → 2.270s
 +
Вызовов calculate → 11972222
 +
 
 +
Переносим «кеширование DP»
 +
<blockquote>
 +
… делаем  {{diff|20506|правку}}, получаем {{oldid|1=digprime.py|2=20506|3=версию}}, для которой …
 +
</blockquote>
 +
радикально уменьшились рекурсивные вызовы (хотя внутри функции теперь больше работы), но время тоже падает
 +
 
 +
CPython time → 17.762s
 +
Pypy time → 2.099s
 +
Вызовов calculate → 3477870
 +
 
 +
Явно улучшилось!
 +
 
 +
Да, тут многое чешеться еще улучшить, но пора попробовать, вдруг уже пройдет →
 +
[https://www.codechef.com/viewsolution/63961917 ура, проходит]!
 +
 
 +
[[File:Python-оптимизация алгоритма динамического программирования из codechef_2022-05-01_05-40-36_image0.png|center|640px]]
 +
 
 +
Да, тут многое можно было написать красивей<ref>жалко, что пришлось вставлять лишний парсинг входа, с точки зрения чистых алгоритмов это конечно хак, хотя в любом случае это читаемый код, а не С++шный «лишбыработало» [https://www.codechef.com/viewsolution/14955997], [https://www.codechef.com/viewsolution/15415158])</ref>, очень желательны комментарии для будущих читателей кода… но для целей иллюстрации, как оптимизировать питоновский код, думаю, достаточно, чтобы не перегружать статью!
 +
 
 +
Удивительно, что один из наших студентов решил [https://www.codechef.com/viewsolution/64117884 эту задачу на на CPython], хотя это конечно хак, запускать питоном чистый ассемблер!
 +
Не надо так делать в наших задачах, лучше подумать над алгоритмом, но всеже тема интересная и попробуем написать про такой подход статью-заметку.
 +
 
 +
 
 +
 
 +
 
 +
----
 +
{{wl-publish: 2022-05-01 02:48:25 +0000 | StasFomin }}

Текущая версия на 10:18, 4 мая 2022

При разборе ваших решений, столкнулся с одной из попыток, с результатом «вроде все сделано правильно, но система не пропускает, наверно что-то не так с системой или вообще питон не потянет, вот на плюсах все решают и все хорошо».

Попробую кратко показать, как можно системно поработать с улучшением решения, даже не особо вьезжая в алгоритм (ну при условии, что идея будет правильна).

Первая версия digprime.py

Видим вроде типичный алгоритм ДП, смотрим описание задачи Codechef/DIGPRIME, переходим по ссылке Editorial на разбор и разъяснение задачи, но даже если этой ссылки не будет, смотрим список принятых решений:

Python-оптимизация алгоритма динамического программирования из codechef 2022-05-01 02-47-12 image0.png

Берем из них любое принятое CPP-решение, сохраняем его в файл digprime-good.cpp компилируем его

 gcc -g -o digprime-good digprime-good.cpp -lstdc++

Итак, у нас есть референсное решение.

Смотрим на описание задачи, особенно на секцию «ограничения»…

Python-оптимизация алгоритма динамического программирования из codechef 2022-05-01 02-50-11 image0.png

и пишем примитивных генератор «digprime-generate.py», такой, чтобы задействовать все ограничения (вдруг все проходит на минимальных данных, но где-то что-то переполняется на самых крайних случаях), плюс большой тестсет даст возможность разумно измерять время работы.

import numpy as np
num = 100000
print(num)
for t in range(num):
    print(np.random.randint(1,1000000000000000000))

Генерим наш тестовый набор:

python digprime-generate.py > big-samples.txt

Генерим результаты нашего алгоритма и референсной реализации на тех же входных данных:

digprime-good < big-samples.txt > reference-good.txt
python digprime.py < big-samples.txt > big-our-results.txt

Сравниваем («meld», «winmerge», «fc», ) — используйте то, что ставится на вашу ось и есть под рукой, но в данном случае, совпадение добайтовое даже по ответу «md5sum»:

md5sum big-our-results.txt
  e602cd2d36e9c6749764cace13774bdc  big-our-results.txt
md5sum reference-good.txt
  e602cd2d36e9c6749764cace13774bdc  reference-good.txt

Вроде же все в абсолютном порядке, но что выдает codechef?

Python-оптимизация алгоритма динамического программирования из codechef 2022-05-01 02-58-12 image0.png

Ошибка «NZEC» — «какая-то ошибка», увы, без малейших подсказок на чем, и что за ошибка. Но по времени работы — доли секунды, предположим, что что-то сразу с вводом.

Тут на самом деле наблюдались разные проблемы в этих (codechef, spoj) тестовых системах. То что-то не так с буферизацией, и какой-то перевод строки становился пробелом, или длинная строка, и input() не вычитывал ее до конца, то большое количество операций input, а там перенаправление на чтение из файла, много IO операций, срабатывают какие-то контейнерные ограничения или просто будет тормозить.

Поэтому, лучше сразу сделать чтение типа

    lines = sys.stdin.readlines()
    content = " ".join(lines).strip()

А потом парсить и отдавать результаты генератором.

Делаем такие правки и получаем


версию, которая работает абсолютно также по результату, возможно чуть медленней перекопированием ввода в память (если померять у себя), но возможно быстрее, чем у них на сервере, с задушенным IO.

В любом случае, сначала надо избавиться от NZEC, потом уже заниматься оптимизацией.

Ага, от NZEC уже избавились, но теперь здравствуй TL.

Python-оптимизация алгоритма динамического программирования из codechef 2022-05-01 03-36-58 image0.png

Может если перейти на PYPY, скомпилируется и пройдет?

Увы, нет.

Python-оптимизация алгоритма динамического программирования из codechef 2022-05-01 03-34-48 image0.png

Обращаю внимание — 10.01 и 4.01 секунды это не время работы программы! Это только таймлимиты (плюс сколько мгновений, пока программу не убили), которые выделены питону (Python 3.6) и скомпилированному питону (PYPY).

Хотя обычному питону дается фора, в 2.5 раза времени, выигрыш PYPY обычно бывает больше. Есть конечно минусы PYPY — в нем нет numpy, удобных многомерных массивов и эффективных матричных операций, есть модуль «array», который иногда полезен… но в нашем случае, попробуем обойтись без всего этого, используя обычные питоновые списки-вектора, структуры, которые были изначально.

А вот что необходимо — начать замерять время выполнения (экзаменационная система вам не поможет — там только да или нет), и профилировать выполнение.

У меня древний десктоп нулевых годов, цифры могут отличатся от ваших, если вы повторяете эксперименты, плюс у вас будут другие входные данные, но буду приводить свои данные. Обычная linux утилита time, где нужно смотреть только «user time» нам вполне подойдет.

time python digprime.py < big-samples.txt > big-our-results.txt
real    1m8.108s
user    1m6.412s
sys     0m0.351s


time pypy3 digprime.py < big-samples.txt > big-our-results.txt
real    0m4.519s
user    0m4.143s
sys     0m0.143s

Впечатляющая разница? Но нам увы, недостаточно.

Давайте посмотрим, что «жрет». Всегда можно сделать стандартное профилирование (разумная сортировка по общезатраченному времени «-s cumulative»)

python -m cProfile -s cumulative digprime.py < big-samples.txt >profile-results.txt
ncallstottimepercallcumtimepercallfilename:lineno(function)
10.0000.000103.393103.393{built-inmethodbuiltins.exec}
12.7162.716103.393103.393digprime.py:2(<module>)
43546528/10000093.6910.000100.0710.001digprime.py:19(calculate)
435465286.3800.0006.3800.000{built-inmethodbuiltins.len}

Что видно — огромное количество (43.5M) рекурсивных вызовов функции «calculate», ну и еще там где-то зря дергаются лишний раз «len()».

Начинаем оптимизировать, учитываем пропущенную эвристику[1], делаем правку, получаем версию, для которой

CPython time → 41.203s
Pypy time → 2.722s
Вызовов calculate → 26874678

Уберем кстати, хардкодинг, число цифр в константу.

делаем правку, получаем версию, для которой

CPython time → 40.610s
Pypy time → 2.749s
Вызовов calculate → 26874678

(ничего интересного не достигли)


Введем глобальную переменную N c длиной текущего числа, уберем перерасчитывание высчитывание длины внутри функции.


… делаем правку, получаем версию, для которой …

CPython time → 37.117s
Pypy time → 2.907s
Вызовов calculate → 26874678

… чуть ускорили обычный питон, где байткода такие вещи не оптимизирует, но скомпилированный PyPy лучше не стал.

Потом в коде видим странные штуки типа «taken | (x == 2) | (x == 3) | (x == 5) | (x == 7)» — ох тыж, считается «бинарное или» вместо логического, а ведь в везде «логическое или» оптимизируется слева направо, т.е. если левый операнд уже «истина», то дальше ничего считать не надо.

… делаем правку, получаем версию, для которой …

CPython time → 24.413s
Pypy time → 2.907s
Вызовов calculate → 26874678

Большой прорыв по обычному питону, но PyPy3 об этом похоже, догадался сам, тут не помогло.

Небольшие правки, вроде убираем ненужные требования о преобразованиях типов

… делаем правку, получаем версию, для которой …

… если и есть экономия, то копеечная.


Пора заняться важным, оптимизацией хвостовой рекурсии.

Мы видим, что в рекурсивной функции, в самом начале у нас куча эвристик по выходу из этой функции… так может ее сразу не вызывать в рекурсивных вызовах? А в первом вызове они не сработают.

Начинаем, переносить каждое условие по одному

… делаем правку, получаем версию, для которой …

… экономия начинает появлятся, хотя во времени на уровне колебаний измерения, но вот количество рекурсивных вызовов уменьшилось:

CPython time → 24.718s
Pypy time → 2.413s
Вызовов calculate → 25324715

Переносим «эвристику с десятками»

… делаем правку, получаем версию, для которой …

радикально уменьшились рекурсивные вызовы (хотя внутри функции теперь больше работы), но время тоже падает

CPython time → 21.763s
Pypy time → 2.270s
Вызовов calculate → 11972222

Переносим «кеширование DP»

… делаем правку, получаем версию, для которой …

радикально уменьшились рекурсивные вызовы (хотя внутри функции теперь больше работы), но время тоже падает

CPython time → 17.762s
Pypy time → 2.099s
Вызовов calculate → 3477870

Явно улучшилось!

Да, тут многое чешеться еще улучшить, но пора попробовать, вдруг уже пройдет → ура, проходит!

Python-оптимизация алгоритма динамического программирования из codechef 2022-05-01 05-40-36 image0.png

Да, тут многое можно было написать красивей[2], очень желательны комментарии для будущих читателей кода… но для целей иллюстрации, как оптимизировать питоновский код, думаю, достаточно, чтобы не перегружать статью!

Удивительно, что один из наших студентов решил эту задачу на на CPython, хотя это конечно хак, запускать питоном чистый ассемблер! Не надо так делать в наших задачах, лучше подумать над алгоритмом, но всеже тема интересная и попробуем написать про такой подход статью-заметку.




  1. что когда у нас «230» → то на второй цифре можно сразу вернуть число десятков, не перебирая глубже
  2. жалко, что пришлось вставлять лишний парсинг входа, с точки зрения чистых алгоритмов это конечно хак, хотя в любом случае это читаемый код, а не С++шный «лишбыработало» [1], [2])