Как стать автором
Обновить
727.96
Яндекс
Как мы делаем Яндекс

Diplodoc 5.0: как ускорить сборку документации в пять раз

Уровень сложностиСредний
Время на прочтение13 мин
Количество просмотров4.2K

Diplodoc — опенсорс‑платформа для работы с документацией в парадигме Docs as Code, которая создаётся в Яндексе силами команд Yandex Infrastructure и Yandex Cloud и является частью наших опенсорс‑инструментов. С её помощью мы собираем всю документацию компании. Это суммарно более 300 тысяч статей в более чем 2500 документационных проектов и порядка 6000 запусков Diplodoc CLI каждый день.

На таких объёмах нам важно быть эффективными — умеренно расходовать ресурсы сборочных ферм и при этом собирать проекты как можно быстрее, чтобы документаторы могли увидеть финальный результат без смены контекста на чай.

Изначально платформа Diplodoc создавалась для интенсивного развития документации Yandex Cloud. Писали её в сжатые сроки под понятный профиль документов — оптимальный. То есть все документы были среднего размера (около 300 Кб), с умеренным количеством заимствований из соседних файлов (инклудов), умеренным количеством ссылок на смежные документы, умеренным количеством картинок. Ну вы поняли... Никаких экстремальных сценариев.

Со временем, скорость CLI заметно деградировала. С одной стороны, платформа прирастала полезными функциями, это увеличивало время обработки контента, с другой — размер документации вырос и для некоторых продуктов перевалил за тысячу файлов.

Больше всего от растущего времени сборки страдали технические писатели: для просмотра внесенных изменений им требовалось собирать документацию целиком и на больших проектах это стало приводить к ожиданию более 10 минут. С десятками тысяч правок документации ежедневно эти десятки минут складываются в человеко‑месяцы простоя, которые никому не идут на пользу. Поэтому мы приняли решение всё это основательно причесать.

Архитектура

Во фронтенде сложено много былин о трансформации клиентских архитектур в духе «Как мы переписали всё на React», а сегодня мы поговорим о более редком звере — изменении архитектуры CLI.

Изменение архитектуры сильно помогло нам в поиске точек оптимизации приложения. Здесь перечислим тезисно основные изменения, а подробно раскроем их в следующих разделах:

  • изоляция работы с файловой системой;

  • выделение отдельных функциональностей (Feature sliced design);

  • контекст выполнения команды.

Работа с файловой системой

Наш CLI был создан не вчера и накопил за годы развития ряд проблем в работе с файловой системой.

  1. Использование readFileSync/writeFileSync потому что так удобнее и быстрее. Части CLI действительно могут быть исключительно синхронными. И разработчики использовали readFileSync прямо внутри таких обработчиков. В итоге и так медленные синхронные части становились ещё медленнее.

  2. Там, где использовался асинхронный readFile/writeFile, также было не всё в порядке. Из‑за логических ошибок, а иногда по общему соглашению, происходила множественная запись в один и тот же файл. Это очевидно заставляло ждать все обработчики кроме первого.

  3. Бессистемный подход к путям. Импорт resolve и readFileSync в любом месте позволял читать данные из любой части файловой системы, в том числе за пределами проекта. Это не влияло на скорость выполнения, но очень мешало стабильно разрабатывать приложение. А в некоторых сценариях создавало угрозу безопасности данных.

Первое, что мы изменили, — это методы, связанные с работой на файловой системе. Они стали первыми, что мы добавили в контекст выполнения программы:

class BaseRun {
    async read();
    async write();
    async copy();
    async remove();
    async realpath();
    exists();


    private fs: Pick<Node.FS, 'readFile' | 'writeFile' | '...'>;
}

Теперь работа с FS на уровне архитектуры регламентирована именно через эти методы.

В методы также заложена дополнительная функциональность, чтобы разработчики стремились использовать их вместо нативных readFile/writeFile. Например, write по умолчанию проверяет, существует ли уже файл, в который мы собираемся писать. Если такой есть, то быстро выходим, ничего не делая.

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

Уверенности в отсутствии конфликтов мы достигаем разделением слоёв работы с FS. На схеме это выглядит примерно так:

Разделение обработки проекта на этапы позволяет нам смелее работать с вводом/выводом программы.

