Pull to refresh
1272
МТС
Про жизнь и развитие в IT

Как ускорить Python с помощью C-расширений. Часть 2

Reading time10 min
Views5.5K

Привет, Хабр! Меня зовут Игорь Алимов, я ведущий разработчик группы Python в МТС Digital, и это вторая часть статьи, посвященной тому, как писать быстрый код на Python с использованием C-расширений. Я расскажу о всех нюансах и приведу конкретный пример применения этого метода.

Первую часть статьи читайте здесь.

В прошлый раз мы дошли как раз до примера, с него и начнем.

Как считать sha256?

В Python-коде мы задействовали библиотеку hashlib, но ее использование в нашей C-функции выглядит неправильным. Не для того мы уходили в С-код, чтобы из него снова запускать Python-код. Возможны следующие варианты:

  • Написать свою реализацию sha256 на C.

  • Использовать готовую библиотеку.

Есть мнение, что программисты по своей натуре – люди ленивые, поэтому первый вариант лично у меня вызывает отторжение.

Рассмотрим второй вариант. Какую готовую библиотеку мы можем взять? Любую, которая соответствует нашим потребностям и имеет подходящую лицензию. Если у вас есть мощная видеокарта и библиотека, которая может вычислять хеш суммы при помощи этой карты, – это отличный вариант. 

Еще более производительное решение – аппаратный майнер и библиотека для управления. К сожалению, далеко не у всех есть аппаратный майнер или мощная видеокарта, поэтому воспользуемся скромным расчетом хеш-сумм на CPU. Но это все еще не отвечает на вопрос, какую библиотеку использовать. Если вы работаете в Linux (на что я искренне надеюсь) – откройте консоль и наберите man sha256. Далее я привожу короткую выдержку из того, что появилось у меня на экране:

SHA256_INIT(3)                                         OpenSSL                                        SHA256_INIT(3)

NAME
       SHA1, SHA1_Init, SHA1_Update, SHA1_Final, SHA224, SHA224_Init, SHA224_Update, SHA224_Final, SHA256,
       SHA256_Init, SHA256_Update, SHA256_Final, SHA384, SHA384_Init, SHA384_Update, SHA384_Final, SHA512,
       SHA512_Init, SHA512_Update, SHA512_Final - Secure Hash Algorithm

SYNOPSIS
        #include <openssl/sha.h>
        ...
        int SHA256_Init(SHA256_CTX *c);
        int SHA256_Update(SHA256_CTX *c, const void *data, size_t len);
        int SHA256_Final(unsigned char *md, SHA256_CTX *c);
        unsigned char *SHA256(const unsigned char *d, size_t n,
                              unsigned char *md);

Библиотека OpenSSL умеет вычислять sha256, она есть в любом дистрибутиве Linux и использует в своей работе CPU. При виде прототипов функций появляется предложение о возможной оптимизации функции mining_mining. Мы могли бы один раз обработать строку с начальным значением и наработать от нее контекст. Затем мы сохраним наработанный контекст и для дальнейшего расчета случайного значения используем его копию. С нашей небольшой начальной строкой это не даст заметного увеличения производительности. Но если начальная строка размером несколько мегабайт – это позволит значительно выиграть время.

Парсинг входных аргументов

На Python заголовок функции mining_mining выглядел бы так:

from typing import Optional, Tuple

def mining(init_value: str, printable: str, start: int, end: int, expected: str) -> Optional[Tuple[str, str, int]]:
    """
    Производит поиск hash сумм начинающихся особым образом

    :param init_value:  Начальная строка к которой будет добавляться случайная строка
    :param printable:   Набор символов, используемый для формирования случайной строки
    :param start:       Начальный номер строки
    :param end:         Конечный номер строки
    :param expected:    Ожидаемое начало hash суммы
    :return:            Tuple с тремя значениями: случайная строка, хеш сумма, номер случайной строки    
    """

Для парсинга входных аргументов в C-функции применим следующий код:

static PyObject *
mining_mining(PyObject *self, PyObject *args)
{
    unsigned long long start, end;
    const char *init_value, *printable, *expected;
    size_t len_init_value, len_printable, len_expected;
    if (!PyArg_ParseTuple(args, "s#s#KKs#", &init_value, &len_init_value, &printable,
                          &len_printable, &start, &end, &expected, &len_expected))
        return NULL;
    ...
}

