python concurrent.futures.ProcessPoolExecutor: производительность .submit() против .map()

Я использую concurrent.futures.ProcessPoolExecutor, чтобы найти вхождение числа из диапазона чисел. Цель состоит в том, чтобы исследовать степень повышения производительности за счет параллелизма. Для оценки производительности у меня есть элемент управления — серийный код для выполнения указанной задачи (показан ниже). Я написал 2 параллельных кода, один с использованием concurrent.futures.ProcessPoolExecutor.submit(), а другой с использованием concurrent.futures.ProcessPoolExecutor.map() для выполнения одной и той же задачи. Они показаны ниже. Советы по составлению первого и второго вариантов можно найти здесь и здесь соответственно.

Задача, выданная всем трем кодам, состояла в том, чтобы найти количество вхождений числа 5 в диапазоне чисел от 0 до 1E8. И .submit(), и .map() были назначены по 6 рабочих, а .map() имел размер фрагмента 10 000. Способ дискретизации рабочей нагрузки был идентичен в параллельных кодах. Однако функция, используемая для поиска вхождений в обоих кодах, была разной. Это произошло потому, что способ передачи аргументов в функцию, вызываемую .submit() и .map(), был разным.

Все 3 кода сообщили об одном и том же количестве случаев, то есть 56 953 279 раз. Однако время, затраченное на выполнение задачи, было очень разным. .submit() выполнялось в 2 раза быстрее, чем контроль, а .map() потребовалось в два раза больше времени, чем контроль, чтобы выполнить свою задачу.

Вопросы:

  1. Я хотел бы знать, является ли низкая производительность .map() артефактом моего кодирования или она изначально медленная?» Если первое, как я могу ее улучшить. много стимулов для его использования.
  2. Мне хотелось бы знать, есть ли способ заставить код .submit() работать еще быстрее. У меня есть условие, что функция _concurrent_submit() должна возвращать итерацию с числами/вхождениями, содержащими число 5.

Результаты тестирования
результаты тестов

concurrent.futures.ProcessPoolExecutor.submit()

#!/usr/bin/python3.5
# -*- coding: utf-8 -*-

import concurrent.futures as cf
from time import time
from traceback import print_exc

def _findmatch(nmin, nmax, number):
    '''Function to find the occurrence of number in range nmin to nmax and return
       the found occurrences in a list.'''
    print('\n def _findmatch', nmin, nmax, number)
    start = time()
    match=[]
    for n in range(nmin, nmax):
        if number in str(n):
            match.append(n)
    end = time() - start
    print("found {0} in {1:.4f}sec".format(len(match),end))
    return match

def _concurrent_submit(nmax, number, workers):
    '''Function that utilises concurrent.futures.ProcessPoolExecutor.submit to
       find the occurences of a given number in a number range in a parallelised
       manner.'''
    # 1. Local variables
    start = time()
    chunk = nmax // workers
    futures = []
    found =[]
    #2. Parallelization
    with cf.ProcessPoolExecutor(max_workers=workers) as executor:
        # 2.1. Discretise workload and submit to worker pool
        for i in range(workers):
            cstart = chunk * i
            cstop = chunk * (i + 1) if i != workers - 1 else nmax
            futures.append(executor.submit(_findmatch, cstart, cstop, number))
        # 2.2. Instruct workers to process results as they come, when all are
        #      completed or .....
        cf.as_completed(futures) # faster than cf.wait()
        # 2.3. Consolidate result as a list and return this list.
        for future in futures:
            for f in future.result():
                try:
                    found.append(f)
                except:
                    print_exc()
        foundsize = len(found)
        end = time() - start
        print('within statement of def _concurrent_submit():')
        print("found {0} in {1:.4f}sec".format(foundsize, end))
    return found

if __name__ == '__main__':
    nmax = int(1E8) # Number range maximum.
    number = str(5) # Number to be found in number range.
    workers = 6     # Pool of workers

    start = time()
    a = _concurrent_submit(nmax, number, workers)
    end = time() - start
    print('\n main')
    print('workers = ', workers)
    print("found {0} in {1:.4f}sec".format(len(a),end))

concurrent.futures.ProcessPoolExecutor.map()

#!/usr/bin/python3.5
# -*- coding: utf-8 -*-

import concurrent.futures as cf
import itertools
from time import time
from traceback import print_exc