Мы также пытались добавить существенную оптимизацию в метод copy — копировать через hardlink. Это бы ускорило этап копирования. Но пока эта идея провалилась, поскольку проект может работать сразу с несколькими файловыми системами, и очевидно, что в такой ситуации hardlink невозможен. Сейчас мы оставили компромиссное решение в виде флага COPY_ON_WRITE (его поддержка сильно ограничена в FS). В дальнейшем планируем реализовать copy on write программно, с помощью виртуальной файловой системы.

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

Мы добавили в систему несколько новых типов:

// paths.d.ts


type UnresolvedPath = string & {
    __type: 'path';
    __mode: 'unresolved';
};


type AbsolutePath = string &
    (
        | {
              __type: 'path';
              __mode: 'absolute';
          }
        | `/${string}`
    );


type RelativePath = string &
    (
        | {
              __type: 'path';
              __mode: 'relative';
          }
        | `./${string}`
    );


type NormalizedPath = string & {
    __type: 'path';
    __mode: 'relative';
    __fix: 'normalized';
};


type AnyPath = string | UnresolvedPath | AbsolutePath | RelativePath | NormalizedPath;

А также переопределили интерфейс FS:

declare module 'node:path' {
    namespace path {
        interface PlatformPath extends PlatformPath {
            normalize<T extends AnyPath>(path: T): T;


            join<T extends AnyPath>(path: T, ...paths: string[]): T;


            resolve(...paths: string[]): AbsolutePath;


            isAbsolute(path: AnyPath): path is AbsolutePath;


            relative(from: AnyPath, to: AnyPath): RelativePath;


            dirname<T extends AnyPath>(path: T): T;


            basename(path: AnyPath, suffix?: string): RelativePath;


            extname(path: AnyPath): string;
        }
    }


    const path: path.PlatformPath;
    export = path;
}

Реализация не претендует на непробиваемость, если у вас есть идеи, как сделать лучше, — поделитесь с нами в комментариях или на GitHub.

В итоге на этом этапе мы:

  • полностью убрали синхронную работу с FS;

  • убрали конкурирующую асинхронную запись в файлы;

  • частично оптимизировали процесс копирования.

Не делаем лишнего

Токенизация. Один из наиболее дорогих сценариев для трансформации markdown — разбор файла на токены. Мы парсили каждый файл от четырёх до шести раз за сборку:

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

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

  • предварительный поиск инклудов;

  • трансформация документа;

  • линтинг документа (+2).

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

Стандартный сценарий. В нашей CLI достаточно большое количество параметров. Но при этом около 90% наших проектов живут на стандартных настройках. В том числе и со стандартными настройками для markdownlint, о котором и пойдёт речь.
Мы используем markdownlint для проверки синтаксиса в проекте, но в достаточно своеобразном режиме. По умолчанию мы отключаем все стандартные правила, и используем только собственные (так мы считали). На самом же деле все стандартные правила продолжали работать, генерировали ошибки. А мы эти ошибки игнорировали.
Это всё само по себе — куча бесполезной работы, но тут есть дополнительная специфика, о которой стоит упомянуть.

В очередном мажоре markdownlint для своих правил начал использовать новый парсер — micromark. Это отличный мощный парсер, который очень точно позиционирует токены, что улучшает сообщения об ошибках. Например, он используется внутри MDX. Но, к сожалению, micromark медленный. Он примерно в 10 раз медленнее markdown‑it.
И сколько бы правил мы ни выключали, markdownlint всё равно дважды парсил документ — один раз micromark и один markdown‑it.

Мы сделали исправление в markdownlint, и оно уже вышло в версии 0.38.0. Теперь, если стандартные правила не используются, то и micromark‑токены не генерятся.
Важным в понимании проблемы был перевод архитектуры на Feature Sliced Design.
Как примерно выглядел наш код раньше: он был разделён на шаги. Но при этом внутри шага творилось следующее:

// some-program-step.js


export function someProgramStep(config) {
    if (config.feature1) {
        doSomethingForFeature1();
    }


    doSomething();


    if (config.feature2) {
        doSomethingForFeature2();
    }


    doSomething();


    if (config.feature1 || config.feature2 && !config.feature3) {
        doSomething();
    }
}

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

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

