Перейти к содержимому

Вызов Python из Mojo

Главный практический довод в пользу Mojo — не скорость сама по себе, а то, что переписывать всё с нуля не нужно. Библиотеки Python остаются доступны.

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

from std.python import Python
def main() raises:
var math = Python.import_module("math")
print("sqrt(2):", math.sqrt(2))
Результат
sqrt(2): 1.4142135623730951

Обратите внимание на raises в сигнатуре: импорт может не удаться, и любой вызов Python способен бросить исключение. Без raises компилятор не пропустит.

Любое значение из Python приходит завёрнутым в PythonObject. С ним можно работать почти как в Python: обращаться по индексу, звать методы, перебирать.

from std.python import Python
def main() raises:
var lst = Python.list(1, 2, 3)
print("длина:", len(lst), "| элемент:", lst[1])
var d = Python.dict()
d["ключ"] = 7
print("словарь:", d)
Результат
длина: 3 | элемент: 2
словарь: {'ключ': 7}

PythonObject — не число и не строка, и большинство функций Mojo его не примут. Преобразование делается через именованный аргумент py=:

from std.python import Python
def main() raises:
var b = Python.import_module("builtins")
var as_int = Int(py=b.int("123"))
var as_float = Float64(py=b.float("2.5"))
var as_str = String(py=b.str("привет"))
var as_bool = Bool(py=b.bool(1))
print(as_int + 1, as_float * 2, as_str, as_bool)
Результат
124 5.0 привет True

Ошибка Python становится обычным исключением Mojo, с сохранённым сообщением:

from std.python import Python
def main() raises:
var math = Python.import_module("math")
try:
var bad = math.sqrt(-1)
except e:
print("исключение Python:", e)
try:
var nope = Python.import_module("no_such_module_xyz")
except e:
print("модуль не найден:", e)
Результат
исключение Python: math domain error
модуль не найден: No module named 'no_such_module_xyz'

Это одна из самых приятных частей интеропа: отдельного механизма разбирательства с ошибками Python учить не нужно.

Теперь то, ради чего эта глава стоит после раздела о производительности.

Один и тот же расчёт — корень из тысячи чисел, — сделанный нативно и через math.sqrt из Python. Замер по методике из главы Как честно мерить скорость, пять прогонов:

СпособНаносекунд на вызов
нативный sqrt из std.math≈ 2
math.sqrt через Python≈ 300

Примерно в полтораста раз дороже. Точнее сказать нельзя: отношение гуляло от 144 до 148 между прогонами, а в отдельных запусках доходило до 210. Порядок устойчив, третья значащая цифра — нет.

И это не про то, что Python медленный: сам math.sqrt написан на C и считает быстро. Платим мы за пересечение границы — упаковку числа в PythonObject, вызов через интерпретатор, распаковку результата.

Правильный приём — пересекать границу редко, но с крупной порцией. Сравним честно, на одном и том же объёме — тысяча чисел из массива NumPy:

СпособВремя
поэлементно, arr[i] в цикле138,8 мкс
одним вызовом, np.sum(arr)2,8 мкс

Разница в пятьдесят раз на одинаковых данных.

А вот что происходит, когда порция растёт:

Элементовnp.sum, мкс
1 0002,8
100 00016,5

Данных в сто раз больше, а времени — всего в шесть раз. Причина видна из чисел: у вызова есть постоянная цена около двух с половиной микросекунд, и она не зависит от объёма. Чем крупнее порция, тем сильнее эта цена размазывается.

Правило простое: граница пересекается порциями, а не поштучно. Именно поэтому NumPy, pandas и подобные библиотеки хорошо сочетаются с Mojo: они и сами устроены как «один вызов — много работы».

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

Складывать нельзя. Вызов Python внутри parallelize приводит к взаимной блокировке: программа не падает с ошибкой, а зависает навсегда.

Проверено: четыре потока, в каждом math.sqrt. Первые два отработали, третий стартовал — и всё, процесс пришлось убивать по таймауту. С двумя потоками не печатается вообще ничего.

Причина — глобальная блокировка интерпретатора (GIL): одновременно исполнять байт-код Python может только один поток. Управлять ею из Mojo нечем: в std.python нет ни одной обёртки над GIL — проверено.

Импортировать можно не только установленные пакеты, но и собственные файлы — достаточно указать каталог:

Пусть рядом с программой лежит каталог scripts с файлом helper.py:

def greet(name):
return "привет, " + name

Тогда из Mojo он доступен так:

