Spec-Zone.ru › NumPy 1.18

Использование Python в качестве связующего языка

Многие говорят, что Python — это фантастический связующий язык. Надеюсь, эта глава убедит вас в этом. Первыми пользователями Python в научных исследованиях, как правило, были люди, которые использовали его для объединения больших приложений, работающих на суперкомпьютерах. Не только программирование на Python было намного приятнее, чем на скриптах оболочки или Perl, но и возможность легко расширять Python делала относительно простым создание новых классов и типов, специально адаптированных к решаемым проблемам. В результате взаимодействия этих ранних разработчиков появился Numeric — объект, подобный массиву, который можно было использовать для передачи данных между этими приложениями.

По мере созревания и развития Numeric в NumPy люди смогли писать больше кода непосредственно в NumPy. Часто этот код достаточно быстр для использования в производстве, но иногда всё же возникает необходимость доступа к скомпилированному коду. Либо для достижения последней доли эффективности алгоритма, либо для облегчения доступа к широко распространённому коду, написанному на C/C++ или Fortran.

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

Вызов других скомпилированных библиотек из Python

Хотя Python — отличный язык, и программирование на нем доставляет удовольствие, его динамическая природа приводит к накладным расходам, которые могут привести к тому, что некоторый код (например, вычисления в циклах for) будет выполняться в 10–100 раз медленнее, чем эквивалентный код, написанный на статически скомпилированном языке. Кроме того, это может привести к большему, чем необходимо, использованию памяти, поскольку временные массивы создаются и уничтожаются во время вычислений. Для многих типов вычислительных задач дополнительные замедления и потребление памяти часто нельзя компенсировать (по крайней мере, для критически важных по времени или памяти частей вашего кода). Поэтому одной из самых распространённых потребностей является вызов из кода Python быстрой, машинной кодовой функции (например, скомпилированной с помощью C/C++ или Fortran). Тот факт, что это относительно просто сделать, является одной из причин, почему Python — это превосходный язык высокого уровня для научного и инженерного программирования.

Существует два основных подхода к вызову скомпилированного кода: написание модуля расширения, который затем импортируется в Python с помощью команды импорта, или прямой вызов подпрограммы общей библиотеки из Python с помощью модуля ctypes. Написание модуля расширения — наиболее распространённый метод.

Предупреждение

Вызов кода C из Python может привести к сбою Python, если вы не будете осторожны. Ни один из подходов в этой главе не застрахован от этого. Вам нужно знать что-то о том, как данные обрабатываются как NumPy, так и используемой сторонней библиотекой.

Самописные обертки

Модули расширения обсуждались в Написание модуля расширения. Наиболее простой способ взаимодействия со скомпилированным кодом — написание модуля расширения и создание метода модуля, который вызывает скомпилированный код. Для повышения читабельности ваш метод должен использовать PyArg_ParseTuple для преобразования между объектами Python и типами данных C. Для стандартных типов данных C, вероятно, уже есть встроенный преобразователь. Для других типов вам может потребоваться написать собственный преобразователь и использовать формат "O&", который позволяет указать функцию, которая будет использоваться для преобразования объекта Python во все необходимые структуры C.

После преобразования в соответствующие структуры C и типы данных C следующим шагом в обёртке является вызов подлежащей функции. Это просто, если подлежащая функция написана на C или C++. Однако для вызова кода Fortran вам необходимо ознакомиться с тем, как подпрограммы Fortran вызываются из C/C++ с использованием вашего компилятора и платформы. Это может несколько отличаться на разных платформах и компиляторах (вот почему f2py значительно упрощает работу с кодом Fortran), но, как правило, включает модификацию имени с помощью нижнего подчеркивания и тот факт, что все переменные передаются по ссылке (т.е. все аргументы являются указателями).

Преимущества самописных обёрток заключаются в том, что вы имеете полный контроль над тем, как используется и вызывается библиотека C, что может привести к надёжному и эффективному интерфейсу с минимальными накладными расходами. Недостатком является то, что вам нужно писать, отлаживать и поддерживать код C, хотя большая его часть может быть адаптирована с использованием проверенного временем приёма «вырезать-вставить-и-изменить» из других модулей расширения. Поскольку процедура вызова дополнительного кода C довольно регламентирована, были разработаны процедуры генерации кода, чтобы упростить этот процесс. Один из таких методов генерации кода поставляется с NumPy и позволяет легко интегрировать код Fortran и (простой) C. Этот пакет, f2py, будет кратко рассмотрен в следующем разделе.

f2py

F2py позволяет автоматически создавать модуль расширения, взаимодействующий с подпрограммами в коде Fortran 77/90/95. Он обладает возможностью парсинга кода Fortran 77/90/95 и автоматической генерации сигнатур Python для встречающихся подпрограмм, или вы можете направить, как подпрограмма взаимодействует с Python, создавая файл определения интерфейса (или модифицируя сгенерированный f2py).

Создание исходного кода для базового модуля расширения

Наверное, самый простой способ познакомится с f2py — это предложить простой пример. Вот одна из подпрограмм, содержащихся в файле с именем add.f:

C
      SUBROUTINE ZADD(A,B,C,N)
C
      DOUBLE COMPLEX A(*)
      DOUBLE COMPLEX B(*)
      DOUBLE COMPLEX C(*)
      INTEGER N
      DO 20 J = 1, N
         C(J) = A(J)+B(J)
 20   CONTINUE
      END

