ESPN DeportesDuelo de jóvenes superestrellas: ¿Qué aportan Chourio y Caminero a la Serie Mundial 2026?ESPNCarrick unsure on future amid Man United pressurePunchFG releases N38.93bn to upgrade 166 tertiary institutionsThe Jerusalem PostAsher Grunis steps down from leading Submarine Affair commission due to health concernsCBS News10/25: Face The Nation20 Minuten«Knallte wie eine Explosion»: Fenster zerschmettert neben RestiZDF heuteAktuelle Pressemitteilungen des ZDFMyJoyOnlineBright Simons proposes $13m civil service AI assistant as alternative to Ghana’s $270m GhanaChat planComplete Sports2026 U-17 WWC: Flamingos To Face Mexico In Round Of 16ANSA SportRugby: Italia in raduno a Padova, tante le assenze per QuesadaBBC News BrasilVoto no exterior: veja os resultados por país e a apuração de Lula x Flávio no 2º turno das eleições 2026ColliderTreasure Island: 20 Years of Friendship Made Cruelty Harder | Set Stories
The Daily Newsstand · Free, Always
Sunday, October 25, 2026

pyrusmorph: в 30 раз быстрее. Интеграция ядра libmorph/rus в Python

Translate

Разбираем производительность морфологических анализаторов для русскоязычных текстов в Python-среде. Расскажем, почему библиотеки семейства PyMorphy работают из рук вон хорошо так медленно и как интеграция нативного C++ ядра libmorph/rus решает проблему производительности high-load систем.

Введение

Фактическим стандартом морфологического анализатора для русскоязычных текстов в Python-среде стали библиотеки семейства PyMorphy (PyMorphy2, PyMorphy3). Основными причинами стали популярность Python среди инженеров ML и низкий порог входа. Но у них есть критический недостаток: они катастрофически медленные.

Из-за выполнения кода интерпретатором и множественных аллокаций памяти при анализе каждого слова этап лемматизации текста часто становится бутылочным горлышком всего NLP-пайплайна, заставляя тратить ресурсы на масштабирование там, где без этого можно и обойтись.

Традиционным решением для высоконагруженных систем в таких случаях становится перенос лингвистического ядра на компилируемые языки. В этой статье речь пойдет о создании специализированного Python-расширения на базе нативной библиотеки libmorph/rus, реализованной на C++ . Она появилась в конце 90-х годов прошлого века, была построена на базе Великого словаря А. А. Зализняка для контроля орфографии и грамматики в составе пакета Пропись, работала с Microsoft Word и кучей других офисных пакетоы, а потом 30 лет развивалась в составе Апорта, Рамблера и украинской Меты как как в технологическом отношении, так и в плане вычитки словаря и наращивания словарной базы современной лексикой.

Мы напомним о внутренней организации библиотеки, покажем примеры её использования в Python и приведём результаты профилирования производительности.

Архитектура и организация данных в памяти

При проектировании критичных к задержкам (low-latency) систем доминируют два фактора: компактность представления словаря в ОЗУ для эффективного использования кэша процессора и отказ от динамического выделения памяти при выполнении операций. Лингвистического ядра это касается в полной мере, так как оно выполняет по вызову на каждые 6-7 байт плоского текста, или почти 150,000 актов лемматизации на мегабайт.

Сравнительный анализ подходов

PyMorphy3

pyrusmorph
(libmorph binding)

Структура словаря

Направленный ациклический граф слов (DAWG).

Сериализованные вырожденные радиксные деревья (Radix Trees). Словарь представляет собой непрерывный блок размером около 2 мегабайт.

Управление памятью

Множественные аллокации кучи, в том числе для результатов.

Stack only/zero alloc. Поиск и разбор выполняются в фиксированных буферах на стеке.

Словари

OpenCorpora

Пополненный словарь на базе Зализняка.

Идентификация

Строковые или динамические представления

Фиксированные числовые идентификаторы лексем и форм слов.

Главное ограничение библиотек PyMorphy вытекает из самой природы рантайма Python. Порождая список омонимов, интерпретатор инстанцирует множество мелких объектов. Это приводит к деградации кэша процессора (L1/L2 cache misses) и тратит дополнительные ресурсы на работу сборщика мусора.

В libmorph/rus анализ слова сводится к последовательному проходу по таблицам радиксного дерева по статической неизменной памяти “снизу вверх” с байтовыми смещениями, а данные представлены однобайтной кодировкой (в потрохах – Windows-1251 как самая очевидная и некогда наглядная). Все промежуточные структуры минимального размера размещены на стеке.

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

Тесты производительности

Для оценки был подготовлен изолированный стенд. Выполнялось сравнительное тестирование лемматизации (отождествления со словарём) форм слов плоского текста в кодировке utf-8 с приведением к нормальной (словарной) форме и извлечением набора морфологических признаков исходной формы слова.

  • Исходный текст: “Большая книга занимательных фактов в вопросах и ответах” А. П. Кондрашова.

  • Объём данных: 4+ мегабайта текста (около 2 миллионов символов) с вкраплениями латиницы, чисел и естественной пунктуацией.

  • Всего слов: 405000, в том числе 311775 кириллических и 76893 символа пунктуации.

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

pymorphy3

pyrusmorph

Затраченное время, сек.

13.7

0.5

Скорость, слов в сек.

22800

621000

Опознано слов

278519

278221

Для сравнения, тот же тест нативным C++-приложением с использованием libmorph/rus показывает производителность более 1 300 000 слов в секунду. Это позволяет сделать интересный вывод: сама python-обёртка вокруг C++-ядра уже даёт двукратное замедление.

В целом же использование сериализованных префиксных деревьев на C++ в связке с отказом от динамических аллокаций памяти обеспечивает ускорение этапа морфологического анализа в 25-30 раз. При этом пиковое потребление памяти процессом остается стабильным и детерминированным, не превышая фактического объема словаря – двух мегабайт, так как его образ просто отображается в память при загрузке библиотеки. В многопоточном окружении расход памяти не растёт – все внутренние данные libmorph при работе используются в режиме read-only.

Анализ расхождений

Интересно, что pymorphy3 опознал и лемматизировал на три сотни слов больше по сравнению с libmorph. Мы выделили эти слова и присмотрелись получше.

Уникальных нераспознанных слов у pymorphy3 оказалось 114, у pyrusmorph – 306. Из них общих – 41, среди которых осмысленными представились только Ио (один из спутников Юпитера, ближайший к нему), Поликсо (жена родосского царя Тлеполема, греч.), Ра (египетский бог) и Фау (название немецкой буквы V и ракеты Вернера фон Брауна).

Остальное ушло в разницу. Вот примеры явных пропусков (слов, которые не распознались зря):

Пропуски pymorphy3: ИМТ, Антиопы, Гоморры, Денёв, Деций, Полидекта, Полифем, регии, Транссиб, …

Пропуски pyrusmorph: Александро, Арахна, аргоссцы, Бартоломеу, благоуспешно, Гавайи, Галилео, Гвидо, гинкго, ГОЭЛРО, гуппи, Данаей, Дафна, Джорджио, Джулиано, дигидрогенмонооксидом, Захарченко, коринфцы, Марио, Медео, Монгольфье, принародно, Сорренто, табуировано, ...

В обоих случаях это аббревиатуры, древнегреческие имена, “новая мифология” и экзотическая география. Примечательно, что pyrusmorph также не знает о легендарном полковнике Захарченко и его квартире, набитой наличными.

Также мы изучили ложные срабатывания – слова, которые не должны распознаваться как склоняемые или спрягаемые формы, явно таковыми не являются и не должны распознаваться как склоняемые-спрягаемые, однако были ошибочно приняты системами:

pymorphy3: АКТҐ (Ґ из украинского алфавита), апэреа, бактерио, бо, вениаминитя, взрыво, Дыхсу, Кпк, навсего, ЬйпйАее, …

pyrusmorph: Гус, ГэВ, кёй, коу, СсС, умм, Хоу, Ээт

Некоторые из этих слов – например, Гус в pyrusmorph – взялись не из вероятностного анализатора, а явно прописаны в словаре (имеется ввиду тренер по футболу Гус Хиддинг).

В целом pymorphy3 гораздо щедрее раздаёт гипотезы о склонении/спряжении слов, по ощущениям более полагаясь на применимость обобщённых моделей словоизменения, чем на словари, и порождает в целом больше вариантов. Хорошо это или плохо – предмет для отдельного исследования, выходящего за рамки этого текста.

Использование pyrusmorph в Python

Библиотека pyrusmorph построена на основе библиотеки libmorph/rus и пакета pybind11, что обеспечивает Python-разработчику прямой доступ ко всему функционалу оригинальной C++-системы: словарной (mlmaruWc) лемматизации, эвристическому анализу (mlfaruWc), генерации словоформ и итераторам по словарю с поддержкой символов шаблона *?.

Ниже пример сценария использования библиотеки:

  • лемматизация (получение нормальной формы слова);

  • печать частей речи в человекочитаемом виде;

  • синтез форм слова;

  • работа с эвристическими моделями.

import pyrusmorph

class MorphFormatter:
    """Отображатор технической части речи в человекочитаемую, по Зализнаку"""
    
    PART_OF_SPEECH_MAP = [
        '?', 'нсв', 'нсв_нп', 'св', 'св_нп', 'св-нсв', 'св-нсв_нп', 'м', 'мо', 'м//мо', 
        'м//с', 'мо', 'мо', 'ж', 'жо', 'ж//жо', 'с', 'со', 'с//со', 'м//ж', 'мо-жо', 
        'м//с', 'мо//со', 'ж//с', 'мн. м//ж', 'п', 'п', 'п', 'мс-п', 'мс', 'мсм', 
        'мсж', 'мсс', 'числ', 'числ._2', 'числ._с', 'числ.-п', 'и', 'им', 'иж', 'ом', 
        'ож', 'ф', 'г', 'гм', 'гж', 'гс', 'мн._от_г', 'вводн.', 'межд.', 'предик.', 
        'предл.', 'союз', 'союз_соч.', 'част.', 'н', 'сокр._сущ.', 'сокр._прил.', 
        'сокр._вводн.', 'сравн.', 'АБ', 'аб', '#1', '#2', '??'
    ]

    @classmethod
    def get_pos_name(cls, wd_info: int) -> str:
        """Извлекает строку человеческой части речи по значению технической."""
        idx = wd_info & 0x3f
        if idx < len(cls.PART_OF_SPEECH_MAP):
            return cls.PART_OF_SPEECH_MAP[idx]
        return '??'


