2025, Nov 13 03:03

Исправляем unsupported PEP 3118 в Numba cfunc: не захватывайте ctypes-указатель

Почему Numba падает с unsupported PEP 3118 при захвате ctypes-указателя в замыкании и как исправить: передавайте arr.ctypes и согласуйте intc. Пример.

Когда вы строите фабрику, возвращающую Numba cfunc для работы с массивами NumPy, может показаться удобным передать «сырой» указатель, полученный через ctypes. На практике это часто заканчивается падением с ошибкой unsupported PEP 3118 format: замыкание захватывает ctypes‑указатель, который Numba не может типизировать в режиме nopython. Исправление проще, чем кажется: при вызове скомпилированной функции передавайте напрямую атрибут .ctypes массива.

Воспроизведение проблемы

Ниже приведён минимальный пример фабрики, которая компилирует C‑функцию без аргументов и внутри вызывает другой cfunc для вычисления суммы массива NumPy. Логика сохранена; изменены лишь имена для удобства чтения.

import numpy as np
from numba import cfunc, carray
from numba.types import intc, CPointer, float64
import ctypes
class CalcBox:
    def __init__(self, buf):
        self.buf = buf
        self.length = buf.size
    # C-тип сигнатуры: double func(double* input, int n)
    c_sig = float64(CPointer(float64), intc)
    c_sig_void = float64()
    @staticmethod
    @cfunc(c_sig)
    def c_sum(ptr, n):
        view = carray(ptr, n)
        return np.sum(view)
    def make_sum(self):
        arr = self.buf
        size = self.length
        compiled = type(self).c_sum
        raw_ptr = arr.ctypes.data_as(ctypes.POINTER(ctypes.c_double))
        @cfunc(self.c_sig_void)
        def thunk():
            return compiled(raw_ptr, size)
        return thunk.address
vec = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float64)
box = CalcBox(vec)
addr = box.make_sum()
C0 = ctypes.CFUNCTYPE(ctypes.c_double)
fn = C0(addr)
print("Sum:", fn())

Такой шаблон нередко падает с трассировкой, где фигурируют unsupported PEP 3118 format и untyped global name, связанный с захваченным указателем. Суть проблемы не в суммировании, а в том, как ctypes‑указатель попадает в замыкание скомпилированной функции.

Почему это ломается

Вложенная скомпилированная функция замыкает объект Python — ctypes‑указатель. Внутри cfunc это превращается в проблему типизации: Numba не распознаёт объект ctypes‑указателя как допустимое nopython‑значение и выдаёт ошибку декодирования, связанную с форматами PEP 3118. Проще говоря, указатель, захваченный замыканием, не может быть протипизирован фронтендом Numba, и имя остаётся нетипизированным.

Есть и контекст, объясняющий сопутствующие ловушки. Замыкания поддерживаются довольно неэффективным способом: захваченные переменные рассматриваются так, будто они фактически константы для сгенерированной функции. Numba может сгенерировать отдельную C‑функцию в своём LLVM‑модуле, сохранив эти значения как глобальные. Изменять захваченную переменную после компиляции — это неопределённое поведение (UB): иногда кажется, что всё работает, но может тихо сломаться, особенно когда оптимизаторы не полностью распространяют константы через указатели. Такой подход также способен увеличивать время компиляции и размер модулей, в зависимости от массива и версии инструментов. Наконец, хотя захват ints или floats часто кажется безобидным, с указателями возникают проблемы, и объект ctypes‑указателя в этой ситуации системой типов не распознаётся.

Решение

Обходной путь прямолинеен: не создавайте явно ctypes‑указатель и не захватывайте его; вместо этого передавайте атрибут .ctypes массива непосредственно в скомпилированную функцию. Так вы избегаете нетипизируемого объекта ctypes внутри замыкания.

import numpy as np
from numba import cfunc, carray
from numba.types import intc, CPointer, float64
import ctypes
class CalcBox:
    def __init__(self, buf):
        self.buf = buf
        self.length = buf.size
    c_sig = float64(CPointer(float64), intc)
    c_sig_void = float64()
    @staticmethod
    @cfunc(c_sig)
    def c_sum(ptr, n):
        view = carray(ptr, n)
        return np.sum(view)
    def make_sum(self):
        arr = self.buf
        size = np.intc(self.length)  # при необходимости приводим к intc
        compiled = type(self).c_sum
        @cfunc(self.c_sig_void)
        def thunk():
            return compiled(arr.ctypes, size)
        return thunk.address
vec = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float64)
box = CalcBox(vec)
addr = box.make_sum()
C0 = ctypes.CFUNCTYPE(ctypes.c_double)
fn = C0(addr)
print("Sum:", fn())

Единственное функциональное изменение — заменить явный ctypes‑указатель на arr.ctypes в месте вызова. Если сигнатура ожидает intc для размера, передавайте np.intc, чтобы соответствовать ей.

Почему это важно

Связывание NumPy, Numba и C‑интерфейсов очень мощно, но именно детали — например, как указатель попадает в скомпилированное замыкание — определяют, получите ли вы чисто вызываемую из C функцию или неясную ошибку типизации. Понимание того, что захваченные значения рассматриваются как константы, помогает избежать неопределённого поведения при их изменении в дальнейшем. Это также формирует ожидания насчёт возможных накладных расходов компиляции и роста размера модулей в отдельных сценариях.

Выводы

Если вам нужна фабрика, возвращающая Numba cfunc, который потребляет массив NumPy, не захватывайте «сырые» указатели ctypes внутри скомпилированного замыкания. Используйте arr.ctypes при вызове скомпилированной функции и следите за соответствием разрядностей целых объявленной сигнатуре — например, передавайте np.intc, когда сигнатура использует intc. Это сохраняет типизируемость в nopython‑режиме, устраняет ошибки неподдерживаемого формата PEP 3118 и снижает риск сюрпризов из‑за того, как понижены замыкания под капотом.