Эта подпрограмма просто складывает элементы в двух смежных массивах и помещает результат в третий. Память для всех трёх массивов должна быть предоставлена вызывающей подпрограммой. Очень базовый интерфейс к этой подпрограмме может быть автоматически сгенерирован f2py:

f2py -m add add.f

Вы должны иметь возможность запустить эту команду, предполагая, что ваш путь поиска настроен правильно. Эта команда создаст модуль расширения addmodule.c в текущем каталоге. Этот модуль расширения теперь можно скомпилировать и использовать из Python, как и любой другой модуль расширения.

Создание скомпилированного модуля расширения

Вы также можете заставить f2py скомпилировать add.f и также скомпилировать сгенерированный модуль расширения, оставив только файл расширения общей библиотеки, который можно импортировать из Python:

f2py -c -m add add.f

Эта команда оставит файл add.{ext} в текущем каталоге (где {ext} — соответствующее расширение для модуля расширения Python на вашей платформе — pyd и т.д.). Этот модуль затем можно импортировать из Python. Он будет содержать метод для каждой подпрограммы в add (zadd, cadd, dadd, sadd). Строка документации каждого метода содержит информацию о том, как можно вызвать метод модуля:

>>> import add
>>> print add.zadd.__doc__
zadd - Function signature:
  zadd(a,b,c,n)
Required arguments:
  a : input rank-1 array('D') with bounds (*)
  b : input rank-1 array('D') with bounds (*)
  c : input rank-1 array('D') with bounds (*)
  n : input int

Улучшение базового интерфейса

По умолчанию интерфейс представляет собой очень буквальный перевод кода Fortran в Python. Аргументы массива Fortran теперь должны быть массивами NumPy, а целочисленный аргумент — целым числом. Интерфейс будет пытаться преобразовать все аргументы в их необходимые типы (и формы) и выдавать ошибку, если это не удастся. Однако, поскольку он ничего не знает о семантике аргументов (таких, что C является выводом, и n на самом деле должен соответствовать размерам массива), можно злоупотреблять этой функцией таким образом, что это может привести к сбою Python. Например:

>>> add.zadd([1,2,3], [1,2], [3,4], 1000)

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

Для улучшения интерфейса необходимо предоставить директивы. Это достигается путём создания файла определения интерфейса. Обычно лучше всего начинать с файла интерфейса, который может создать f2py (где он получает своё поведение по умолчанию). Чтобы заставить f2py сгенерировать файл интерфейса, используйте опцию -h:

f2py -h add.pyf -m add add.f

Эта команда оставит файл add.pyf в текущем каталоге. Часть этого файла, соответствующая zadd, следующая:

subroutine zadd(a,b,c,n) ! in :add:add.f
   double complex dimension(*) :: a
   double complex dimension(*) :: b
   double complex dimension(*) :: c
   integer :: n
end subroutine zadd

Путем размещения директив intent и проверки кода интерфейс можно значительно улучшить, пока модуль Python не станет более простым в использовании и надёжным.

subroutine zadd(a,b,c,n) ! in :add:add.f
   double complex dimension(n) :: a
   double complex dimension(n) :: b
   double complex intent(out),dimension(n) :: c
   integer intent(hide),depend(a) :: n=len(a)
end subroutine zadd

Директива intent(out) используется для указания f2py, что c — переменная вывода и должна быть создана интерфейсом перед передачей в базовый код. Директива intent(hide) указывает f2py не разрешать пользователю задавать переменную n, а вместо этого получать её из размера a. Директива depend( a ) необходима для указания f2py, что значение n зависит от входного значения a (чтобы он не пытался создать переменную n до создания переменной a).

После изменения add.pyf новый файл модуля Python можно сгенерировать, скомпилировав как add.f95, так и add.pyf:

f2py -c add.pyf add.f95

Новый интерфейс имеет строку документации:

>>> import add
>>> print add.zadd.__doc__
zadd - Function signature:
  c = zadd(a,b)
Required arguments:
  a : input rank-1 array('D') with bounds (n)
  b : input rank-1 array('D') with bounds (n)
Return objects:
  c : rank-1 array('D') with bounds (n)

Теперь функция может вызываться намного надёжнее:

>>> add.zadd([1,2,3],[4,5,6])
array([ 5.+0.j,  7.+0.j,  9.+0.j])

Обратите внимание на автоматическое преобразование в правильный формат, которое произошло.

Вставка директив в исходный код Fortran

Хороший интерфейс также можно сгенерировать автоматически, поместив директивы переменных в виде специальных комментариев в исходный код Fortran. Таким образом, если я изменю исходный код, чтобы он содержал:

C
      SUBROUTINE ZADD(A,B,C,N)
C
CF2PY INTENT(OUT) :: C
CF2PY INTENT(HIDE) :: N
CF2PY DOUBLE COMPLEX :: A(N)
CF2PY DOUBLE COMPLEX :: B(N)
CF2PY DOUBLE COMPLEX :: C(N)
      DOUBLE COMPLEX A(*)
      DOUBLE COMPLEX B(*)
      DOUBLE COMPLEX C(*)
      INTEGER N
      DO 20 J = 1, N
         C(J) = A(J) + B(J)
 20   CONTINUE
      END

Тогда я могу скомпилировать модуль расширения с помощью:

f2py -c -m add add.f

Результат signature для функции add.zadd точно такой же, как и ранее созданный. Если исходный код содержал A(N) вместо A(*) и так далее, с B и C, то я мог бы получить (почти) тот же интерфейс, просто поместив комментарий INTENT(OUT) :: C в исходный код. Единственное отличие состоит в том, что N будет необязательным вводом, который будет по умолчанию равен длине A.

