Вызов 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
компилятор не пропустит.
PythonObject — всё, что пришло оттуда
Заголовок раздела «PythonObject — всё, что пришло оттуда»Любое значение из 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}Обратно в Mojo
Заголовок раздела «Обратно в Mojo»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 000 | 2,8 |
| 100 000 | 16,5 |
Данных в сто раз больше, а времени — всего в шесть раз. Причина видна из чисел: у вызова есть постоянная цена около двух с половиной микросекунд, и она не зависит от объёма. Чем крупнее порция, тем сильнее эта цена размазывается.
Правило простое: граница пересекается порциями, а не поштучно. Именно поэтому NumPy, pandas и подобные библиотеки хорошо сочетаются с Mojo: они и сами устроены как «один вызов — много работы».
Python и потоки: несовместимы
Заголовок раздела «Python и потоки: несовместимы»Отдельное предупреждение, потому что предыдущий раздел курса учил распараллеливанию, и соблазн сложить одно с другим велик.
Складывать нельзя. Вызов Python внутри parallelize приводит
к взаимной блокировке: программа не падает с ошибкой, а зависает
навсегда.
Проверено: четыре потока, в каждом math.sqrt. Первые два отработали,
третий стартовал — и всё, процесс пришлось убивать по таймауту. С двумя
потоками не печатается вообще ничего.
Причина — глобальная блокировка интерпретатора (GIL): одновременно
исполнять байт-код Python может только один поток. Управлять ею из Mojo
нечем: в std.python нет ни одной обёртки над GIL — проверено.
Свои модули на Python
Заголовок раздела «Свои модули на Python»Импортировать можно не только установленные пакеты, но и собственные файлы — достаточно указать каталог:
Пусть рядом с программой лежит каталог 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 быстрее.
# Разделение труда: разбор — Python, счёт — Mojo.## JSON парсить руками на Mojo незачем: в Python это одна строка.# А вот считать по разобранным числам лучше уже своими средствами —# каждый переход границы стоит сотни наносекунд.
from std.python import Pythonfrom 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, переписав одно узкое место.
Тексты курса — CC BY-NC-SA 4.0, код примеров — Apache 2.0