from std.python import Python
def use_helper() raises:
Python.add_to_path("scripts")
var helper = Python.import_module("helper")
print(helper.greet("мир"))
Результат
привет, мир

Это самый частый практический сценарий: логика, которую переписывать не хочется, остаётся на Python, а горячая часть переезжает в Mojo. Путь в add_to_path считается от рабочего каталога, из которого запущена программа.

Разбор JSON — там, где Python удобнее; счёт — там, где Mojo быстрее.

measurements.mojo
# Разделение труда: разбор — Python, счёт — Mojo.
#
# JSON парсить руками на Mojo незачем: в Python это одна строка.
# А вот считать по разобранным числам лучше уже своими средствами —
# каждый переход границы стоит сотни наносекунд.
from std.python import Python
from std.math import sqrt
def main() raises:
var json = Python.import_module("json")
var raw = String(
'{"sensor": "t-42", "readings": [21.5, 23.0, 19.8, 24.2, 22.1]}'
)
# Граница пересекается один раз: разбираем всё сразу.
var parsed = json.loads(raw)
var name = String(py=parsed["sensor"])
var py_readings = parsed["readings"]
# Переносим числа в родной список — дальше Python не участвует.
var readings = List[Float64]()
for item in py_readings:
readings.append(Float64(py=item))
print("датчик:", name)
print("замеров:", len(readings))
# Всё остальное считается на Mojo.
var total = 0.0
var lowest = readings[0]
var highest = readings[0]
for value in readings:
total += value
if value < lowest:
lowest = value
if value > highest:
highest = value
var mean = total / Float64(len(readings))
var variance = 0.0
for value in readings:
var d = value - mean
variance += d * d
variance /= Float64(len(readings))
print("минимум:", lowest)
print("максимум:", highest)
print("среднее:", mean)
print("отклонение:", sqrt(variance))
Результат
датчик: t-42
замеров: 5
минимум: 19.8
максимум: 24.2
среднее: 22.119999999999997
отклонение: 1.4743133995185687

Ключевая строка примера — цикл, который переносит числа в List[Float64]. После него Python не участвует вовсе, и вся арифметика идёт на родной скорости.

🎯 Проверь себя

Почему функция, вызывающая Python, обязана быть помечена `raises`?

Потому что любой вызов Python может бросить исключение — импорт не найдёт модуль, функция получит недопустимый аргумент. Mojo требует объявить эту возможность в сигнатуре, иначе не соберёт.

`Int(py_value)` не компилируется. Как получить из `PythonObject` число?

Через именованный аргумент: Int(py=py_value). Так же Float64(py=...), String(py=...), Bool(py=...). Прямое преобразование запрещено намеренно: оно может не удаться, и PythonObject не объявляет трейт Intable.

Вызов `math.sqrt` через Python стоит около 300 нс против 2 нс у нативного. Значит ли это, что Python медленный?

Нет. Сам math.sqrt написан на C и считает быстро. Платим мы за пересечение границы: упаковку аргумента в PythonObject, вызов через интерпретатор, распаковку результата. Вывод не «Python медленный», а «границу нельзя пересекать часто».

Нужно сложить тысячу чисел из массива NumPy. Как это сделать правильно?

Одним вызовом np.sum(arr), а не циклом с arr[i]. На одном и том же объёме: поэлементно 138,8 мкс, одним вызовом 2,8 мкс — разница в пятьдесят раз. У вызова есть постоянная цена около двух с половиной микросекунд, и выгодно размазать её по большей порции.

Вы распараллелили счёт через `parallelize`, а внутри зовёте функцию Python. Что произойдёт?

Программа зависнет навсегда. Из-за глобальной блокировки интерпретатора вызов Python из нескольких потоков приводит к дедлоку — не к ошибке, а к бесконечному ожиданию. Управлять GIL из Mojo нечем. Python-часть должна отработать в одном потоке до начала параллельного счёта.

В программе есть цикл на миллион итераций, и внутри нужна одна функция из Python. Что делать?

Вынести Python за пределы цикла: получить все нужные данные одним вызовом до цикла, перенести их в родные структуры и считать уже на Mojo. Триста наносекунд на вызов при миллионе итераций — треть секунды на работе, которая иначе заняла бы две миллисекунды.

Вызов Mojo из Python: обратное направление — как ускорить существующую программу на Python, переписав одно узкое место.

Примеры проверены на Mojo 1.1.0

Тексты курса — CC BY-NC-SA 4.0, код примеров — Apache 2.0