Пример фильтрации

Для сравнения с другими методами, которые будут обсуждаться. Вот ещё один пример функции, которая фильтрует двумерный массив чисел с двойной точностью с помощью фиксированного фильтра усреднения. Преимущество использования Fortran для индексирования многомерных массивов должно быть очевидным из этого примера.

      SUBROUTINE DFILTER2D(A,B,M,N)
C
      DOUBLE PRECISION A(M,N)
      DOUBLE PRECISION B(M,N)
      INTEGER N, M
CF2PY INTENT(OUT) :: B
CF2PY INTENT(HIDE) :: N
CF2PY INTENT(HIDE) :: M
      DO 20 I = 2,M-1
         DO 40 J=2,N-1
            B(I,J) = A(I,J) +
     $           (A(I-1,J)+A(I+1,J) +
     $            A(I,J-1)+A(I,J+1) )*0.5D0 +
     $           (A(I-1,J-1) + A(I-1,J+1) +
     $            A(I+1,J-1) + A(I+1,J+1))*0.25D0
 40      CONTINUE
 20   CONTINUE
      END

Этот код можно скомпилировать и связать в модуль расширения с именем filter с помощью:

f2py -c -m filter filter.f

Это создаст модуль расширения filter.so в текущем каталоге с методом dfilter2d, который возвращает отфильтрованную версию входных данных.

Вызов f2py из Python

Программа f2py написана на Python и может быть запущена изнутри вашего кода для компиляции кода Fortran во время выполнения, как показано ниже:

from numpy import f2py
with open("add.f") as sourcefile:
    sourcecode = sourcefile.read()
f2py.compile(sourcecode, modulename='add')
import add

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

Автоматическое создание модулей расширения

Если вы хотите распространить свой модуль расширения f2py, то вам нужно только включить файл .pyf и код Fortran. Расширения distutils в NumPy позволяют вам определить модуль расширения полностью в терминах этого файла интерфейса. Допустимый файл setup.py, позволяющий распространять модуль add.f (как часть пакета f2py_examples, так что он будет загружаться как f2py_examples.add), выглядит так:

def configuration(parent_package='', top_path=None)
    from numpy.distutils.misc_util import Configuration
    config = Configuration('f2py_examples',parent_package, top_path)
    config.add_extension('add', sources=['add.pyf','add.f'])
    return config

if __name__ == '__main__':
    from numpy.distutils.core import setup
    setup(**configuration(top_path='').todict())

Установка нового пакета проста и выполняется с помощью:

pip install .

предполагая, что у вас есть соответствующие разрешения на запись в основной каталог site-packages для используемой версии Python. Для работы результирующего пакета необходимо создать файл с именем __init__.py (в той же директории, что и add.pyf). Обратите внимание, что модуль расширения определён полностью в терминах файлов add.pyf и add.f. Преобразование файла .pyf в файл .c обрабатывается numpy.disutils.

Заключение

Файл определения интерфейса (.pyf) позволяет точно настроить интерфейс между Python и Fortran. Документация по f2py доступна в каталоге numpy/f2py/docs, где установлен NumPy на вашей системе (обычно в site-packages). Более подробная информация об использовании f2py (включая способ обертывания кода C) доступна на сайте https://scipy-cookbook.readthedocs.io в разделе «Взаимодействие с другими языками».

Метод f2py для связывания скомпилированного кода является в настоящее время наиболее сложным и интегрированным подходом. Он позволяет чётко разделить Python с скомпилированным кодом, сохраняя при этом возможность отдельного распространения модуля расширения. Единственный недостаток заключается в том, что для установки кода пользователю требуется компилятор Fortran. Однако, с существованием бесплатных компиляторов g77, gfortran и g95, а также высококачественных коммерческих компиляторов, это ограничение не является серьёзным. По моему мнению, Fortran по-прежнему является самым простым способом написания быстрого и понятного кода для научных вычислений. Он обрабатывает комплексные числа и многомерную индексацию наиболее простым способом. Однако имейте в виду, что некоторые компиляторы Fortran не смогут оптимизировать код так же хорошо, как хорошо написанный код C.

Cython

Cython — это компилятор для диалекта Python, который добавляет (по желанию) статическую типизацию для повышения скорости и позволяет смешивать код C или C++ в ваши модули. Он генерирует расширения на C или C++, которые могут быть скомпилированы и импортированы в код Python.

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

Обратите внимание, что Cython — это только генератор модулей расширения. В отличие от f2py, он не включает автоматических средств для компиляции и компоновки модуля расширения (что необходимо делать обычным способом). Он предоставляет модифицированный класс distutils под названием build_ext, который позволяет вам создать модуль расширения из исходного кода .pyx. Таким образом, вы можете написать в файле setup.py:

from Cython.Distutils import build_ext
from distutils.extension import Extension
from distutils.core import setup
import numpy

setup(name='mine', description='Nothing',
      ext_modules=[Extension('filter', ['filter.pyx'],
                             include_dirs=[numpy.get_include()])],
      cmdclass = {'build_ext':build_ext})

Добавление каталога заголовков NumPy, конечно же, необходимо только в том случае, если вы используете массивы NumPy в модуле расширения (для чего, собственно, мы и используем Cython). Расширения distutils в NumPy также включают поддержку автоматического создания модуля расширения и его компоновки из файла .pyx. Это работает так, что если у пользователя нет Cython, то он ищет файл с тем же именем, но с расширением .c, которое он затем использует вместо попытки повторно сгенерировать файл .c.