def _findmatch(listnumber, number):    
    '''Function to find the occurrence of number in another number and return
       a string value.'''
    #print('def _findmatch(listnumber, number):')
    #print('listnumber = {0} and ref = {1}'.format(listnumber, number))
    if number in str(listnumber):
        x = listnumber
        #print('x = {0}'.format(x))
        return x 

def _concurrent_map(nmax, number, workers):
    '''Function that utilises concurrent.futures.ProcessPoolExecutor.map to
       find the occurrences of a given number in a number range in a parallelised
       manner.'''
    # 1. Local variables
    start = time()
    chunk = nmax // workers
    futures = []
    found =[]
    #2. Parallelization
    with cf.ProcessPoolExecutor(max_workers=workers) as executor:
        # 2.1. Discretise workload and submit to worker pool
        for i in range(workers):
            cstart = chunk * i
            cstop = chunk * (i + 1) if i != workers - 1 else nmax
            numberlist = range(cstart, cstop)
            futures.append(executor.map(_findmatch, numberlist,
                                        itertools.repeat(number),
                                        chunksize=10000))
        # 2.3. Consolidate result as a list and return this list.
        for future in futures:
            for f in future:
                if f:
                    try:
                        found.append(f)
                    except:
                        print_exc()
        foundsize = len(found)
        end = time() - start
        print('within statement of def _concurrent(nmax, number):')
        print("found {0} in {1:.4f}sec".format(foundsize, end))
    return found

if __name__ == '__main__':
    nmax = int(1E8) # Number range maximum.
    number = str(5) # Number to be found in number range.
    workers = 6     # Pool of workers

    start = time()
    a = _concurrent_map(nmax, number, workers)
    end = time() - start
    print('\n main')
    print('workers = ', workers)
    print("found {0} in {1:.4f}sec".format(len(a),end))

Серийный код:

#!/usr/bin/python3.5
# -*- coding: utf-8 -*-

from time import time

def _serial(nmax, number):    
    start = time()
    match=[]
    nlist = range(nmax)
    for n in nlist:
        if number in str(n):match.append(n)
    end=time()-start
    print("found {0} in {1:.4f}sec".format(len(match),end))
    return match

if __name__ == '__main__':
    nmax = int(1E8) # Number range maximum.
    number = str(5) # Number to be found in number range.

    start = time()
    a = _serial(nmax, number)
    end = time() - start
    print('\n main')
    print("found {0} in {1:.4f}sec".format(len(a),end))

Обновление от 13 февраля 2017 г.

В дополнение к ответу @niemmi я предоставил ответ после некоторых личных исследований, чтобы показать:

  1. как еще больше ускорить решения @niemmi .map() и .submit(), и
  2. когда ProcessPoolExecutor.map() может привести к большему ускорению, чем ProcessPoolExecutor.submit().

person Sun Bear    schedule 06.02.2017    source источник


Ответы (2)


Обзор:

Мой ответ состоит из двух частей:

  • В части 1 показано, как добиться большего ускорения с помощью решения @niemmi ProcessPoolExecutor.map().
  • Часть 2 показывает, когда подклассы ProcessPoolExecutor .submit() и .map() дают неэквивалентное время вычислений.

============================================== ========================

Часть 1. Ускорение работы ProcessPoolExecutor.map()

Общие сведения. Этот раздел основан на решении @niemmi .map(), которое само по себе превосходно. Проводя некоторое исследование его схемы дискретизации, чтобы лучше понять, как это взаимодействует с аргументом .map() chunksize, я нашел это интересное решение.

Я считаю, что определение @niemmi chunk = nmax // workers является определением размера фрагмента, то есть меньшего размера фактического диапазона чисел (данной задачи), который должен решать каждый рабочий в пуле рабочих. Теперь это определение основано на предположении, что если компьютер имеет х рабочих мест, разделение задачи поровну между каждым работником приведет к оптимальному использованию каждого работника и, следовательно, вся задача будет выполнена быстрее. Следовательно, количество фрагментов, на которые нужно разбить данную задачу, всегда должно равняться количеству работников пула. Однако верно ли это предположение?

Предложение: здесь я предполагаю, что приведенное выше предположение не всегда приводит к максимально быстрому времени вычислений при использовании с ProcessPoolExecutor.map(). Скорее, дискретизация задачи до количества, превышающего количество рабочих пула, может привести к ускорению, т. е. к более быстрому выполнению данной задачи.