Ключевым элементом здесь является вызов функции PyArg_ParseTuple, мы передаем в нее указатель на Tuple с аргументами args, форматную строку и адреса переменных, которые в случае успеха установит PyArg_ParseTuple. Форматная строка:

  • s# первый аргумент – строка, помещается в переменную init_value, длина этой строки помещается в len_init_value;

  • s# второй аргумент – строка, помещается в переменную printable, длина этой строки в len_printable;

  • K третий аргумент – int, преобразуется к типу unsigned long long и помещается в переменную start;

  • K четвертый аргумент – int, преобразуется к типу unsigned long long и помещается в переменную end;

  • s# пятый аргумент – строка, помещается в переменную expected, длина этой строки помещается в len_expected.

Если при парсинге входных аргументов произойдет ошибка – функция PyArg_ParseTuple вернет 0 и установит соответствующий объект исключения, нам же остается только вернуть NULL. Подробнее о функции PyArg_ParseTuple и форматной строке Parsing arguments and building values можно почитать здесь.

Выделение памяти

В нашей функции mining_mining только одна переменная имеет изменяемый размер и это случайная строка. Размер всех остальных переменных фиксирован и известен в момент компиляции, размещать мы их будем на стеке. Для определения максимального размера случайной строки используется функция get_max_length_value, которая выглядит следующим образом:

static size_t
get_max_length_value(unsigned long long n, size_t len_printable)
{
    return log(n) / log(len_printable) + 2;
}

Максимальный размер строки определяется наибольшим номером строки end и длиной строки с символами, используемых для формирования случайной строки. Численное значение равно логарифму от end по основанию len_printable, значение округляется до целого и к нему прибавляется 2. Первая единица берется из округления, если логарифм равен 8.76 – то для строки потребуется 9 символов, еще один символ необходим для терминирующего '\0'. Строки в C должны заканчиваться нулевым байтом. Функция get_max_length_value используется для внутреннего использования и не имеет отображения в Python.

Выделение памяти под случайную строку:

char *value = PyMem_RawCalloc(1, get_max_length_value(end, len_printable));
if (value == NULL)
    return PyErr_NoMemory();

Используется функция PyAPI_FUNC(void *) PyMem_RawCalloc(size_t nelem, size_t elsize), которая предназначена для выделения памяти под массив с nelem-элементами, каждый из которых имеет размер elsize. Почему она здесь используется? Эта функция производит инициализацию выделенной памяти нулями, которые обязаны быть в конце C-строк. Если при выделении памяти произошла ошибка, то функция PyMem_RawCalloc возвращает NULL. В этом случае мы вызываем функцию PyErr_NoMemory, которая установит объект исключения и вернет NULL, который мы, как признак ошибки, вернем вызывающей функции. Если все прошло хорошо – необходимо не забыть перед выходом из функции сделать

PyMem_RawFree(value);

для того, чтобы освободить занимаемую память.

Как освободить GIL?

Настала пора победить GIL. Глобальная блокировка интерпретатора не нужна C-коду и ее можно смело отпускать. Главное условие: нельзя обращаться к Python объектам, если блокировка отпущена. Последовательность действий такая:

  1. Распарсить входные аргументы и скопировать их в C-переменные.

  2. Отпустить GIL.

  3. Произвести необходимые вычисления.

  4. Захватить GIL.

  5. Сформировать результат в виде Python объекта.

  6. Вернуть результат.

Для отпускания и получения GIL можно использовать следующие макросы:

Py_BEGIN_ALLOW_THREADS
// Здесь могут находится интенсивные вычисления и блокирующие IO операции
Py_END_ALLOW_THREADS

С этими макросами надо соблюдать осторожность, в макросе Py_BEGIN_ALLOW_THREADS содержится открывающая фигурная скобка, а в макросе Py_END_ALLOW_THREADS есть закрывающая скобка. Поэтому макросы должны находится на одном уровне вложенности.

Если это условие не соблюдается, то можно воспользоваться макросами:

PyThreadState *_save;
Py_UNBLOCK_THREADS
// Здесь могут находится интенсивные вычисления и блокирующие IO операции
Py_BLOCK_THREADS

Подробнее о Thread State and the Global Interpreter Lock – здесь.

Продолжение функции mining_mining