Если вы просто используете Cython для компиляции стандартного модуля Python, то вы получите модуль расширения на C, который, как правило, работает немного быстрее, чем эквивалентный модуль Python. Дальнейшее увеличение скорости можно получить, используя ключевое слово cdef для статического определения переменных C.

Давайте рассмотрим два примера, которые мы видели ранее, чтобы увидеть, как их можно реализовать с помощью Cython. Эти примеры были скомпилированы в модули расширения с помощью Cython 0.21.1.

Комплексное сложение в Cython

Вот часть модуля Cython с именем add.pyx, который реализует функции комплексного сложения, которые мы ранее реализовывали с помощью f2py:

cimport cython
cimport numpy as np
import numpy as np

# We need to initialize NumPy.
np.import_array()

#@cython.boundscheck(False)
def zadd(in1, in2):
    cdef double complex[:] a = in1.ravel()
    cdef double complex[:] b = in2.ravel()

    out = np.empty(a.shape[0], np.complex64)
    cdef double complex[:] c = out.ravel()

    for i in range(c.shape[0]):
        c[i].real = a[i].real + b[i].real
        c[i].imag = a[i].imag + b[i].imag

    return out

Этот модуль демонстрирует использование оператора cimport для загрузки определений из заголовка numpy.pxd, входящего в поставку Cython. Похоже, что NumPy импортируется дважды; cimport делает доступным только C-API NumPy, в то время как обычный import вызывает импорт в стиле Python во время выполнения и позволяет обращаться к знакомому Python-API NumPy.

Пример также демонстрирует «типизированные представления памяти» Cython, которые являются аналогами массивов NumPy на уровне C, в том смысле, что они представляют собой массивы с формой и шагом, которые знают свои собственные пределы (в отличие от массива C, адресуемого через простой указатель). Синтаксис double complex[:] обозначает одномерный массив (вектор) double с произвольными шагами. Contiguous массив целых чисел будет int[::1], а матрица float — float[:, :].

Здесь прокомментирована декоратор cython.boundscheck, который включает или отключает проверку границ для доступа к представлениям памяти на функцию. Мы можем использовать это, чтобы ещё больше ускорить наш код, за счёт безопасности (или ручной проверки перед входом в цикл).

Помимо синтаксиса представления, функция сразу понятна программисту Python. Статическая типизация переменной i подразумевается неявно. Вместо синтаксиса представления мы также могли бы использовать специальный синтаксис массивов NumPy Cython, но синтаксис представления предпочтительнее.

Фильтр изображения в Cython

Двухмерный пример, созданный с помощью Fortran, столь же легко реализуется в Cython:

cimport numpy as np
import numpy as np

np.import_array()

def filter(img):
    cdef double[:, :] a = np.asarray(img, dtype=np.double)
    out = np.zeros(img.shape, dtype=np.double)
    cdef double[:, ::1] b = out

    cdef np.npy_intp i, j

    for i in range(1, a.shape[0] - 1):
        for j in range(1, a.shape[1] - 1):
            b[i, j] = (a[i, j]
                       + .5 * (  a[i-1, j] + a[i+1, j]
                               + a[i, j-1] + a[i, j+1])
                       + .25 * (  a[i-1, j-1] + a[i-1, j+1]
                                + a[i+1, j-1] + a[i+1, j+1]))

    return out

Этот двухмерный фильтр усреднения работает быстро, потому что цикл находится в C, а вычисления указателей выполняются только по мере необходимости. Если код выше скомпилирован как модуль image, то изображение 2D, img, может быть отфильтровано с помощью этого кода очень быстро, используя:

import image
out = image.filter(img)

Что касается кода, следует отметить два момента: во-первых, невозможно вернуть представление памяти в Python. Вместо этого сначала создаётся массив NumPy out, а затем для вычислений используется представление b этого массива. Во-вторых, представление b типизировано double[:, ::1]. Это означает двухмерный массив со смежными строками, т.е. порядок матрицы C. Явное указание порядка может ускорить некоторые алгоритмы, так как они могут пропустить вычисления шага.

Заключение

Cython является механизмом расширения для ряда научных библиотек Python, включая Scipy, Pandas, SAGE, scikit-image и scikit-learn, а также библиотеки обработки XML LXML. Язык и компилятор хорошо поддерживаются.

Есть несколько недостатков использования Cython:

  1. При написании пользовательских алгоритмов, а иногда и при обертывании существующих библиотек C, требуется некоторое знакомство с C. В частности, при использовании управления памятью C (malloc и аналогичные) легко допустить утечку памяти. Однако, просто компиляция модуля Python, переименованного в .pyx, уже может ускорить его, а добавление нескольких объявлений типов может значительно ускорить некоторый код.
  2. Легко потерять чёткое разделение между Python и C, что затрудняет повторное использование кода C для других проектов, не связанных с Python.
  3. Код C, генерируемый Cython, трудно читать и модифицировать (и, как правило, компилируется с раздражающими, но безобидными предупреждениями).

Одним из больших преимуществ модулей расширения, сгенерированных с помощью Cython, является простота их распространения. В заключение, Cython — это очень мощный инструмент для быстрого соединения с кодом C или генерации модуля расширения, и его не следует игнорировать. Он особенно полезен для людей, которые не могут или не хотят писать код на C или Fortran.

ctypes