Эксперимент: я изменил код @niemmi, чтобы количество дискретизированных задач превышало количество работников пула. Этот код приведен ниже и используется для определения количества раз, когда число 5 появляется в диапазоне чисел от 0 до 1E8. Я выполнил этот код, используя 1, 2, 4 и 6 воркеров пула и для различного соотношения количества дискретных задач и количества воркеров пула. Для каждого сценария было выполнено 3 прогона, а время вычислений занесено в таблицу. «Ускорение» здесь определяется как среднее время вычислений с использованием равного количества фрагментов и рабочих процессов пула по сравнению со средним временем вычислений, когда количество дискретизированных задач превышает количество рабочих процессов пула.

Выводы:

загрузить nworkers

  1. На рисунке слева показано время вычислений, затраченное всеми сценариями, упомянутыми в разделе «Эксперимент». Он показывает, что время вычислений, затрачиваемое на количество чанков / количество рабочих процессов = 1, всегда больше, чем время вычислений, затрачиваемое на количество чанков > количество рабочих процессов. . То есть первый случай всегда менее эффективен, чем второй.

  2. На рисунке справа показано, что ускорение в 1,2 раза или более было получено, когда количество фрагментов/количество рабочих операций достигло порогового значения 14 или более. Интересно отметить, что тенденция к ускорению также наблюдалась, когда ProcessPoolExecutor.map() выполнялся с 1 работником.