Как уже отмечалось выше, мы будем использовать два контекста для вычисления sha256:

  • initial_context, который вычисляется от начальной строки;

  • context, это копия initial_context, в который мы добавляем значение случайной строки, финализируем его, получаем двоичное представление хеш-суммы и затем преобразуем его в текстовое представление.

Другие переменные функции mining_mining:

  • hash – двоичное представление хеш суммы;

  • hexdigest – текстовое (в виде шестнадцатеричных чисел) представление хеш-суммы;

  • value – случайная строка;

  • len_value – текущая длинна случайной строки.

Пояснения к коду:

  1. Цикл расположенный в строках 62-79, обеспечивает перебор значений от start до end.

  2. В строке 64 производится копирование initial_context в context.

  3. В строке 65 вызывается функция get_value, которая формирует случайное значение, значение записывается в переменную value, функция возвращает длину формированной строки.

  4. В строках 66-67 производится наработка контекста от случайной строки и финализация расчета хеш суммы.

  5. Цикл в строках 68-71 преобразует двоичное представление sha256 в текстовое.

  6. В строке 72 производится проверка, что начало хеш суммы соответствует ожиданиям.

Формирование результата

Если начало хеш-суммы соответствует нашим ожиданиям – захватываем GIL и формируем результат:

PyObject *res = Py_BuildValue("ssK", value, hexdigest, start);

Функция Py_BuildValue создает новый tuple, состав, которого определяется форматной строкой ssK. Первые два значения – строки. Value – случайная строка, hexdigest – хеш-сумма, третье значение – номер случайной строки, значение типа unsigned long long преобразуется к типу int. У созданного значения увеличивается счетчик ссылок и в таком виде оно возвращается вышестоящей функции.

Освобождаем память, занимаемую случайной строкой value, PyMem_RawFree(value) и возвращаем результат.

В случае, если не удалось найти хеш-сумму, также захватываем GIL, освобождаем память и возвращаем None. Для этого используем макрос Py_RETURN_NONE, он увеличивает счетчик ссылок на None и возвращает указатель на объект.

Формирование необходимых структур и инициализация модуля

Все функции, которые мы хотим импортировать в Python необходимо собрать в следующую структуру:

static PyMethodDef mining_funcs[] = {
        {"mining", mining_mining, METH_VARARGS, "Search for special kinds of hash"},
        ...
        {NULL, NULL, 0, NULL}
};

Определение структуры PyMethodDef:

struct PyMethodDef {
    const char  *ml_name;   /* The name of the built-in function/method */
    PyCFunction ml_meth;    /* The C function that implements it */
    int         ml_flags;   /* Combination of METH_xxx flags, which mostly
                               describe the args expected by the C func */
    const char  *ml_doc;    /* The __doc__ attribute, or NULL */
};
typedef struct PyMethodDef PyMethodDef;
  • ml_name – название функции, как она будет видна в Python;

  • ml_meth – указатель на C-функцию;

  • ml_flags – тип функции, в данном случае это функция с позиционными аргументами, METH_VARARGS;

  • ml_doc – Документация функции.

{NULL, NULL, 0, NULL} – признак окончания списка.

Структура, описывающая модуль:

static struct PyModuleDef mining_module = {
        PyModuleDef_HEAD_INIT,
        "mining",   /* name of module */
        "Documentation for mining module", /* module documentation, may be NULL */
        -1, /* size module */
        mining_funcs /* List funcs */
};

Функция инициализации модуля:

PyMODINIT_FUNC
PyInit_mining(void)
{
    return PyModule_Create(&mining_module);
}

Надо отменить, что функция инициализации, это единственный non static элемент файла mining.c, то есть эту функцию видно за пределами файла.

Сборка модуля

Для сборки модуля используется файл setup.py:

from setuptools import setup, Extension

module = Extension(
    'mining',
    sources=['mining.c'],
    libraries=['crypto', 'm'],
    extra_compile_args=['-Wall', '-Werror', '-O2']
)

setup(
    name='mining',
    version='0.0.1',
    ext_modules=[module]
)

Сборка осуществляется стандартным классом Extension. Для получения списка библиотек следует использовать команду:

pkg-config --libs --cflags openssl

Ее вывод будет таким: '-lssl -lcrypto'. В процессе сборки выяснилось, что библиотека ssl не требуется, но в функции get_max_length_value мы использовали log и для его работы необходимо подтянуть библиотеку math или, как это принято в C, m.