Ctypes — это модуль расширения Python, включённый в стандартную библиотеку, который позволяет напрямую вызывать произвольную функцию в динамической библиотеке из Python. Этот подход позволяет взаимодействовать с кодом C напрямую из Python. Это открывает огромное количество библиотек для использования из Python. Однако недостаток заключается в том, что ошибки программирования могут очень легко привести к неприятным сбоям программы (точно так же, как это может произойти в C), потому что проверка типов и границ параметров почти отсутствует. Это особенно верно, когда данные массива передаются как указатель на сырое местоположение памяти. Тогда ответственность лежит на вас, чтобы подпрограмма не обращалась к памяти за пределами фактической области массива. Но, если вы не против немного рисковать, ctypes может быть эффективным инструментом для быстрого использования большой динамической библиотеки (или написания расширенных функций в вашей собственной динамической библиотеке).

Поскольку подход ctypes обеспечивает прямой интерфейс к скомпилированному коду, он не всегда устойчив к ошибкам пользователей. Надежное использование модуля ctypes, как правило, включает дополнительный слой кода Python для проверки типов данных и границ массивов объектов, передаваемых в подпрограмму. Этот дополнительный слой проверки (не говоря уже о преобразовании объектов ctypes в типы данных C, которое выполняет сам ctypes), сделает интерфейс медленнее, чем интерфейс написанного вручную модуля расширения. Однако это издержки должны быть незначительными, если вызываемая функция C выполняет значительную работу. Если вы — отличный программист Python с плохими навыками программирования на C, ctypes — это простой способ написать полезный интерфейс к (динамической) библиотеке скомпилированного кода.

Для использования ctypes необходимо:

  1. Иметь динамическую библиотеку.
  2. Загрузить динамическую библиотеку.
  3. Преобразовать объекты Python в аргументы, понимаемые ctypes.
  4. Вызвать функцию из библиотеки с аргументами ctypes.

Использование динамической библиотеки

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

  • Динамическая библиотека должна быть скомпилирована особым образом ( например, с использованием флага -shared с gcc).
  • На некоторых платформах (например, Windows), динамическая библиотека требует файла .def, в котором указаны экспортируемые функции. Например, файл mylib.def может содержать:

    LIBRARY mylib.dll
    EXPORTS
    cool_function1
    cool_function2
    

    В качестве альтернативы, вы можете использовать спецификатор класса хранения __declspec(dllexport) в определении функции C, чтобы избежать необходимости в этом файле .def.

В Python distutils нет стандартного способа создания стандартной разделяемой библиотеки (модуль расширения — это «специальная» разделяемая библиотека, которую понимает Python) в кроссплатформенном режиме. Таким образом, существенным недостатком ctypes на момент написания этой книги является то, что сложно обеспечить кроссплатформенное распространение расширения Python, использующего ctypes и включающего собственный код, который должен быть скомпилирован как разделяемая библиотека на системе пользователя.

Загрузка разделяемой библиотеки

Простой, но надёжный способ загрузки разделяемой библиотеки — получить абсолютный путь и загрузить её, используя объект cdll из ctypes:

lib = ctypes.cdll[<full_path_name>]

Однако в Windows доступ к атрибуту метода cdll загрузит первый DLL с таким именем, найденный в текущей директории или в PATH. Получение абсолютного пути требует небольшого мастерства для кроссплатформенной работы, так как расширение разделяемых библиотек варьируется. Существует утилита ctypes.util.find_library, которая может упростить процесс поиска библиотеки для загрузки, но она не является безошибочной. Сложности добавляет то, что разные платформы используют разные стандартные расширения для разделяемых библиотек (например, .dll — Windows, .so — Linux, .dylib — Mac OS X). Это также необходимо учитывать, если вы используете ctypes для оборачивания кода, который должен работать на нескольких платформах.

NumPy предоставляет удобную функцию под названием ctypeslib.load_library (имя, путь). Эта функция принимает имя разделяемой библиотеки (включая любой префикс, например, «lib», но исключая расширение) и путь, где может быть расположена разделяемая библиотека. Она возвращает объект библиотеки ctypes или генерирует исключение OSError, если библиотека не найдена, или исключение ImportError, если модуль ctypes недоступен. (Пользователи Windows: объект библиотеки ctypes, загруженный с помощью load_library, всегда загружается, предполагая конвенцию вызова cdecl. См. документацию ctypes по темам ctypes.windll и/или ctypes.oledll для способов загрузки библиотек с другими конвенциями вызова).

Функции в разделяемой библиотеке доступны как атрибуты объекта библиотеки ctypes (возвращаемого из ctypeslib.load_library) или как элементы с помощью синтаксиса lib['func_name']. Последний метод получения имени функции особенно полезен, если имя функции содержит символы, недопустимые в именах переменных Python.

Преобразование аргументов

Целые числа/длинные целые числа, строки и объекты unicode Python автоматически преобразуются в эквивалентные аргументы ctypes по мере необходимости. Объект None также автоматически преобразуется в указатель NULL. Все остальные объекты Python необходимо преобразовать в типы, специфичные для ctypes. Есть два способа обойти это ограничение, которые позволяют ctypes интегрироваться с другими объектами.

  1. Не устанавливайте атрибут argtypes объекта функции и определите метод _as_parameter_ для объекта, который вы хотите передать. Метод _as_parameter_ должен возвращать целое число Python, которое будет передано непосредственно функции.
  2. Установите атрибут argtypes в список, элементы которого содержат объекты с методом класса from_param, который знает, как преобразовать ваш объект в объект, понятный ctypes (целое число/длинное целое число, строка, unicode или объект с атрибутом _as_parameter_).