Вывод: при настройке количества дискретных задач, которые ProcessPoolExecutor.map()` должен использовать для решения данной задачи, разумно убедиться, что это число больше, чем количество рабочих пула, поскольку эта практика сокращает вычислить время.

код concurrent.futures.ProcessPoolExecutor.map(). (только измененные части)

def _concurrent_map(nmax, number, workers, num_of_chunks):
    '''Function that utilises concurrent.futures.ProcessPoolExecutor.map to
       find the occurrences of a given number in a number range in a parallelised
       manner.'''
    # 1. Local variables
    start = time()
    chunksize = nmax // num_of_chunks
    futures = []
    found =[]
    #2. Parallelization
    with cf.ProcessPoolExecutor(max_workers=workers) as executor:
        # 2.1. Discretise workload and submit to worker pool
        cstart = (chunksize * i for i in range(num_of_chunks))
        cstop = (chunksize * i if i != num_of_chunks else nmax
                 for i in range(1, num_of_chunks + 1))
        futures = executor.map(_findmatch, cstart, cstop,
                               itertools.repeat(number))
        # 2.2. Consolidate result as a list and return this list.
        for future in futures:
            #print('type(future)=',type(future))
            for f in future:
                if f:
                    try:
                        found.append(f)
                    except:
                        print_exc()
        foundsize = len(found)
        end = time() - start
        print('\n within statement of def _concurrent(nmax, number):')
        print("found {0} in {1:.4f}sec".format(foundsize, end))
    return found

if __name__ == '__main__':
    nmax = int(1E8) # Number range maximum.
    number = str(5) # Number to be found in number range.
    workers = 4     # Pool of workers
    chunks_vs_workers = 14 # A factor of =>14 can provide optimum performance  
    num_of_chunks = chunks_vs_workers * workers

    start = time()
    a = _concurrent_map(nmax, number, workers, num_of_chunks)
    end = time() - start
    print('\n main')
    print('nmax={}, workers={}, num_of_chunks={}'.format(
          nmax, workers, num_of_chunks))
    print('workers = ', workers)
    print("found {0} in {1:.4f}sec".format(len(a),end))

============================================== ========================

Часть 2. Общее время вычислений при использовании подклассов ProcessPoolExecutor .submit() и .map() может быть разным при возврате отсортированного/упорядоченного списка результатов.

Предыстория: я внес поправки в коды .submit() и .map(), чтобы обеспечить возможность сравнения времени вычислений "яблоко-яблоко" и возможность визуализировать время вычисления основного кода, время вычисления метод _concurrent, вызываемый основным кодом для выполнения параллельных операций, и время вычислений для каждой дискретизированной задачи/работника, вызываемого методом _concurrent. Кроме того, параллельный метод в этих кодах был построен так, чтобы возвращать неупорядоченный и упорядоченный список результатов непосредственно из будущего объекта .submit() и итератора .map(). Исходный код приведен ниже (Надеюсь, он вам поможет.).

Эксперименты Эти два недавно улучшенных кода использовались для выполнения того же эксперимента, описанного в части 1, за исключением того, что рассматривались только 6 рабочих процессов пула, а встроенные методы list и sorted python использовались для возврата неупорядоченного и упорядоченный список результатов к основному разделу кода соответственно.

Выводы: .submit или .map плюс список или отсортированный

  1. Из результата метода _concurrent мы можем увидеть время вычислений метода _concurrent, используемого для создания всех объектов Future ProcessPoolExecutor.submit() и для создания итератора ProcessPoolExecutor.map(), как функцию количества дискретизированных задач по количеству рабочих пула, эквивалентны. Этот результат просто означает, что подклассы ProcessPoolExecutor .submit() и .map() одинаково эффективны/быстры.
  2. Сравнивая время вычислений от main и его метода _concurrent, мы видим, что main работает дольше, чем его метод _concurrent. Этого и следовало ожидать, поскольку их разница во времени отражает количество времени вычислений методов list и sorted (и других методов, заключенных в эти методы). Ясно видно, что методу list потребовалось меньше времени вычислений для возврата списка результатов, чем методу sorted. Среднее время вычислений метода list для кодов .submit() и .map() было одинаковым и составляло ~0,47 с. Среднее время вычисления метода sorted для кодов .submit() и .map() составило 1,23 с и 1,01 с соответственно. Другими словами, метод list работал в 2,62 и 2,15 раза быстрее, чем метод sorted для кодов .submit() и .map() соответственно.
  3. Непонятно, почему метод sorted генерировал упорядоченный список из .map() быстрее, чем из .submit(), так как количество дискретизированных задач увеличилось больше, чем количество работников пула, за исключением случаев, когда количество дискретизированных задач равнялось количеству работников пула. Тем не менее, эти результаты показывают, что решение об использовании одинаково быстрых подклассов .submit() или .map() может быть обременено методом сортировки. Например, если целью является создание упорядоченного списка в кратчайшие сроки, использование ProcessPoolExecutor.map() должно быть предпочтительнее, чем ProcessPoolExecutor.submit(), поскольку .map() может обеспечить кратчайшее общее время вычислений.
  4. Здесь показана схема дискретизации, упомянутая в части 1 моего ответа, для повышения производительности подклассов .submit() и .map(). Величина ускорения может достигать 20% по сравнению со случаем, когда количество дискретизированных задач равнялось количеству работников пула.

Улучшенный код .map()

#!/usr/bin/python3.5
# -*- coding: utf-8 -*-

import concurrent.futures as cf
from time import time
from itertools import repeat, chain 


def _findmatch(nmin, nmax, number):
    '''Function to find the occurence of number in range nmin to nmax and return
       the found occurences in a list.'''
    start = time()
    match=[]
    for n in range(nmin, nmax):
        if number in str(n):
            match.append(n)
    end = time() - start
    #print("\n def _findmatch {0:<10} {1:<10} {2:<3} found {3:8} in {4:.4f}sec".
    #      format(nmin, nmax, number, len(match),end))
    return match

def _concurrent(nmax, number, workers, num_of_chunks):
    '''Function that utilises concurrent.futures.ProcessPoolExecutor.map to
       find the occurrences of a given number in a number range in a concurrent
       manner.'''
    # 1. Local variables
    start = time()
    chunksize = nmax // num_of_chunks
    #2. Parallelization
    with cf.ProcessPoolExecutor(max_workers=workers) as executor:
        # 2.1. Discretise workload and submit to worker pool
        cstart = (chunksize * i for i in range(num_of_chunks))
        cstop = (chunksize * i if i != num_of_chunks else nmax
                 for i in range(1, num_of_chunks + 1))
        futures = executor.map(_findmatch, cstart, cstop, repeat(number))
    end = time() - start
    print('\n within statement of def _concurrent_map(nmax, number, workers, num_of_chunks):')
    print("found in {0:.4f}sec".format(end))
    return list(chain.from_iterable(futures)) #Return an unordered result list
    #return sorted(chain.from_iterable(futures)) #Return an ordered result list

if __name__ == '__main__':
    nmax = int(1E8) # Number range maximum.
    number = str(5) # Number to be found in number range.
    workers = 6     # Pool of workers
    chunks_vs_workers = 30 # A factor of =>14 can provide optimum performance 
    num_of_chunks = chunks_vs_workers * workers

    start = time()
    found = _concurrent(nmax, number, workers, num_of_chunks)
    end = time() - start
    print('\n main')
    print('nmax={}, workers={}, num_of_chunks={}'.format(
          nmax, workers, num_of_chunks))
    #print('found = ', found)
    print("found {0} in {1:.4f}sec".format(len(found),end))    

Улучшенный код .submit().
Этот код аналогичен коду .map, за исключением замены метода _concurrent следующим:

def _concurrent(nmax, number, workers, num_of_chunks):
    '''Function that utilises concurrent.futures.ProcessPoolExecutor.submit to
       find the occurrences of a given number in a number range in a concurrent
       manner.'''
    # 1. Local variables
    start = time()
    chunksize = nmax // num_of_chunks
    futures = []
    #2. Parallelization
    with cf.ProcessPoolExecutor(max_workers=workers) as executor:
        # 2.1. Discretise workload and submit to worker pool
        for i in range(num_of_chunks):
            cstart = chunksize * i
            cstop = chunksize * (i + 1) if i != num_of_chunks - 1 else nmax
            futures.append(executor.submit(_findmatch, cstart, cstop, number))
    end = time() - start
    print('\n within statement of def _concurrent_submit(nmax, number, workers, num_of_chunks):')
    print("found in {0:.4f}sec".format(end))
    return list(chain.from_iterable(f.result() for f in cf.as_completed(
        futures))) #Return an unordered list
    #return list(chain.from_iterable(f.result() for f in cf.as_completed(
    #    futures))) #Return an ordered list

============================================== ========================

person Sun Bear    schedule 07.02.2017

Вы сравниваете яблоки с апельсинами здесь. При использовании map вы производите все числа 1E8 и передаете их рабочим процессам. Это занимает много времени по сравнению с реальным выполнением. При использовании submit вы просто создаете 6 наборов параметров, которые передаются.

Если вы измените map, чтобы он работал по тому же принципу, вы получите числа, близкие друг к другу:

def _findmatch(nmin, nmax, number):
    '''Function to find the occurrence of number in range nmin to nmax and return
       the found occurrences in a list.'''
    print('\n def _findmatch', nmin, nmax, number)
    start = time()
    match=[]
    for n in range(nmin, nmax):
        if number in str(n):
            match.append(n)
    end = time() - start
    print("found {0} in {1:.4f}sec".format(len(match),end))
    return match

def _concurrent_map(nmax, number, workers):
    '''Function that utilises concurrent.futures.ProcessPoolExecutor.map to
       find the occurrences of a given number in a number range in a parallelised
       manner.'''
    # 1. Local variables
    start = time()
    chunk = nmax // workers
    futures = []
    found =[]
    #2. Parallelization
    with cf.ProcessPoolExecutor(max_workers=workers) as executor:
        # 2.1. Discretise workload and submit to worker pool
        cstart = (chunk * i for i in range(workers))
        cstop = (chunk * i if i != workers else nmax for i in range(1, workers + 1))
        futures = executor.map(_findmatch, cstart, cstop, itertools.repeat(number))

        # 2.3. Consolidate result as a list and return this list.
        for future in futures:
            for f in future:
                try:
                    found.append(f)
                except:
                    print_exc()
        foundsize = len(found)
        end = time() - start
        print('within statement of def _concurrent(nmax, number):')
        print("found {0} in {1:.4f}sec".format(foundsize, end))
    return found

Вы можете улучшить производительность отправки, используя as_completed правильно. Для данной итерации фьючерсов он вернет итератор, который будет yield фьючерсов в порядке их завершения.

Вы также можете пропустить копирование данных в другой массив и использовать itertools.chain.from_iterable чтобы объединить результаты из фьючерсов в одну итерацию:

import concurrent.futures as cf
import itertools
from time import time
from traceback import print_exc
from itertools import chain

def _findmatch(nmin, nmax, number):
    '''Function to find the occurrence of number in range nmin to nmax and return
       the found occurrences in a list.'''
    print('\n def _findmatch', nmin, nmax, number)
    start = time()
    match=[]
    for n in range(nmin, nmax):
        if number in str(n):
            match.append(n)
    end = time() - start
    print("found {0} in {1:.4f}sec".format(len(match),end))
    return match

def _concurrent_map(nmax, number, workers):
    '''Function that utilises concurrent.futures.ProcessPoolExecutor.map to
       find the occurrences of a given number in a number range in a parallelised
       manner.'''
    # 1. Local variables
    chunk = nmax // workers
    futures = []
    found =[]
    #2. Parallelization
    with cf.ProcessPoolExecutor(max_workers=workers) as executor:
        # 2.1. Discretise workload and submit to worker pool
        for i in range(workers):
            cstart = chunk * i
            cstop = chunk * (i + 1) if i != workers - 1 else nmax
            futures.append(executor.submit(_findmatch, cstart, cstop, number))

    return chain.from_iterable(f.result() for f in cf.as_completed(futures))

if __name__ == '__main__':
    nmax = int(1E8) # Number range maximum.
    number = str(5) # Number to be found in number range.
    workers = 6     # Pool of workers

    start = time()
    a = _concurrent_map(nmax, number, workers)
    end = time() - start
    print('\n main')
    print('workers = ', workers)
    print("found {0} in {1:.4f}sec".format(sum(1 for x in a),end))
person niemmi    schedule 07.02.2017
comment
Я только что изучил ваше .map() решение. Вау... то, как вы переписали cstart и cstop, чтобы применить их к _findmatch() и .map(), гениально. Я не думал, что смогу сделать это таким образом. 1-й раз, используя .map(). Вот почему _findmatch в коде .map() был написан иначе, чем в коде .submit() и управляющем коде, и это привело к сравнению яблока с апельсином. ;) Я попытался включить размер фрагмента в .map(), но обнаружил, что это снижает производительность. Чем больше chunksize, тем медленнее выполняется код .map. Можете ли вы помочь мне понять, почему это так? - person Sun Bear; 07.02.2017
comment
@SunBear Если вы использовали мою версию карты, должно быть простое объяснение. Допустим, у вас на машине 2 ядра, а это значит, что при правильном распараллеливании работы ее можно выполнить вдвое быстрее. Теперь реализация карты разбивает работу на 6 частей. Допустим, вы определяете chunksize=5 один из рабочих получает 5 из 6 частей, в результате чего 5/6 работы обрабатывается на одном из ядер. В общем, использование большего размера фрагмента имеет смысл, но только в том случае, если это позволяет равномерно распределить работу между работниками. Попробуйте уменьшить размер фрагмента с помощью исходного submit, вы должны увидеть, что он замедляется. - person niemmi; 07.02.2017
comment
Я следовал вашим рассуждениям до '5/6 работы, выполняемой на одном из ядер. ' Что происходит, когда размер фрагмента = 10? Означает ли это, что все 6 входят в 1 рабочего, а другие рабочие простаивают? Что означает дополнительный размер фрагмента? Простите меня, я немного медлительный здесь. Между прочим, я нашел кое-что интересное, когда выяснял, как ваш размер фрагмента и размер фрагмента .map() вместе влияют на скорость вычислений. Смотрите мой дополнительный ответ на ваш. Я думаю, что взаимодействие вызывает количество фрагментов / количество рабочих ‹‹ 1, тем самым переходя в левую часть графика, то есть увеличивая время вычислений. - person Sun Bear; 07.02.2017
comment
Я сравнил коды .submit(). Используя 6 рабочих и из 5 запусков, среднее время вычислений из вашего кода примерно в 1,4 раза быстрее, чем среднее время вычислений из кода .submit(), опубликованного в моем вопросе. Среднее время из вашего кода составляет 6,41 секунды. Вау.. это потрясающе! Сравнивая код .submit() и .code .map() с моим предложенным изменением, код .submit() все еще быстрее. - person Sun Bear; 08.02.2017
comment
@SunBear Обратите внимание, что в моем решении числа, полученные из iterable, не упорядочены. Экономия времени достигается за счет того, что числа не копируются в список в основном процессе и не нужно ждать завершения фрагмента, содержащего числа 5xxxxxxx, прежде чем использовать результаты из следующих. Я постараюсь расширить свой ответ на основе комментариев и вашего ответа позже. - person niemmi; 08.02.2017