Аргументы компилятора:

  • -Wall – включить все предупреждения;

  • -Werror – все предупреждения считать ошибками и прерывать компиляцию;

  • -O2 – уровень оптимизации.

Для сборки модуля нужно использовать

python setup.py build

Переменная working, функции для ее установки и сброса

В файле mining.c определена переменная working:

#include <stdbool.h>

static volatile bool working = true;

Это глобальная, в рамках файла mining.c, переменная, которая предназначена для немедленного завершения workers.

Ключевое слово volatile, оно говорит компилятору, что он не должен делать предположений о ее состоянии. Переменная может быть изменена другим потоком.

Используется в двух случаях:

  • Если один из workers нашел хеш сумму с необходимыми свойствами.

  • При преждевременном завершении программы по Ctrl-C.

По умолчанию переменная working равна true и workers могут работать. Для сброса working в состояние false используется функция:

static PyObject *
mining_stop_working(PyObject *self, PyObject *args)
{
    working = false;
    Py_RETURN_NONE;
}

Для того, что бы вернуть переменную working в состояние true, используется функция:

static PyObject *
mining_enable_working(PyObject *self, PyObject *args)
{
    working = true;
    Py_RETURN_NONE;
}

Обе эти функции импортируются в Python и для этого добавляются в массив mining_funcs.

Головной файл blockchain.py

Исходный код blockchain.py в целом соответствует prototype_multi.py с некоторыми изменениями:

  • модуль multiprocessing заменен на ThreadPoolExecutor из модуля concurrent.futures. Нам теперь не страшен GIL.

  • Добавлен обработчик signal_handler, который завершает работу всех workers при завершении программы по Ctrl-C.

  • Не требуется очереди для получения результатов работы workers.

Результаты работы blockchain.py

Результаты работы blockchain.py представлены в файле blockchain.log. Время выполнения – 56 минут 23.663 секунды, средняя производительность 2122.755 kH/s. Это меньше времени исполнения prototype_multi.py на 5 минут 31.347 секунд. Я ожидал большего.

Почему так ? В исходном коде модуля hashlib видно, что производится попытка импорта модуля _hashlib. Если она удачна, то для расчета хеш-сумм используется он.

Модуль _hashlib написан на C и его исходный код – _hashopenssl.c. Он также использует для расчета хеш-сумм библиотеку OpenSSL. Причем перед интенсивными расчетами там тоже отпускается GIL. Для примера смотрим определение функции EVP_update.

Получается, что время в 5 минут мы выиграли исключительно за счет того, что формировали случайные строки в функции get_value на C.

В случае этого конкретного примера логично остановиться на варианте prototype_multi.py.

Что НЕ НУЖНО переписывать с Python на C

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

  • Клиентскую работу с сетью. При сетевых операциях большая часть времени уходит на задержки, ожидание ответа удаленного сервера. Пусть 95% времени занимает ожидание ответа сервера, а 5% – время работы нашего кода. Если мы перепишем код на C и заставим его работать в пять раз быстрее – мы уменьшим в пять раз только 5%. Общая выгода от такого рефакторинга – 4%, с учетом случайности сетевых задержек ее трудно уловить.

  • Работу с базами данными и сетевыми хранилищами. Исключение – когда вы имеете дело с экзотической базой данных, для соединения с которой есть только C-библиотека. Тогда нужно написать binding к этой библиотеке на C, затем выложить его в открытый доступ, чтобы в дальнейшем ни у кого такой проблемы не было

  • Переписывать код, который уже выполняется при помощи C extensions (это как раз наш случай).

Вывод

Комплексное использование языков высокого и низкого уровней в одном проекте позволяет достичь ощутимо более высоких результатов, чем при применении одного только языка высокого уровня, а разработка идет быстрее и дешевле. Python в этом отношении не уникален, практически все промышленные языки программирования в той или иной форме позволяют использовать нативные расширения. Общим знаменателем этого метода были, есть и еще достаточно долго будут C/C++.

А вы применяете в своей работе с Python С-расширения? Или знаете другие способы ускорить Python и побороть GIL? Расскажите о своем опыте в комментариях к статье!

Tags:
Hubs:
+9
Comments4

Articles

Information

Website
www.mts.ru
Registered
Founded
Employees
over 10,000 employees
Location
Россия