NumPy использует оба метода, отдавая предпочтение второму методу, потому что он может быть безопаснее. Атрибут ctypes массива ndarray возвращает объект, имеющий атрибут _as_parameter_, который возвращает целое число, представляющее адрес ndarray, к которому он связан. В результате можно напрямую передать этот объект атрибута ctypes функции, ожидающей указатель на данные в вашем ndarray. Вызывающий должен убедиться, что объект ndarray имеет правильный тип, размерность и установлены правильные флаги, чтобы избежать неприятных аварий, если указатели на данные в неподходящие массивы передаются некорректно.

Для реализации второго метода NumPy предоставляет функцию-фабрику классов ndpointer в модуле numpy.ctypeslib. Эта функция-фабрика генерирует подходящий класс, который можно поместить в запись атрибута argtypes функции ctypes. Класс будет содержать метод from_param, который ctypes будет использовать для преобразования любого переданного в функцию ndarray в объект, распознаваемый ctypes. В процессе преобразования будет произведена проверка любых свойств ndarray, указанных пользователем при вызове ndpointer. Проверятся такие аспекты ndarray, как тип данных, количество измерений, форма и/или состояние флагов любого переданного массива. Значение метода from_param — это атрибут ctypes массива, который (поскольку он содержит атрибут _as_parameter_, указывающий на область данных массива) может быть непосредственно использован ctypes.

Атрибут ctypes массива ndarray также снабжён дополнительными атрибутами, которые могут быть удобны при передаче дополнительной информации об массиве в функцию ctypes. Атрибуты data, shape и strides могут предоставить типы, совместимые с ctypes, соответствующие области данных, форме и шагам массива. Атрибут data возвращает c_void_p, представляющий указатель на область данных. Атрибуты shape и strides каждый возвращают массив целых чисел ctypes (или None, представляющий указатель NULL, если массив 0-мерный). Базовый тип данных массива — целое число ctypes такого же размера, как указатель на платформе. Также есть методы data_as({ctype}), shape_as(<base ctype>) и strides_as(<base ctype>). Они возвращают данные как объект ctype по вашему выбору и массивы shape/strides, используя базовый тип по вашему выбору. Для удобства модуль ctypeslib также содержит c_intp как тип данных целых чисел ctypes, размер которого соответствует размеру c_void_p на платформе (его значение равно None, если ctypes не установлен).

Вызов функции

Функция доступна как атрибут или элемент загруженной разделяемой библиотеки. Таким образом, если в ./mylib.so есть функция под названием cool_function1, я могу получить доступ к этой функции либо как:

lib = numpy.ctypeslib.load_library('mylib','.')
func1 = lib.cool_function1  # or equivalently
func1 = lib['cool_function1']

В ctypes значение возврата функции по умолчанию — «int». Это поведение можно изменить, установив атрибут restype функции. Используйте None для restype, если функция не имеет значения возврата («void»):

func1.restype = None

Как обсуждалось ранее, вы также можете установить атрибут argtypes функции, чтобы ctypes проверял типы входных аргументов при вызове функции. Используйте функцию-фабрику ndpointer для создания готового класса для проверки типов данных, формы и флагов для вашей новой функции. Функция ndpointer имеет сигнатуру

ndpointer(dtype=None, ndim=None, shape=None, flags=None)

Ключевые аргументы со значением None не проверяются. Указание ключевого слова обеспечивает проверку этого аспекта ndarray при преобразовании в объект, совместимый с ctypes. Ключевое слово dtype может быть любым объектом, интерпретируемым как объект типа данных. Ключевое слово ndim должно быть целым числом, а ключевое слово shape должно быть целым числом или последовательностью целых чисел. Ключевое слово flags указывает минимальные флаги, необходимые для любого переданного массива. Это может быть указано как строка с перечисленными через запятую требованиями, целое число, указывающее биты требования, объединённые операцией ИЛИ, или объект флагов, возвращаемый атрибутом flags массива с необходимыми требованиями.

Использование класса ndpointer в методе argtypes может значительно повысить безопасность вызова C-функции с помощью ctypes и области данных ndarray. Вы все равно можете обернуть функцию в дополнительную оболочку Python, чтобы сделать её более удобной (скрывая некоторые очевидные аргументы и делая некоторые аргументы аргументами вывода). В этом процессе функция requires в NumPy может быть полезна для возврата нужного типа массива из заданного входного значения.

Полный пример

В этом примере я покажу, как можно реализовать функцию сложения и функцию фильтрации, реализованные ранее другими методами, с помощью ctypes. Сначала C-код, реализующий алгоритмы, содержит функции zadd, dadd, sadd, cadd и dfilter2d. Функция zadd:

/* Add arrays of contiguous data */
typedef struct {double real; double imag;} cdouble;
typedef struct {float real; float imag;} cfloat;
void zadd(cdouble *a, cdouble *b, cdouble *c, long n)
{
    while (n--) {
        c->real = a->real + b->real;
        c->imag = a->imag + b->imag;
        a++; b++; c++;
    }
}

со схожим кодом для cadd, dadd и sadd, которые обрабатывают комплексные типы данных float, double и float соответственно:

void cadd(cfloat *a, cfloat *b, cfloat *c, long n)
{
        while (n--) {
                c->real = a->real + b->real;
                c->imag = a->imag + b->imag;
                a++; b++; c++;
        }
}
void dadd(double *a, double *b, double *c, long n)
{
        while (n--) {
                *c++ = *a++ + *b++;
        }
}
void sadd(float *a, float *b, float *c, long n)
{
        while (n--) {
                *c++ = *a++ + *b++;
        }
}