export class SinglePageFeature {
    apply(program: Build) {
        const entries = [];


        getBuildHooks(program)
            .Entry.for('html')
            .tap('SinglePage', (run, entry) => {
                entries.push(entry.toString());
            });


        getBuildHooks(program)
            .AfterRun.for('html')
            .tapPromise('SinglePage', (run) => {
                return run.write('single-page.html', entries.join('\n'));
            });  
    }
}

Во многом это напоминает архитектуру Webpack. А библиотеку хуков и базовый интерфейс расширений мы напрямую переняли оттуда.

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

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

  • Крайне проблематично сослаться на соседнюю фичу, доступ к её рантайму не предоставляется. Это скорее плюс — такой подход требует более ответственно относиться к выделению функциональностей в системе.

Шаблонизация. Наш процесс рендеринга markdown состоит из двух принципиальных этапов: шаблонизация с использованием liquid‑синтаксиса и трансформация с использованием markdown‑it.

На этапе шаблонизации мы вычисляем набор переменных для каждого файла. Мы идём от корня проекта до файла, по пути ищем все presets.yaml (именно в них хранятся переменные). Все найденные переменные мёрджим с помощью lodash/merge.

«Как оказалось», глубокое копирование объектов из 140 тысяч полей, для 9 тысяч файлов — не лучшая идея. Особенно если учесть, что в файле будет использована всего одна переменная из всего набора.
Мы реализовали структуру через Proxy‑объект, который вычисляет конкретные переменные по запросу. При этом внутри не происходит никакого копирования, только чтение (полная реализация тут).

class VarsService {
    get(path: RelativePath): Preset {
        // Ищем все объекты переменных, которые будут использованы для указанного файла.
        const scopes: Record[] = this.scopes(dirname(path));


        // Создаём прокси-объект, которой будет вычислять переменную при обращении к ней.
        const proxy: Hash = new Proxy(
            {},
            {
                get(_target, prop: string) {
                    for (const scope of scopes) {
                        if (own(scope, prop)) {
                            return scope[prop];
                        }
                    }


                    return undefined;
                },


                // Другие необходимые прокси-хуки
                // ...
            },
        );


        return proxy;
    }
}

Такая структура позволила нам выиграть не только по производительности, но и по памяти. Собственно, из‑за памяти мы эту оптимизацию и нашли. Об этом в следующем разделе.

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

Например, в случае с вычислением переменных мы 9000 раз закешировали объект из 140 тысяч полей. Когда документация объёмом не более 100Мб стала занимать в памяти 16Гб, мы догадались, что двигаемся не в том направлении.

«Кешировать всё подряд нельзя», — записали мы себе и углубились в исследование вопроса. Рассмотрели возможность чистки уже ненужных ресурсов, но пришли к выводу, что такой вариант нам не подойдёт: в некоторых режимах сборки нам в самом конце нужна вся информация о проекте — чистить нечего.

В итоге приняли решение кешировать только строковые данные после наиболее нагруженных слоёв обработки. На этом этапе мы убрали примерно 20% лишних чтений с файловой системы вместе с этапом шаблонизации прочитанных данных.
Для анализа памяти сильно пригодился единый контекст сборки. Так как все кеши прикреплялись к нему и в отладчике, в первую очередь, нужно было исследовать этот объект и его зависимости.

На скриншоте можно увидеть объект Run, который вместе с потомками занимает 48% всего снапшота памяти. К нему привязаны основные сервисы (vars, markdown, tocs), которые и потребляют основную часть памяти.

Итого на этом этапе:

  • убрали множественное создание тяжёлых бесполезных объектов;

  • убрали ошибочные дополнительные токенизации;

  • оптимизировали стандартный сценарий выполнения сборки;

  • закешировали наиболее тяжёлые этапы сборки.

Многопоточность

Многопоточность — идея, которая лежит на поверхности и кажется простой в реализации. Берём threads, чтобы было ещё проще внедрять, и радуемся.

Исторически мы давно использовали многопоточность для наиболее нагруженного шага — прогон markdownlint. Но в рамках новой архитектуры старая реализация многопоточности не подходила. Основная проблема была с Feature Sliced Design.

К моменту внедрения мультитрединга наша архитектура имела свой финальный вид:

Есть модули ядра (1). Они используются внутри контекста запуска команды build (2). У модулей есть наборы хуков, на которые можно подписаться.

