Привет, Хабр! Сегодня хочу поделиться своим небольшим опытом выбора инструментов для организации расчетов на будущем сервере. Отмечу сразу, что в этой публикации речь пойдет не о самом сервере, а скорее об оптимизации символьных вычислений на нем.
Есть некий функционал, который позволяет пользователям формировать нередко громоздкие формулы следующего общего вида, по которым в дальнейшем необходимо рассчитывать запросы других пользователей.
Формула поступает в виде строки и подлежит сохранению на сервере и вызову по запросам пользователей. Предполагается, что в запросах пользователей передаются параметры x1_, x2_,… в виде простого списка значений. Требуется определить способ организации подобных вычислений с уклоном на минимизацию времени выполнения.
Время формирования самих формул достаточно велико (до пары минут для приведенной формулы), поэтому время обработки и хранения поступающих формул-строк в данной задаче не является критичным (в дальнейшем будет показано, что это величины разных порядков).
Предполагается, что основной объем запросов будет носить групповой характер, т.е. в одном запросе могут передаваться несколько наборов значений x1_, x2_,… для расчета по одной и той же формуле.
Язык программирования — Python 3x. В качестве СУБД — Redis (NoSQL).
Пару слов про Redis. На мой взгляд данная задача — прекрасный пример для его использования: пользователь формирует формулу; формула обрабатывается и отправляется в хранилище; далее она извлекается из хранилища и обрабатывается в случае, если кто-то захотел ей воспользоваться; переданные по запросу значения подставляются в формулу и выдается результат. Всё. Единственное, что необходимо знать пользователю, который хочет что-то рассчитать — количество уникальных переменных в формуле. В Redis есть встроенный механизм хэшей, так почему бы им и не воспользоваться?
Для работы с самими формулами воспользуемся замечательной библиотекой Sympy, которая умеет переводить формулу-строку в символьное выражение и производить необходимые вычисления (а вообще библиотека открывает огромный математический функционал для работы с символьными выражениями).
Для измерения времени выполнения участков кода воспользуемся следующим классом (где-то позаимствованным в просторах интернета):
Поехали… Для чистоты эксперимента введем num_iter = 1000 — число испытаний.
Протестируем профилировщик на чтении формулы-строки из файла:
Формула-строка загружена. Теперь определим сколько же в ней переменных и какие они (должны же мы знать в какую переменную именно подставлять значения):
Полученное время вполне устраивает. Теперь переведем формулу-строку в символьное выражение:
В этом виде ее уже можно использовать для вычислений. Попробуем:
Здесь есть особенность: sympy не умеет (?) подставлять сразу все значения в переменные символьного выражения. Приходится пользоваться циклом. В результате выполнения в expr_copy получаем вещественное число.
В sympy есть возможность преобразовать символьное выражение в лямбда-функцию с использованием модуля numpy, что теоретически должно ускорить расчеты. Осуществим перевод:
Не слишком долго получилось, что радует. Теперь проверим как быстро будут осуществляться вычисления:
Вот это уровень! Быстрее почти на порядок. Особенно вкусно, если учесть необходимость в групповых запросах (особенность 2). Проверим на всякий случай совпадение значений:
Хранить строку-формулу нецелесообразно — велико время ее преобразования для вычислений. Имеет смысл хранить либо символьное выражение, либо лямбда-функцию.
Попробуем разобраться с хранением. Символьное выражение — класс sympy, лямбда функция — также класс (в особенности не вникал). Будем пробовать сериализовать с помощью встроенного pickle, cloudpickle, dill:
Отметим, что pickle супер быстро сериализует символьные выражения, если сравнивать с «коллегами». Время десериализации отличается, но уже не так существенно. Теперь попробует протестировать сериализацию/десериализацию в связке с хранением/загрузкой Redis. Следует отметить тот факт, что pickle не сумел сериализовать/десериализовать лямбда-функцию.
cloudpickle и dill с сериализацией/десериализацией лямбда-функции справились (в примере выше, правда, cloudpickle работал с символьным выражением).
Redis показывает хороший результат чтение/запись 1000 значений в одном потоке. Чтобы сделать выбор в дальнейшем требуется профилировать полные цепочки действий от поступления формулы-строки до выдачи пользователю рассчитанного по ней значения:
Создание лямбда-функции и ее сериализация с помощью cloudpickle, конечно, оказались самыми долгими, НО, если вспомнить (особенность 1) некритичность времени обработки и хранения, то… Cloudpickle молодец! Удалось в рамках одного потока вытащить из базы, десериализовать и рассчитать 1000 раз за 1,7 сек. Что, в целом, хорошо, учитывая сложность исходной формулы-строки.
Попробуем оценить производительность для групповых запросов. Будем менять число групп параметров порядками с надеждой на улучшение результата:
Результат выглядит вполне жизнеспособным. Расчеты проводились на виртуальной машине со следующими характеристиками: ОС Ubuntu 16.04.2 LTS, Процессор Intel® Core(TM) i7-4720HQ CPU @ 2.60GHz (выделено 1 ядро), DDR3-1600 (выделено 1Gb).
Спасибо за просмотр! Буду рад конструктивной критике и интересным замечаниям.
В вопросе профилирования и оптимизации требуемых вычислений были использованы идеи и подходы, изложенные здесь (слишком «слабая» формула в примере, но хороший набор тестов) и здесь (информация о сериализации лямбда-функций).
Чтобы воспользоваться кодом, необходимо:
Задача
Есть некий функционал, который позволяет пользователям формировать нередко громоздкие формулы следующего общего вида, по которым в дальнейшем необходимо рассчитывать запросы других пользователей.
Типовая формула. Использовалась для профилирования
8.348841409877572e-11*x1_*x2_*x3_*x4_*x5_*x6_*x7_ — 3.480284409621004e-9*x1_*x2_*x3_*x4_*x5_*x6_ — 1.44049340858321e-9*x1_*x2_*x3_*x4_*x5_*x7_ + 6.004816835089577e-8*x1_*x2_*x3_*x4_*x5_ — 2.674192940005371e-9*x1_*x2_*x3_*x4_*x6_*x7_ + 1.1147596343241695e-7*x1_*x2_*x3_*x4_*x6_ + 4.614001865646533e-8*x1_*x2_*x3_*x4_*x7_ — 1.92338517189701e-6*x1_*x2_*x3_*x4_ — 3.980463071998064e-9*x1_*x2_*x3_*x5_*x6_*x7_ + 1.6592892295580475e-7*x1_*x2_*x3_*x5_*x6_ + 6.867815593308846e-8*x1_*x2_*x3_*x5_*x7_ — 2.862906227803913e-6*x1_*x2_*x3_*x5_ + 1.2749703798969891e-7*x1_*x2_*x3_*x6_*x7_ — 5.314820397426395e-6*x1_*x2_*x3_*x6_ — 2.199809760060692e-6*x1_*x2_*x3_*x7_ + 9.1700905110223e-5*x1_*x2_*x3_ — 1.846888532733293e-9*x1_*x2_*x4_*x5_*x6_*x7_ + 7.69890890657543e-8*x1_*x2_*x4_*x5_*x6_ + 3.1865865064706345e-8*x1_*x2_*x4_*x5_*x7_ — 1.3283551698311385e-6*x1_*x2_*x4_*x5_ + 5.915714175810938e-8*x1_*x2_*x4_*x6_*x7_ — 2.4660148079756056e-6*x1_*x2_*x4_*x6_ — 1.0206861262244266e-6*x1_*x2_*x4_*x7_ + 4.254815271209286e-5*x1_*x2_*x4_ + 8.80537876744858e-8*x1_*x2_*x5_*x6_*x7_ — 3.6705956013363683e-6*x1_*x2_*x5_*x6_ — 1.5192633852443432e-6*x1_*x2_*x5_*x7_ + 6.333176170880347e-5*x1_*x2_*x5_ — 2.820424906208041e-6*x1_*x2_*x6_*x7_ + 0.0001175717652455964*x1_*x2_*x6_ + 4.866307746134377e-5*x1_*x2_*x7_ — 0.002028560982722154*x1_*x2_ — 4.643965319933718e-9*x1_*x3_*x4_*x5_*x6_*x7_ + 1.9358756900289542e-7*x1_*x3_*x4_*x5_*x6_ + 8.012609870218512e-8*x1_*x3_*x4_*x5_*x7_ — 3.3401232720775553e-6*x1_*x3_*x4_*x5_ + 1.4874948386055242e-7*x1_*x3_*x4_*x6_*x7_ — 6.200746333919621e-6*x1_*x3_*x4_*x6_ — 2.5664954382120103e-6*x1_*x3_*x4_*x7_ + 0.00010698650352362546*x1_*x3_*x4_ + 2.2140953873789337e-7*x1_*x3_*x5_*x6_*x7_ — 9.229641340558273e-6*x1_*x3_*x5_*x6_ — 3.8201582714825905e-6*x1_*x3_*x5_*x7_ + 0.00015924648463737888*x1_*x3_*x5_ — 7.091903641665703e-6*x1_*x3_*x6_*x7_ + 0.00029563191995286495*x1_*x3_*x6_ + 0.00012236236302703984*x1_*x3_*x7_ — 0.005100777187540484*x1_*x3_ + 1.0273144909755949e-7*x1_*x4_*x5_*x6_*x7_ — 4.282446163036621e-6*x1_*x4_*x5_*x6_ — 1.7725089771387925e-6*x1_*x4_*x5_*x7_ + 7.388851548491282e-5*x1_*x4_*x5_ — 3.290560750768279e-6*x1_*x4_*x6_*x7_ + 0.0001371697701523112*x1_*x4_*x6_ + 5.6774712332795935e-5*x1_*x4_*x7_ — 0.0023667012497318313*x1_*x4_ — 4.897909687533869e-6*x1_*x5_*x6_*x7_ + 0.0002041734515648569*x1_*x5_*x6_ + 8.45076066374878e-5*x1_*x5_*x7_ — 0.0035227700858871253*x1_*x5_ + 0.00015688350080537115*x1_*x6_*x7_ — 0.006539819616205367*x1_*x6_ — 0.0027068382268636906*x1_*x7_ + 0.11283680975413288*x1_ — 1.4404933842970813e-9*x2_*x3_*x4_*x5_*x6_*x7_ + 6.004816833354854e-8*x2_*x3_*x4_*x5_*x6_ + 2.4854000114926666e-8*x2_*x3_*x4_*x5_*x7_ — 1.0360597302149638e-6*x2_*x3_*x4_*x5_ + 4.614001870156814e-8*x2_*x3_*x4_*x6_*x7_ — 1.923385171910888e-6*x2_*x3_*x4_*x6_ — 7.960911484056199e-7*x2_*x3_*x4_*x7_ + 3.3185723683902546e-5*x2_*x3_*x4_ + 6.867815595043569e-8*x2_*x3_*x5_*x6_*x7_ — 2.8629062278143214e-6*x2_*x3_*x5_*x6_ — 1.1849599028824348e-6*x2_*x3_*x5_*x7_ + 4.9396042143235244e-5*x2_*x3_*x5_ — 2.1998097600572225e-6*x2_*x3_*x6_*x7_ + 9.170090511020218e-5*x2_*x3_*x6_ + 3.795510120421959e-5*x2_*x3_*x7_ — 0.0015821900589679597*x2_*x3_ + 3.1865865045624386e-8*x2_*x4_*x5_*x6_*x7_ — 1.3283551698172608e-6*x2_*x4_*x5_*x6_ — 5.498076038248229e-7*x2_*x4_*x5_*x7_ + 2.2919188659665732e-5*x2_*x4_*x5_ — 1.0206861262122835e-6*x2_*x4_*x6_*x7_ + 4.254815271210674e-5*x2_*x4_*x6_ + 1.7610725219094348e-5*x2_*x4_*x7_ — 0.0007341177730757296*x2_*x4_ — 1.5192633852512821e-6*x2_*x5_*x6_*x7_ + 6.333176170880174e-5*x2_*x5_*x6_ + 2.6213082872067472e-5*x2_*x5_*x7_ — 0.0010927142286346146*x2_*x5_ + 4.8663077461354176e-5*x2_*x6_*x7_ — 0.002028560982722149*x2_*x6_ — 0.0008396235272224249*x2_*x7_ + 0.03500040721534296*x2_ + 8.012609870391985e-8*x3_*x4_*x5_*x6_*x7_ — 3.340123272067147e-6*x3_*x4_*x5_*x6_ — 1.38248054011407e-6*x3_*x4_*x5_*x7_ + 5.762985469397186e-5*x3_*x4_*x5_ — 2.566495438213745e-6*x3_*x4_*x6_*x7_ + 0.00010698650352363066*x3_*x4_*x6_ + 4.428182648625982e-5*x3_*x4_*x7_ — 0.00184592488062541*x3_*x4_ — 3.820158271480856e-6*x3_*x5_*x6_*x7_ + 0.00015924648463738755*x3_*x5_*x6_ + 6.591228770929172e-5*x3_*x5_*x7_ — 0.0027476087026188038*x3_*x5_ + 0.00012236236302704678*x3_*x6_*x7_ — 0.005100777187540465*x3_*x6_ — 0.0021112170500446024*x3_*x7_ + 0.08800784408220161*x3_ — 1.7725089771387925e-6*x4_*x5_*x6_*x7_ + 7.388851548491629e-5*x4_*x5_*x6_ + 3.058253437834488e-5*x4_*x5_*x7_ — 0.0012748584600295945*x4_*x5_ + 5.677471233278379e-5*x4_*x6_*x7_ — 0.002366701249731833*x4_*x6_ — 0.0009795801398659112*x4_*x7_ + 0.040834615376717426*x4_ + 8.450760663750168e-5*x5_*x6_*x7_ — 0.003522770085887094*x5_*x6_ — 0.0014580782487184623*x5_*x7_ + 0.060781208246755536*x5_ — 0.0027068382268636976*x6_*x7_ + 0.11283680975413288*x6_ + 0.04670327439658878*x7_ + 0.5527559695044361
Формула поступает в виде строки и подлежит сохранению на сервере и вызову по запросам пользователей. Предполагается, что в запросах пользователей передаются параметры x1_, x2_,… в виде простого списка значений. Требуется определить способ организации подобных вычислений с уклоном на минимизацию времени выполнения.
Особенность 1
Время формирования самих формул достаточно велико (до пары минут для приведенной формулы), поэтому время обработки и хранения поступающих формул-строк в данной задаче не является критичным (в дальнейшем будет показано, что это величины разных порядков).
Особенность 2
Предполагается, что основной объем запросов будет носить групповой характер, т.е. в одном запросе могут передаваться несколько наборов значений x1_, x2_,… для расчета по одной и той же формуле.
Инструменты
Язык программирования — Python 3x. В качестве СУБД — Redis (NoSQL).
Пару слов про Redis. На мой взгляд данная задача — прекрасный пример для его использования: пользователь формирует формулу; формула обрабатывается и отправляется в хранилище; далее она извлекается из хранилища и обрабатывается в случае, если кто-то захотел ей воспользоваться; переданные по запросу значения подставляются в формулу и выдается результат. Всё. Единственное, что необходимо знать пользователю, который хочет что-то рассчитать — количество уникальных переменных в формуле. В Redis есть встроенный механизм хэшей, так почему бы им и не воспользоваться?
Пример использования Python + Redis
import redis
r = redis.StrictRedis(host='localhost', port=6379, db=0) #подключение к серверу redis
r.hset('expr:1', 'expr', expr) #запись самой формулы в хэш 'expr:1'
r.hset('expr:1', 'params', num) #запись числа параметров в хэш 'expr:1'
r.hget('expr:1', 'expr') #извлечение формулы из хэша 'expr:1'
r.hget('expr:1', 'params') #извлечение числа параметров из хэша 'expr:1'
Для работы с самими формулами воспользуемся замечательной библиотекой Sympy, которая умеет переводить формулу-строку в символьное выражение и производить необходимые вычисления (а вообще библиотека открывает огромный математический функционал для работы с символьными выражениями).
Профилирование и оптимизация
Для измерения времени выполнения участков кода воспользуемся следующим классом (где-то позаимствованным в просторах интернета):
class Profiler(object): #профилировщик времени
def __init__(self,info=''):
self.info = info
def __enter__(self):
self._startTime = time()
def __exit__(self, type, value, traceback):
print(self.info, "Elapsed time: {:.3f} sec".format(time() - self._startTime))
Поехали… Для чистоты эксперимента введем num_iter = 1000 — число испытаний.
Протестируем профилировщик на чтении формулы-строки из файла:
with Profiler('read (' + str(num_iter) + '): cycle'):
for i in range(num_iter):
f = open('expr.txt')
expr_txt = f.read()
f.close()
>>read (1000): cycle Elapsed time: 0.014 sec
Формула-строка загружена. Теперь определим сколько же в ней переменных и какие они (должны же мы знать в какую переменную именно подставлять значения):
with Profiler('find unique sorted symbols (' + str(num_iter) + '): cycle'):
for i in range(num_iter):
symbols_set = set()
result = re.findall(r"x\d_", expr_txt)
for match in result:
symbols_set.add(match)
symbols_set = sorted(symbols_set)
symbols_list = symbols(symbols_set)
>>find unique sorted symbols (1000): cycle Elapsed time: 0.156 sec
Полученное время вполне устраивает. Теперь переведем формулу-строку в символьное выражение:
with Profiler('sympify'):
expr = sympify(expr_txt)
>>sympify Elapsed time: 0.426 sec
В этом виде ее уже можно использовать для вычислений. Попробуем:
with Profiler('subs cycle (' + str(num_iter) + '): cycle'):
for i in range(num_iter):
expr_copy = copy.copy(expr)
for x in symbols_list:
expr_copy = expr_copy.subs(x,1)
>>subs cycle (1000): cycle Elapsed time: 0.245 sec
Здесь есть особенность: sympy не умеет (?) подставлять сразу все значения в переменные символьного выражения. Приходится пользоваться циклом. В результате выполнения в expr_copy получаем вещественное число.
В sympy есть возможность преобразовать символьное выражение в лямбда-функцию с использованием модуля numpy, что теоретически должно ускорить расчеты. Осуществим перевод:
with Profiler('lambdify'):
func = lambdify(tuple(symbols_list), expr, 'numpy') # returns a numpy-ready function
>>lambdify Elapsed time: 0.114 sec
Не слишком долго получилось, что радует. Теперь проверим как быстро будут осуществляться вычисления:
with Profiler('subs cycle (' + str(num_iter) + '): lambdify'):
for i in range(num_iter):
func(*[1 for i in range(len(symbols_set))])
>>subs cycle (1000): lambdify Elapsed time: 0.026 sec
Вот это уровень! Быстрее почти на порядок. Особенно вкусно, если учесть необходимость в групповых запросах (особенность 2). Проверим на всякий случай совпадение значений:
print('exp1 == exp2:', round(expr_copy,12) == round(func(*[1 for i in range(len(symbols_set))]),12))
>>exp1 == exp2: True
Вывод 1
Хранить строку-формулу нецелесообразно — велико время ее преобразования для вычислений. Имеет смысл хранить либо символьное выражение, либо лямбда-функцию.
Попробуем разобраться с хранением. Символьное выражение — класс sympy, лямбда функция — также класс (в особенности не вникал). Будем пробовать сериализовать с помощью встроенного pickle, cloudpickle, dill:
with Profiler('pickle_dumps cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
pickle_dump = pickle.dumps(expr)
with Profiler('pickle_loads cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
pickle.loads(pickle_dump)
print()
with Profiler('cloudpickle_dumps cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
cloudpickle_dump = cloudpickle.dumps(expr)
with Profiler('cloudpickle_loads cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
cloudpickle.loads(cloudpickle_dump)
print()
with Profiler('dill_dumps cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
dill_dump = dill.dumps(expr)
with Profiler('dill_loads cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
dill.loads(dill_dump)
>>pickle_dumps cycle (1000): sympifyed expr Elapsed time: 0.430 sec
>>pickle_loads cycle (1000): sympifyed expr Elapsed time: 2.320 sec
>>
>>cloudpickle_dumps cycle (1000): sympifyed expr Elapsed time: 7.584 sec
>>cloudpickle_loads cycle (1000): sympifyed expr Elapsed time: 2.314 sec
>>
>>dill_dumps cycle (1000): sympifyed expr Elapsed time: 8.259 sec
>>dill_loads cycle (1000): sympifyed expr Elapsed time: 2.806 sec
Отметим, что pickle супер быстро сериализует символьные выражения, если сравнивать с «коллегами». Время десериализации отличается, но уже не так существенно. Теперь попробует протестировать сериализацию/десериализацию в связке с хранением/загрузкой Redis. Следует отметить тот факт, что pickle не сумел сериализовать/десериализовать лямбда-функцию.
with Profiler('redis_set cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
r.set('expr', pickle_dump)
with Profiler('redis_get cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
r.get('expr')
print()
with Profiler('pickle_dumps + redis_set cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
r.set('expr', pickle.dumps(expr))
with Profiler('redis_get + pickle_loads cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
pickle.loads(r.get('expr'))
print()
with Profiler('cloudpickle_dumps + redis_set cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
r.set('expr', cloudpickle.dumps(expr))
with Profiler('redis_get + cloudpickle_loads cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
cloudpickle.loads(r.get('expr'))
print()
with Profiler('dill_dumps + redis_set cycle (' + str(num_iter) + '): lambdifyed expr'):
for i in range(num_iter):
r.set('expr', dill.dumps(expr))
with Profiler('redis_get + dill_loads cycle (' + str(num_iter) + '): lambdifyed expr'):
for i in range(num_iter):
dill.loads(r.get('expr'))
>>redis_set cycle (1000): sympifyed expr Elapsed time: 0.066 sec
>>redis_get cycle (1000): sympifyed expr Elapsed time: 0.051 sec
>>
>>pickle_dumps + redis_set cycle (1000): sympifyed expr Elapsed time: 0.524 sec
>>redis_get + pickle_loads cycle (1000): sympifyed expr Elapsed time: 2.437 sec
>>
>>cloudpickle_dumps + redis_set cycle (1000): sympifyed expr Elapsed time: 7.659 sec
>>redis_get + cloudpickle_loads cycle (1000): sympifyed expr Elapsed time: 2.492 sec
>>
>>dill_dumps + redis_set cycle (1000): lambdifyed expr Elapsed time: 8.333 sec
>>redis_get + dill_loads cycle (1000): lambdifyed expr Elapsed time: 2.932 sec
cloudpickle и dill с сериализацией/десериализацией лямбда-функции справились (в примере выше, правда, cloudpickle работал с символьным выражением).
Вывод 2
Redis показывает хороший результат чтение/запись 1000 значений в одном потоке. Чтобы сделать выбор в дальнейшем требуется профилировать полные цепочки действий от поступления формулы-строки до выдачи пользователю рассчитанного по ней значения:
print('\nFINAL performance test:')
with Profiler('sympify + pickle_dumps_sympifyed_expr + redis_set cycle (' + str(num_iter) + '): '):
for i in range(num_iter):
expr = sympify(expr_txt)
r.set('expr', pickle.dumps(expr))
with Profiler('redis_get + pickle_loads_sympifyed_expr + subs cycle (' + str(num_iter) + '): '):
for i in range(num_iter):
loaded_expr = pickle.loads(r.get('expr'))
expr_copy = copy.copy(loaded_expr)
for x in symbols_list:
expr_copy = expr_copy.subs(x,1)
with Profiler('sympify + lambdify + dill_dumps_lambdifyed_expr + redis_set cycle (' + str(num_iter) + '): '):
for i in range(num_iter):
expr = sympify(expr_txt)
func = lambdify(tuple(symbols_list), expr, 'numpy')
r.set('expr', dill.dumps(expr))
with Profiler('redis_get + dill_loads_lambdifyed_expr + subs cycle (' + str(num_iter) + '): '):
for i in range(num_iter):
loaded_expr = dill.loads(r.get('expr'))
func(*[1 for i in range(len(symbols_set))])
with Profiler('sympify + cloudpickle_dumps_sympifyed_expr + redis_set cycle (' + str(num_iter) + '): '):
for i in range(num_iter):
expr = sympify(expr_txt)
r.set('expr', cloudpickle.dumps(expr))
with Profiler('redis_get + cloudpickle_loads_sympifyed_expr + subs cycle (' + str(num_iter) + '): '):
for i in range(num_iter):
loaded_expr = cloudpickle.loads(r.get('expr'))
expr_copy = copy.copy(loaded_expr)
for x in symbols_list:
expr_copy = expr_copy.subs(x,1)
with Profiler('sympify + lambdify + cloudpickle_dumps_lambdifyed_expr + redis_set cycle (' + str(num_iter) + '): '):
for i in range(num_iter):
expr = sympify(expr_txt)
func = lambdify(tuple(symbols_list), expr, 'numpy')
r.set('expr', cloudpickle.dumps(expr))
with Profiler('redis_get + cloudpickle_loads_lambdifyed_expr + subs cycle (' + str(num_iter) + '): '):
for i in range(num_iter):
loaded_expr = cloudpickle.loads(r.get('expr'))
func(*[1 for i in range(len(symbols_set))])
>>FINAL performance test:
>>sympify + pickle_dumps_sympifyed_expr + redis_set cycle (1000): Elapsed time: 15.075 sec
>>redis_get + pickle_loads_sympifyed_expr + subs cycle (1000): Elapsed time: 2.929 sec
>>sympify + lambdify + dill_dumps_lambdifyed_expr + redis_set cycle (1000): Elapsed time: 87.707 sec
>>redis_get + dill_loads_lambdifyed_expr + subs cycle (1000): Elapsed time: 2.356 sec
>>sympify + cloudpickle_dumps_sympifyed_expr + redis_set cycle (1000): Elapsed time: 23.633 sec
>>redis_get + cloudpickle_loads_sympifyed_expr + subs cycle (1000): Elapsed time: 3.059 sec
>>sympify + lambdify + cloudpickle_dumps_lambdifyed_expr + redis_set cycle (1000): Elapsed time: 86.739 sec
>>redis_get + cloudpickle_loads_lambdifyed_expr + subs cycle (1000): Elapsed time: 1.721 sec
Вывод 3
Создание лямбда-функции и ее сериализация с помощью cloudpickle, конечно, оказались самыми долгими, НО, если вспомнить (особенность 1) некритичность времени обработки и хранения, то… Cloudpickle молодец! Удалось в рамках одного потока вытащить из базы, десериализовать и рассчитать 1000 раз за 1,7 сек. Что, в целом, хорошо, учитывая сложность исходной формулы-строки.
Попробуем оценить производительность для групповых запросов. Будем менять число групп параметров порядками с надеждой на улучшение результата:
print('\nTEST performance for complex requests:')
for x in [1,10,100,1000]:
with Profiler('redis_get + cloudpickle_loads_lambdifyed_expr + ' + str(x) + '*subs cycle (' + str(round(num_iter/x)) + '): '):
for i in range(round(num_iter/x)):
loaded_expr = cloudpickle.loads(r.get('expr'))
for j in range(x):
func(*[1 for i in range(len(symbols_set))])
>>TEST performance for complex requests:
>>redis_get + cloudpickle_loads_lambdifyed_expr + 1*subs cycle (1000): Elapsed time: 1.768 sec
>>redis_get + cloudpickle_loads_lambdifyed_expr + 10*subs cycle (100): Elapsed time: 0.204 sec
>>redis_get + cloudpickle_loads_lambdifyed_expr + 100*subs cycle (10): Elapsed time: 0.046 sec
>>redis_get + cloudpickle_loads_lambdifyed_expr + 1000*subs cycle (1): Elapsed time: 0.028 sec
Результат выглядит вполне жизнеспособным. Расчеты проводились на виртуальной машине со следующими характеристиками: ОС Ubuntu 16.04.2 LTS, Процессор Intel® Core(TM) i7-4720HQ CPU @ 2.60GHz (выделено 1 ядро), DDR3-1600 (выделено 1Gb).
Заключение
Спасибо за просмотр! Буду рад конструктивной критике и интересным замечаниям.
В вопросе профилирования и оптимизации требуемых вычислений были использованы идеи и подходы, изложенные здесь (слишком «слабая» формула в примере, но хороший набор тестов) и здесь (информация о сериализации лямбда-функций).
Полный текст проведенных тестов, включая импорты библиотек
import redis
import pickle
import dill
import cloudpickle
import re
import copy
from time import time
from sympy.utilities.lambdify import lambdify
from sympy import sympify, symbols
class Profiler(object): #профилировщик времени
def __init__(self,info=''):
self.info = info
def __enter__(self):
self._startTime = time()
def __exit__(self, type, value, traceback):
print(self.info, "Elapsed time: {:.3f} sec".format(time() - self._startTime))
num_iter = 1000
dill.settings['recurse'] = True
r = redis.StrictRedis(host='localhost', port=6379, db=0)
with Profiler('read (' + str(num_iter) + '): cycle'):
for i in range(num_iter):
f = open('expr.txt')
expr_txt = f.read()
f.close()
with Profiler('find unique sorted symbols (' + str(num_iter) + '): cycle'):
for i in range(num_iter):
symbols_set = set()
result = re.findall(r"x\d_", expr_txt)
for match in result:
symbols_set.add(match)
symbols_set = sorted(symbols_set)
symbols_list = symbols(symbols_set)
print()
with Profiler('sympify'):
expr = sympify(expr_txt)
with Profiler('lambdify'):
func = lambdify(tuple(symbols_list), expr, 'numpy') # returns a numpy-ready function
print()
with Profiler('subs cycle (' + str(num_iter) + '): cycle'):
for i in range(num_iter):
expr_copy = copy.copy(expr)
for x in symbols_list:
expr_copy = expr_copy.subs(x,1)
with Profiler('subs cycle (' + str(num_iter) + '): lambdify'):
for i in range(num_iter):
func(*[1 for i in range(len(symbols_set))])
print()
print('exp1 == exp2:', round(expr_copy,12) == round(func(*[1 for i in range(len(symbols_set))]),12))
print()
with Profiler('pickle_dumps cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
pickle_dump = pickle.dumps(expr)
with Profiler('pickle_loads cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
pickle.loads(pickle_dump)
print()
with Profiler('cloudpickle_dumps cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
cloudpickle_dump = cloudpickle.dumps(expr)
with Profiler('cloudpickle_loads cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
cloudpickle.loads(cloudpickle_dump)
print()
with Profiler('dill_dumps cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
dill_dump = dill.dumps(expr)
with Profiler('dill_loads cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
dill.loads(dill_dump)
print()
#убедились, что все правильно считает (до 12 знака), сравнили производительность, попробуем побаловаться с redis
with Profiler('redis_set cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
r.set('expr', pickle_dump)
with Profiler('redis_get cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
r.get('expr')
print()
with Profiler('pickle_dumps + redis_set cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
r.set('expr', pickle.dumps(expr))
with Profiler('redis_get + pickle_loads cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
pickle.loads(r.get('expr'))
print()
with Profiler('cloudpickle_dumps + redis_set cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
r.set('expr', cloudpickle.dumps(expr))
with Profiler('redis_get + cloudpickle_loads cycle (' + str(num_iter) + '): sympifyed expr'):
for i in range(num_iter):
cloudpickle.loads(r.get('expr'))
print()
with Profiler('dill_dumps + redis_set cycle (' + str(num_iter) + '): lambdifyed expr'):
for i in range(num_iter):
r.set('expr', dill.dumps(expr))
with Profiler('redis_get + dill_loads cycle (' + str(num_iter) + '): lambdifyed expr'):
for i in range(num_iter):
dill.loads(r.get('expr'))
print('\nFINAL performance test:')
with Profiler('sympify + pickle_dumps_sympifyed_expr + redis_set cycle (' + str(num_iter) + '): '):
for i in range(num_iter):
expr = sympify(expr_txt)
r.set('expr', pickle.dumps(expr))
with Profiler('redis_get + pickle_loads_sympifyed_expr + subs cycle (' + str(num_iter) + '): '):
for i in range(num_iter):
loaded_expr = pickle.loads(r.get('expr'))
expr_copy = copy.copy(loaded_expr)
for x in symbols_list:
expr_copy = expr_copy.subs(x,1)
with Profiler('sympify + lambdify + dill_dumps_lambdifyed_expr + redis_set cycle (' + str(num_iter) + '): '):
for i in range(num_iter):
expr = sympify(expr_txt)
func = lambdify(tuple(symbols_list), expr, 'numpy')
r.set('expr', dill.dumps(expr))
with Profiler('redis_get + dill_loads_lambdifyed_expr + subs cycle (' + str(num_iter) + '): '):
for i in range(num_iter):
loaded_expr = dill.loads(r.get('expr'))
func(*[1 for i in range(len(symbols_set))])
with Profiler('sympify + cloudpickle_dumps_sympifyed_expr + redis_set cycle (' + str(num_iter) + '): '):
for i in range(num_iter):
expr = sympify(expr_txt)
r.set('expr', cloudpickle.dumps(expr))
with Profiler('redis_get + cloudpickle_loads_sympifyed_expr + subs cycle (' + str(num_iter) + '): '):
for i in range(num_iter):
loaded_expr = cloudpickle.loads(r.get('expr'))
expr_copy = copy.copy(loaded_expr)
for x in symbols_list:
expr_copy = expr_copy.subs(x,1)
with Profiler('sympify + lambdify + cloudpickle_dumps_lambdifyed_expr + redis_set cycle (' + str(num_iter) + '): '):
for i in range(num_iter):
expr = sympify(expr_txt)
func = lambdify(tuple(symbols_list), expr, 'numpy')
r.set('expr', cloudpickle.dumps(expr))
with Profiler('redis_get + cloudpickle_loads_lambdifyed_expr + subs cycle (' + str(num_iter) + '): '):
for i in range(num_iter):
loaded_expr = cloudpickle.loads(r.get('expr'))
func(*[1 for i in range(len(symbols_set))])
print('\nTEST performance for complex requests:')
for x in [1,10,100,1000]:
with Profiler('redis_get + cloudpickle_loads_lambdifyed_expr + ' + str(x) + '*subs cycle (' + str(round(num_iter/x)) + '): '):
for i in range(round(num_iter/x)):
loaded_expr = cloudpickle.loads(r.get('expr'))
for j in range(x):
func(*[1 for i in range(len(symbols_set))])
#r.set('expr', func)
>>read (1000): cycle Elapsed time: 0.014 sec
>>find unique sorted symbols (1000): cycle Elapsed time: 0.156 sec
>>
>>sympify Elapsed time: 0.426 sec
>>lambdify Elapsed time: 0.114 sec
>>
>>subs cycle (1000): cycle Elapsed time: 0.245 sec
>>subs cycle (1000): lambdify Elapsed time: 0.026 sec
>>
>>exp1 == exp2: True
>>
>>pickle_dumps cycle (1000): sympifyed expr Elapsed time: 0.430 sec
>>pickle_loads cycle (1000): sympifyed expr Elapsed time: 2.320 sec
>>
>>cloudpickle_dumps cycle (1000): sympifyed expr Elapsed time: 7.584 sec
>>cloudpickle_loads cycle (1000): sympifyed expr Elapsed time: 2.314 sec
>>
>>dill_dumps cycle (1000): sympifyed expr Elapsed time: 8.259 sec
>>dill_loads cycle (1000): sympifyed expr Elapsed time: 2.806 sec
>>
>>redis_set cycle (1000): sympifyed expr Elapsed time: 0.066 sec
>>redis_get cycle (1000): sympifyed expr Elapsed time: 0.051 sec
>>
>>pickle_dumps + redis_set cycle (1000): sympifyed expr Elapsed time: 0.524 sec
>>redis_get + pickle_loads cycle (1000): sympifyed expr Elapsed time: 2.437 sec
>>
>>cloudpickle_dumps + redis_set cycle (1000): sympifyed expr Elapsed time: 7.659 sec
>>redis_get + cloudpickle_loads cycle (1000): sympifyed expr Elapsed time: 2.492 sec
>>
>>dill_dumps + redis_set cycle (1000): lambdifyed expr Elapsed time: 8.333 sec
>>redis_get + dill_loads cycle (1000): lambdifyed expr Elapsed time: 2.932 sec
>>
>>FINAL performance test:
>>sympify + pickle_dumps_sympifyed_expr + redis_set cycle (1000): Elapsed time: 15.075 sec
>>redis_get + pickle_loads_sympifyed_expr + subs cycle (1000): Elapsed time: 2.929 sec
>>sympify + lambdify + dill_dumps_lambdifyed_expr + redis_set cycle (1000): Elapsed time: 87.707 sec
>>redis_get + dill_loads_lambdifyed_expr + subs cycle (1000): Elapsed time: 2.356 sec
>>sympify + cloudpickle_dumps_sympifyed_expr + redis_set cycle (1000): Elapsed time: 23.633 sec
>>redis_get + cloudpickle_loads_sympifyed_expr + subs cycle (1000): Elapsed time: 3.059 sec
>>sympify + lambdify + cloudpickle_dumps_lambdifyed_expr + redis_set cycle (1000): Elapsed time: 86.739 sec
>>redis_get + cloudpickle_loads_lambdifyed_expr + subs cycle (1000): Elapsed time: 1.721 sec
>>
>>TEST performance for complex requests:
>>redis_get + cloudpickle_loads_lambdifyed_expr + 1*subs cycle (1000): Elapsed time: 1.768 sec
>>redis_get + cloudpickle_loads_lambdifyed_expr + 10*subs cycle (100): Elapsed time: 0.204 sec
>>redis_get + cloudpickle_loads_lambdifyed_expr + 100*subs cycle (10): Elapsed time: 0.046 sec
>>redis_get + cloudpickle_loads_lambdifyed_expr + 1000*subs cycle (1): Elapsed time: 0.028 sec
Чтобы воспользоваться кодом, необходимо:
- Создать файл expr.txt рядом с python-скриптом и поместить в него формулу-строку соответствующего вида
- Установить библиотеки redis, dill, cloudpickle, sympy, numpy
Поделиться с друзьями
Комментарии (12)
MInner
08.05.2017 00:52Я в свое время для решения похожей задачи на С представлял формулу в обратной польской записи после всяческих нормализаций вроде этой. В таком случае, вычисление произвольного полинома сводилось к последовательному применению функций из списка к стеку заполненному входными данными. Если поиграться с numpy'ем, но наверное можно и в батчах это делать. Тогда функция — это перестановка входных данных в стеке и список атомарных операций на этим стеком, вроде «сложить два верхних элемента».
Roman_Kh
Если формулы — полиномы, возможно вам стоит
numexpr
попрбовать. Будет в 10 раз проще и в 100 раз быстрее.Palich239
Спасибо, формулы по сути полиномы, да. Пробовал numexpr. Почему-то с первого тыка не завелся, поэтому я его отложил. Т.е. лямбда-функция создалась, но работать отказывается при вызове:
>>ValueError: bytes must be in range(0, 256)
#это на фоне KeyError: ('8.348841409877572e-… длинная ошибка, до которой руки не дошли. Есть идеи как правильно пользовать?
Roman_Kh
Попробуйте сгенерить формулу без явных констант в тексте — может с парсингом что-то не так.
Palich239
Обнаружил, что если убрать последние 4 члена (3 несвободных и 1 свободный) или более, то все работает. Но если убрать только 3 или менее — то вылетает с обозначенными ошибками. Может на длину полинома ограничение?
Palich239
Кстати, тесты при использовании numexpr не дали обещанной производительности:
…
subs cycle (1000): lambdify Elapsed time: 0.482 sec (против numpy 0.026 sec)
…
redis_get + cloudpickle_loads_lambdifyed_expr + subs cycle (1000): Elapsed time: 2.396 sec (против numpy 1,721)
Вероятно, с учетом ограничений и результатов тестов, придется отказаться от numexpr.
Roman_Kh
Я не знаю, что и как вы делаете, но ваша формула (укороченная на 3 члена) считается за 30 микросекунд в 1 строчку
numexpr.evaluate(long_formula_as_a_string)
. Anumexpr.re_evaluate()
вообще отрабатывает за 22 микросекунды.Что в 1000 раз быстрее вашего текущего подхода с лямбда-функциями, на создание которых уходит еще в 10 тысяч раз больше времени.
Palich239
Надеюсь вы обратили внимание, что каждый тест прогоняется 1000 раз? И можете ли вы дать кусок кода? Я не силен в numexpr. Спасибо
Roman_Kh
Так я привел код. Все, что вам нужно — это:
или можно чуть иначе:
Palich239
Так я и думал. Ну не может то, что на входе в виде формулы-строки, переменных в виде строк и их значений считаться быстрее, чем уже составленное дерево операций. Вот результаты расчетов:
numexpr (1000): cycle Elapsed time: 0.535 sec (против subs cycle (1000): lambdify Elapsed time: 0.026 sec).
Факт — штука упрямая…
Roman_Kh
Упрямы обычно криворукие необразованые дилетанты… а факты бывают разные.
Ваш подход:
expr = sympify(expr_txt)
func = lambdify(tuple(symbols_list), expr, 'numpy')
func(*[1 for i in range(len(symbols_set))])
Все это вместе отнимает 540 миллисек.
Или можно взять
numexpr
, тогда потребуется только одна операцияnumexpr.evaluate(long_formula_as_a_string, local_dict=variables_dict)
Все это вместе отнимает 30 микросекунд.
Теперь рассчитаем, во сколько раз быстрее
numexpr
:540 миллисекунд / 30 микросекунд = 18000
numexpr быстрее в 18 тысяч раз!
Palich239
Вы, видимо, не слишком внимательно читали статью. Первые два пункта, которые вы обозначили, рассчитываются лишь однажды. Критично время самих расчетов (п.3). И прекращайте вводить людей в заблуждение — нет там 30 микросекунд. Вот с какого потолка вы эти результаты берете? Не бывает чудес. Разве что вы на мейнфрейме считаете. У меня полсек ВАШ код отработал (1000 итераций).