Файл code.c также содержит функцию dfilter2d:

/*
 * Assumes b is contiguous and has strides that are multiples of
 * sizeof(double)
 */
void
dfilter2d(double *a, double *b, ssize_t *astrides, ssize_t *dims)
{
    ssize_t i, j, M, N, S0, S1;
    ssize_t r, c, rm1, rp1, cp1, cm1;

    M = dims[0]; N = dims[1];
    S0 = astrides[0]/sizeof(double);
    S1 = astrides[1]/sizeof(double);
    for (i = 1; i < M - 1; i++) {
        r = i*S0;
        rp1 = r + S0;
        rm1 = r - S0;
        for (j = 1; j < N - 1; j++) {
            c = j*S1;
            cp1 = j + S1;
            cm1 = j - S1;
            b[i*N + j] = a[r + c] +
                (a[rp1 + c] + a[rm1 + c] +
                 a[r + cp1] + a[r + cm1])*0.5 +
                (a[rp1 + cp1] + a[rp1 + cm1] +
                 a[rm1 + cp1] + a[rm1 + cp1])*0.25;
        }
    }
}

Возможным преимуществом этого кода перед эквивалентным кодом на Fortran является то, что он принимает произвольно шагированные (то есть несмежные массивы) и, возможно, также выполняется быстрее в зависимости от возможностей оптимизации вашего компилятора. Но, очевидно, он сложнее, чем простой код в filter.f. Этот код должен быть скомпилирован в разделяемую библиотеку. На моей Linux-системе это делается с помощью:

gcc -o code.so -shared code.c

Что создаёт разделяемую библиотеку под названием code.so в текущей директории. В Windows не забудьте либо добавить __declspec(dllexport) перед void в строке перед каждой функцией, либо создать файл code.def, который перечисляет имена функций, которые должны экспортироваться.

Должен быть создан подходящий Python-интерфейс к этой разделяемой библиотеке. Для этого создайте файл interface.py с указанными вверху строками:

__all__ = ['add', 'filter2d']

import numpy as np
import os

_path = os.path.dirname('__file__')
lib = np.ctypeslib.load_library('code', _path)
_typedict = {'zadd' : complex, 'sadd' : np.single,
             'cadd' : np.csingle, 'dadd' : float}
for name in _typedict.keys():
    val = getattr(lib, name)
    val.restype = None
    _type = _typedict[name]
    val.argtypes = [np.ctypeslib.ndpointer(_type,
                      flags='aligned, contiguous'),
                    np.ctypeslib.ndpointer(_type,
                      flags='aligned, contiguous'),
                    np.ctypeslib.ndpointer(_type,
                      flags='aligned, contiguous,'\
                            'writeable'),
                    np.ctypeslib.c_intp]

Этот код загружает разделяемую библиотеку под названием code.{ext}, расположенную в той же директории, что и этот файл. Затем он добавляет тип возвращаемого значения void для функций, содержащихся в библиотеке. Он также добавляет проверку аргументов для функций в библиотеке, чтобы ndarray могли передаваться в качестве первых трёх аргументов вместе с целым числом (достаточно большим, чтобы хранить указатель на платформе) в качестве четвёртого аргумента.

Настройка функции фильтрации аналогична и позволяет вызывать функцию фильтрации с аргументами ndarray в качестве первых двух аргументов и указателями на целые числа (достаточно большие для обработки шагов и формы ndarray) в качестве последних двух аргументов:

lib.dfilter2d.restype=None
lib.dfilter2d.argtypes = [np.ctypeslib.ndpointer(float, ndim=2,
                                       flags='aligned'),
                          np.ctypeslib.ndpointer(float, ndim=2,
                                 flags='aligned, contiguous,'\
                                       'writeable'),
                          ctypes.POINTER(np.ctypeslib.c_intp),
                          ctypes.POINTER(np.ctypeslib.c_intp)]

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

def select(dtype):
    if dtype.char in ['?bBhHf']:
        return lib.sadd, single
    elif dtype.char in ['F']:
        return lib.cadd, csingle
    elif dtype.char in ['DG']:
        return lib.zadd, complex
    else:
        return lib.dadd, float
    return func, ntype

Наконец, две функции, которые должны экспортироваться интерфейсом, могут быть записаны просто как:

def add(a, b):
    requires = ['CONTIGUOUS', 'ALIGNED']
    a = np.asanyarray(a)
    func, dtype = select(a.dtype)
    a = np.require(a, dtype, requires)
    b = np.require(b, dtype, requires)
    c = np.empty_like(a)
    func(a,b,c,a.size)
    return c

и:

def filter2d(a):
    a = np.require(a, float, ['ALIGNED'])
    b = np.zeros_like(a)
    lib.dfilter2d(a, b, a.ctypes.strides, a.ctypes.shape)
    return b

Заключение

Использование ctypes — мощный способ подключения Python к произвольному C-коду. Преимущества его использования для расширения Python включают

  • Четкое разделение кода на C и Python

    • нет необходимости изучать новый синтаксис, кроме Python и C
    • позволяет повторно использовать код на C
    • функциональность в общих библиотеках, написанных для других целей, может быть получена с помощью простого обертки Python и поиска библиотеки.
  • лёгкая интеграция с NumPy через атрибут ctypes
  • полная проверка аргументов с помощью фабрики классов ndpointer