def demo_lemmatize(morph_analyzer, word: str):
    """Лемматизация и печать омонимов"""
    print(f"\nСлово: '{word}'")
    lemmas = morph_analyzer.lemmatize(word, 0)
    
    if not lemmas:
        print("  -> Не найдено в словаре")
        return

    for lemma in lemmas:
        print(f"  ID лексемы: {lemma.lexid:<6d} | Нормальная форма: {lemma.lemma}")
        for gram in lemma.grams:
            pos = MorphFormatter.get_pos_name(gram.wdInfo)
            print(f"    [Граммема] Часть речи: {pos:<10} | ID формы: {gram.idForm}")


def demo_build_form(morph_analyzer, lexeme_id: int, form_id: int):
    """Синтез формы слова"""
    print(f"\nЛексема {lexeme_id}, форма {form_id}:")
    forms = morph_analyzer.build_form(lexeme_id, form_id)
    for form in forms:
        print(f"  -> {form}")


def display_matched_forms(lexeme_id: int, form_list):
    """Callback для find_match"""
    print(f"  ID лексемы: {lexeme_id:<6d}")
    for matched_form in form_list:
        print(f"    Форма {matched_form.form:<2d}: {matched_form.text}")


def demo_fuzzy_analysis(fuzzy_analyzer, word: str):
    """Вероятностная лемматизация"""
    print(f"\nСлово: '{word}'")
    stem_list = fuzzy_analyzer.lemmatize(word, 0)
    
    if not stem_list:
        print("  -> Не удалось построить гипотезы")
        return

    for stem in stem_list:
        # Визуально разделяем основу и флексию
        stem_form = f"{stem.lemma[:stem.lStem]}|{stem.lemma[stem.lStem:]}"
        print(f"  prob: {stem.fProb:<6.4f} | stem: {stem_form:<15} | class: {stem.clsId}")
        for gram in stem.grams:
            pos = MorphFormatter.get_pos_name(gram.wdInfo)
            print(f"    Часть речи: {pos:<10} | id формы: {gram.idForm}")


def demo_fuzzy_build_form(fuzzy_analyzer, stem: str, class_id: int, form_id: int):
    """Синтез формы слова для основы"""
    print(f"\nСинтез формы ('{stem}', class: {class_id}, form id: {form_id}):")
    forms = fuzzy_analyzer.build_form(stem, class_id, form_id)
    if not forms:
        print("  -> Не удалось построить форму")
        return
    for form in forms:
        print(f"  -> {form}")


if __name__ == "__main__":
    # Init
    morph = pyrusmorph.mlmaruWc()
    fuzzy = pyrusmorph.mlfaruWc()

    print("=== 1. lemmatize ===")
    demo_lemmatize(morph, 'программирование')
    demo_lemmatize(morph, 'Киев')
    demo_lemmatize(morph, 'простой')

    print("\n=== 2. build_form ===")
    demo_build_form(morph, 182067, 5)

    print("\n=== 3. find_match ===")
    print("Поиск по шаблону 'до?ал?д':")
    morph.find_match("до?ал?д", display_matched_forms)
    
    print("\nПоиск по шаблону 'ко*ро*ва':")
    morph.find_match("ко*ро*ва", display_matched_forms)

    print("\n=== 4. stematize ===")
    demo_fuzzy_analysis(fuzzy, "командировочка")
    demo_fuzzy_analysis(fuzzy, "нейрослоп")
    demo_fuzzy_analysis(fuzzy, "вундервафля")

    print("\n=== 5. build form by stem and class ===")
    demo_fuzzy_build_form(fuzzy, "командировоч", 14, 5)

(пример кода отформатирован и причёсан ИИ в соответствии с традициями сообщества, без свойственных мне и C++ стилистических выкрутасов)

Заключение

Разработка поисковых и NLP-систем часто буксует на неэффективных алгоритмах нижнего уровня, требуя масштабирования железа там, где без этого можно вполне обойтись. Использование интерпретируемого кода для базовых операций на больших объёмах данных представляется неразумной тратой ресурсов.

Библиотека pyrusmorph доказывает, что в экосистеме Python можно работать с промышленной производительностью уровня C++ без усложнения процессов развёртывания.

Установка для Python:

pip install pyrusmorph

Репозиторий проекта: https://github.com/big-keva/libmorph

Лицензия: MIT

Подробнее об истории развития лингвистической библиотеки можно почитать в статье Тридцать лет libmorph.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.