Привет, Хабр! Сегодня хочу поделиться своим небольшим опытом выбора инструментов для организации расчетов на будущем сервере. Отмечу сразу, что в этой публикации речь пойдет не о самом сервере, а скорее об оптимизации символьных вычислений на нем.

Задача


Есть некий функционал, который позволяет пользователям формировать нередко громоздкие формулы следующего общего вида, по которым в дальнейшем необходимо рассчитывать запросы других пользователей.

Типовая формула. Использовалась для профилирования
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)


  1. Roman_Kh
    07.05.2017 21:29

    Если формулы — полиномы, возможно вам стоит numexpr попрбовать. Будет в 10 раз проще и в 100 раз быстрее.


    1. Palich239
      07.05.2017 21:47

      Спасибо, формулы по сути полиномы, да. Пробовал numexpr. Почему-то с первого тыка не завелся, поэтому я его отложил. Т.е. лямбда-функция создалась, но работать отказывается при вызове:
      >>ValueError: bytes must be in range(0, 256)
      #это на фоне KeyError: ('8.348841409877572e-… длинная ошибка, до которой руки не дошли. Есть идеи как правильно пользовать?


      1. Roman_Kh
        07.05.2017 22:20

        Попробуйте сгенерить формулу без явных констант в тексте — может с парсингом что-то не так.


        1. Palich239
          07.05.2017 23:06

          Обнаружил, что если убрать последние 4 члена (3 несвободных и 1 свободный) или более, то все работает. Но если убрать только 3 или менее — то вылетает с обозначенными ошибками. Может на длину полинома ограничение?


        1. Palich239
          07.05.2017 23:26

          Кстати, тесты при использовании 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.


          1. Roman_Kh
            08.05.2017 00:25
            +1

            Я не знаю, что и как вы делаете, но ваша формула (укороченная на 3 члена) считается за 30 микросекунд в 1 строчку numexpr.evaluate(long_formula_as_a_string). A numexpr.re_evaluate() вообще отрабатывает за 22 микросекунды.


            Что в 1000 раз быстрее вашего текущего подхода с лямбда-функциями, на создание которых уходит еще в 10 тысяч раз больше времени.


            1. Palich239
              08.05.2017 01:01

              Надеюсь вы обратили внимание, что каждый тест прогоняется 1000 раз? И можете ли вы дать кусок кода? Я не силен в numexpr. Спасибо


              1. Roman_Kh
                08.05.2017 01:18

                Так я привел код. Все, что вам нужно — это:


                x1_, x2_, x3_, x4_, x5_, x6_, x7_ = ... # значения переменных
                numexpr.evaluate(long_formula_as_a_string)

                или можно чуть иначе:


                variables_dict = dict(zip(variables_names, variables_values))
                numexpr.evaluate(long_formula_as_a_string, local_dict=variables_dict)


                1. Palich239
                  08.05.2017 02:23
                  -1

                  Так я и думал. Ну не может то, что на входе в виде формулы-строки, переменных в виде строк и их значений считаться быстрее, чем уже составленное дерево операций. Вот результаты расчетов:
                  numexpr (1000): cycle Elapsed time: 0.535 sec (против subs cycle (1000): lambdify Elapsed time: 0.026 sec).
                  Факт — штука упрямая…


                  1. Roman_Kh
                    08.05.2017 12:50

                    Упрямы обычно криворукие необразованые дилетанты… а факты бывают разные.


                    Ваш подход:


                    • сначала надо откомпилировать выражение, это занимает 426 миллисек на 1 итерацию
                      expr = sympify(expr_txt)
                    • затем создать лямдба-функцию, на это уйдет 114 миллисек на 1 итерацию
                      func = lambdify(tuple(symbols_list), expr, 'numpy')
                    • и только после этого выполнять расчеты, 26 микросек на 1 итерацию
                      func(*[1 for i in range(len(symbols_set))])
                      Все это вместе отнимает 540 миллисек.

                    Или можно взять numexpr, тогда потребуется только одна операция


                    • скопилировать и вычислить выражение за 30 микросекунд
                      numexpr.evaluate(long_formula_as_a_string, local_dict=variables_dict)
                      Все это вместе отнимает 30 микросекунд.

                    Теперь рассчитаем, во сколько раз быстрее numexpr:
                    540 миллисекунд / 30 микросекунд = 18000


                    numexpr быстрее в 18 тысяч раз!


                    1. Palich239
                      08.05.2017 13:38
                      -1

                      Вы, видимо, не слишком внимательно читали статью. Первые два пункта, которые вы обозначили, рассчитываются лишь однажды. Критично время самих расчетов (п.3). И прекращайте вводить людей в заблуждение — нет там 30 микросекунд. Вот с какого потолка вы эти результаты берете? Не бывает чудес. Разве что вы на мейнфрейме считаете. У меня полсек ВАШ код отработал (1000 итераций).


  1. MInner
    08.05.2017 00:52

    Я в свое время для решения похожей задачи на С представлял формулу в обратной польской записи после всяческих нормализаций вроде этой. В таком случае, вычисление произвольного полинома сводилось к последовательному применению функций из списка к стеку заполненному входными данными. Если поиграться с numpy'ем, но наверное можно и в батчах это делать. Тогда функция — это перестановка входных данных в стеке и список атомарных операций на этим стеком, вроде «сложить два верхних элемента».