К недостаткам относятся

  • Трудно распространять модуль расширения, созданный с помощью ctypes, из-за отсутствия поддержки создания общих библиотек в distutils (но я подозреваю, что со временем это изменится).
  • Необходимо иметь общие библиотеки вашего кода (без статических библиотек).
  • Очень слабая поддержка кода C++ и его различных соглашений вызова библиотек. Вам, вероятно, потребуется обёртка C вокруг кода C++, чтобы использовать его с ctypes (или просто используйте Boost.Python).

Из-за сложности распространения модуля расширения, созданного с помощью ctypes, f2py и Cython по-прежнему являются самыми простыми способами расширения Python для создания пакетов. Однако ctypes в некоторых случаях является полезной альтернативой. Это должно добавить больше функций в ctypes, что должно устранить трудности с расширением Python и распространением расширения с помощью ctypes.

Дополнительные инструменты, которые могут оказаться полезными

Эти инструменты были полезны другим пользователям Python, поэтому они включены здесь. Они обсуждаются отдельно, потому что это либо устаревшие способы работы, которые теперь обрабатываются f2py, Cython или ctypes (SWIG, PyFort), либо потому, что я не знаю о них много (SIP, Boost). Я не добавил ссылки на эти методы, потому что по моему опыту вы можете быстрее найти наиболее релевантную ссылку с помощью Google или другого поискового движка, и любые ссылки, предоставленные здесь, быстро устареют. Не предполагайте, что просто потому, что он включен в этот список, я не считаю, что пакет заслуживает вашего внимания. Я включаю информацию об этих пакетах, потому что многие люди нашли их полезными, и я хотел бы предоставить вам как можно больше вариантов для решения проблемы лёгкой интеграции вашего кода.

SWIG

Simplified Wrapper and Interface Generator (SWIG) — это старый и довольно стабильный метод для обертывания C/C++ библиотек в большое количество других языков. Он не понимает массивы NumPy напрямую, но может быть использован с NumPy с помощью typemap. Есть некоторые примеры typemap в каталоге numpy/tools/swig в numpy вместе с примерным модулем, который использует их. SWIG превосходит при обертывании больших C/C++ библиотек, потому что он может (почти) проанализировать их заголовки и автоматически сгенерировать интерфейс. Технически, вам нужно сгенерировать файл .i, который определяет интерфейс. Однако часто этот файл .i может быть частью самого заголовка. Интерфейс обычно требует некоторой настройки, чтобы быть очень полезным. Эта возможность анализа заголовков C/C++ и автоматического генерирования интерфейса по-прежнему делает SWIG полезным подходом к добавлению функциональности из C/C++ в Python, несмотря на появившиеся другие методы, более нацеленные на Python. SWIG фактически может создавать расширения для нескольких языков, но typemaps обычно должны быть специфичны для языка. Тем не менее, с изменениями в Python-специфических typemaps SWIG можно использовать для интерфейса библиотеки с другими языками, такими как Perl, Tcl и Ruby.

Мой опыт работы с SWIG в целом положительный, так как он относительно прост в использовании и довольно мощный. Раньше я часто им пользовался, прежде чем стал более опытным в написании C-расширений. Однако я столкнулся с трудностями при написании пользовательских интерфейсов с помощью SWIG, потому что это должно быть сделано с использованием понятия typemaps, которые не являются специфичными для Python и написаны в синтаксисе, похожем на C. Поэтому я предпочитаю другие стратегии склеивания и буду пытаться использовать SWIG только для обертывания очень больших C/C++ библиотек. Тем не менее, есть и другие, кто использует SWIG вполне успешно.

SIP

SIP — ещё один инструмент для обертывания C/C++ библиотек, специфичный для Python, и, похоже, имеет очень хорошую поддержку C++. Компания Riverbank Computing разработала SIP для создания Python-связей с библиотекой QT. Для генерации связей необходимо написать файл интерфейса, но файл интерфейса очень похож на файл заголовка C/C++. Хотя SIP не является полным парсером C++, он понимает довольно много синтаксиса C++, а также свои собственные специальные директивы, которые позволяют изменять то, как выполняется Python-связывание. Он также позволяет пользователю определять соответствия между типами Python и C/C++ структурами и классами.

Boost Python

Boost — это хранилище C++ библиотек, а Boost.Python — одна из таких библиотек, которая обеспечивает лаконичный интерфейс для связывания C++ классов и функций с Python. Удивительная часть подхода Boost.Python заключается в том, что он работает полностью в чистом C++ без введения нового синтаксиса. Многие пользователи C++ сообщают, что Boost.Python позволяет соединить лучшее из обоих миров бесшовным образом. Я не использовал Boost.Python, потому что я не являюсь большим пользователем C++, а использование Boost для обертывания простых C-подпрограмм обычно является избыточным. Его основная цель — сделать доступными C++ классы в Python. Итак, если у вас есть набор C++ классов, которые необходимо надёжно интегрировать в Python, подумайте о изучении и использовании Boost.Python.

PyFort

PyFort — это хороший инструмент для обертывания кода Fortran и Fortran-подобного C-кода в Python с поддержкой массивов Numeric. Он был написан Полом Дюбуа, известным учёным-компьютерщиком и первым разработчиком Numeric (сейчас на пенсии). Стоит упомянуть об этом в надежде, что кто-то обновит PyFort для работы с массивами NumPy, которые теперь поддерживают массивы Fortran или C-стиля.

© 2005–2020 NumPy Developers
Licensed under the 3-clause BSD License.
https://numpy.org/doc/1.18/user/c-info.python-as-glue.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API