Есть фичи (3). Они инициализируются внутри команды и подписываются на хуки модулей ядра (4).

Pipeline вызывает методы модулей ядра, тем самым запуская выполнение хуков (Call hooks).

При этом часть логики внутри модулей может быть выполнена как в основном, так и в дочерних потоках:

В такой ситуации сложно отследить, какое окружение потребуется для выполнения логики в воркерах.

В итоге вместо вынесения в треды отдельных слоёв обработки, мы унесли туда всё приложение. Это максимально похоже на кластерный режим запуска NodeJS на серверах.

Мы инициализируем приложение N раз (по количеству потоков). Какие‑то подготовительные этапы у нас дублируются, и мы на это согласны, это не влияет на производительность. Основной поток анализирует базовую структуру проекта, и распределяет работу по воркерам.

При этом в подпоток можно отправить любой метод, который мы декорируем подобным образом:

class BuildRun extends BaseRun {
    
    @threads.threaded('build.process')
    async process(...props): Promise<EntryInfo> {
        // Do something in thread
    }
}

Здесь build.process — это способ добраться до этого метода от корня приложения.

Код декоратора выглядит так:

// THread API
expose({
    async call(call: string, args: unknown[]) {
        const method = get(program, call);
        return method(...args);
    },
});


export function threaded(call: string) {
    return function (_originalMethod: unknown, context: ClassMethodDecoratorContext) {
        const methodName = context.name;


        context.addInitializer(function (this: any) {
            const method = this[methodName];


            if (isMainThread) {
                this[methodName] = function (...args: unknown[]) {
                    // Если мы в основном треде и режим мультитрединга включен,
                    // то отдаем задание в пул, дожидаемся результата.
                    if (pool) {
                        return pool.queue((thread: ThreadAPI) => {
                            return thread.call(call, args);
                        });
                    } else {
                        return method.call(this, ...args);
                    }
                };
            } else {
                // Если мы в воркере - выполняем оригинальный метод.
                this[methodName] = method.bind(this);
            }
        });
    };
}

Такой подход позволяет нам просто добавлять мультитрединг и в другие команды, которые есть в самой CLI или в расширениях.

Внедрение

Наш подход к оптимизации скорости выполнения CLI оказался достаточно комплексным. В процессе мы переписали/переместили 95% кода нашего приложения.

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

Что сделали мы:

  1. Прототип — отдельная версия CLI, избавленная от множества условностей. Задача прототипа — максимально быстро собирать одну‑единственную документацию Yandex Cloud. Получив информацию о максимальном улучшении, мы можем понять, стоит ли в это вообще вкладываться. Прототип ускорял сборку в 3–7 раз, в зависимости от сборочного окружения.

  2. Постепенное внедрение. На этом этапе мы проработали финальную архитектуру, к которой нужно привести CLI в рамках улучшения (а учитывать нужно было не только скорость, но и другие направления развития. Например, расширяемость CLI внешними модулями). Понимая финальную архитектуру, мы начали поэтапно переписывать на неё модули приложения.

Каждый релиз нашей CLI проверяется большим количеством тестов. Это набор юнит‑ и интеграционных тестов в GitHub. А также более пяти тысяч системных тестов в нашем внутреннем репозитории.

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

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

Для серверной сборки документации CLI последовательно запускается в режимах ‑f md (prebuild) и ‑f html (build), мы посчитали общее выполнение этих запусков: суммарно сборка документации ускорилась в 5,3 раза.

При этом мы не видим такого большого ускорения, как на прототипе, поскольку для обработки всех наших документаций нужно выполнять больше логики, чем только для документации Yandex Cloud. Мы понимали эту специфику на старте.


С такими результатами мы завершили первое крупное обновление производительности Diplodoc. Как ещё можно ускорить сборку документации? В версии 5.0 не только достигнуто значительное ускорение, но и заложен фундамент для следующей важной оптимизации CLI — инкрементальной сборки проектов. Но об этом мы расскажем в другой раз. А пока вспоминаем, что каждый день CLI запускается более пяти тысяч раз, — и радуемся общему сэкономленному времени.

Теги:
Хабы:
+29
Комментарии0

Публикации

Информация

Сайт
www.ya.ru
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия