В этой статье я подробно опишу 5 стадий принятия неизбежного  уровней оптимизации. В качестве примера рассмотрим, как я пытался оптимизировать функцию для инструмента командной строки, который я сам и написал  (monorepo-hash).

Примечание:

Я не настаиваю на том, что изложенное здесь — 5 священных заповедей инженерии производительноcти, а также не утверждаю, что приведённые здесь приёмы точно соответствуют каким-либо «карьерным уровням». Их можно сравнить просто с вешками на пути.

Это всего лишь тот путь, который проделал я сам. Есть в нём доля эго, пару раз я свернул не туда, бывали и случаи «я знаю, как тут срезать дорогу», за которыми сразу следовало раскаяние.

Также отмечу, что значительную часть этого CLI я написал при подспорье ИИ-моделей. Считайте, что это лёгкий способ подмечать ошибки и находить, что можно оптимизировать.

Кликбейтный заголовок? Что ж, даже, если так — вы же открыли статью, значит, он сработал :)

Ещё один инструмент для командной строки?

Да, упс… 😅
Этот инструмент я написал в ходе моей последней практики, подробнее о проекте рассказано в его файле README.
Всё, что о нём следует знать:

  • Он генерирует хеши для различных рабочих пространств вашего монорепозитория и поддерживает внутренние транзитивные зависимости.

  • Он зародился как импровизированный скрипт, призванный решить возникшую проблему, но я решил доработать его до полноценного инструмента.

  • Для меня это скорее был благовидный предлог сделать собственный инструмент командной строки, повозиться с кое‑какими интересными новыми технологиями (выкатывание, bun, …) и попытаться посмотреть, до какой степени мне удастся его оптимизировать (подкрепляя все опыты бенчмарками).

В принципе, инструмент должен быть в состоянии обрабатывать множество файлов, причём настолько быстро, чтобы пользователь этого не замечал (кроме как при использовании pre‑commit hook).

Решение реализовать всё это на TypeScript было… спорным, если мы стремимся к максимальной производительности, но это уже другой круг проблем 🤭.

На чём сосредоточимся сегодня

Поскольку код простирается почти на 1000 строк (без учёта комментариев), мы не будем здесь подробно его разбирать. Сосредоточимся лишь на том коде, который рассчитывает пофайловые хеши и возвращает их.

Также здесь есть некоторые тонкости, которые не будут меняться от реализации к реализации:

  • Возвращённые пути должны быть оформлены в стиле POSIX. Для этого мы используем собственную функцию, которая в этом посте упрощена (в реальном скрипте ещё есть кэш).

  • Она асинхронная и экспортируется для программного использования.

  • Возвращённую запись мы инициализируем в Object.create(null), а не в {}, чтобы пропустить прото‑инициализацию.

  • Если список пуст, то выходим досрочно.

Вот как выглядит оболочка этой функции:

import { sep } from "node:path"

/**
 * Нормализуем путь для большей наглядности (разделительные знаки всегда подбираются в стиле POSIX)
 * @param p путь для нормализации
 * @returns нормализованный путь
 */
export function displayPath(p: string): string {
  return sep === "/"
    ? p
    : p.replace(/\\/g, "/")
}

/**
 * для заданного `dir` и списка относительных путей к файлам (`fileList`), пофайлово вычисляем хеш SHA-256 для (normalizedPath + rawContent)
 * Всегда возвращает map : { "posix/rel/path": "hex" }
 * @param dir абсолютный путь к каталогу, в котором содержатся файлы
 * @param fileList массив относительных путей к файлам в пределах каталога
 * @returns промис, разрешающийся в запись, которая отображает относительные пути POSIX на соответствующие им шестнадцатеричные хеши SHA-256 
 */
export async function computePerFileHashes(
  dir: string,
  fileList: string[],
): Promise<Record<string, string>> {
  const result: Record<string, string> = Object.create(null)

  if (fileList.length === 0) {
    return result
  }

  // процесс...

  return result
}

Уровень 1: последовательная обработка (новичок)

Ничего сложного: перебери файлы и вычисли их хеши, бро:

import { createHash } from "node:crypto"
import { readFile } from "node:fs/promises"
import { join } from "node:path"

export async function computePerFileHashes(
  dir: string,
  fileList: string[],
): Promise<Record<string, string>> {
  const result: Record<string, string> = Object.create(null)

  if (fileList.length === 0) {
    return result
  }

  for (const file of fileList) {
    const norm = displayPath(file)
    const fullPath = join(dir, file)
    const content = await readFile(fullPath)
    const fileHash = createHash("sha256")
      .update(norm)
      .update(content)
      .digest("hex")

    result[norm] = fileHash
  }

  return result
}

Действительно, очень просто. Но проблема уже даёт о себе знать: этот код медленный. Очень медленный 🐌.
Чем больше байт приходится прочитать и хешировать, тем больше времени на это требуется (примерно O(totalBytesRead) + некоторые издержки).

Уровень 2: стримим как в Нетфликсе (вайб-кодер)

В чате непрошенный гость!
Вайб‑кодер прогнал программу, обнаружил, что она чертовски тормозит и попросил своего товарища Claude Code «переделай её, чтобы работала побыстрее, и без ошибок пжлст! 🥺»
И вот тут ему приходит на ум обманчиво хорошая идея: организовать потоковую передачу файлов.

Понимаете, одно узкое место здесь может возникнуть потому, что мы сначала дожидаемся, пока файл будет полностью загружен в память, а лишь затем мы сможем вычислить его хеш. А если приходится обработать огромный файл, то на загрузку его с диска в память может уйти некоторое время, из‑за чего программа и тормозит. В таком случае явно поможет потоковая передача данных — как с расходом памяти, так и с откликом приложения. Потоки Node.js буквально созданы для такой фрагментарной обработки.

Вот что получится у вайб‑кодера Claude:

import { createHash } from "node:crypto"
import { createReadStream } from "node:fs"
import { join } from "node:path"

export async function computePerFileHashes(
  dir: string,
  fileList: string[],
): Promise<Record<string, string>> {
  const result: Record<string, string> = Object.create(null)

  if (fileList.length === 0) {
    return result
  }

  for (const file of fileList) {
    const norm = displayPath(file)
    const fullPath = join(dir, file)
    const h = createHash("sha256")

    h.update(norm)

    await new Promise<void>((resolve, reject) => {
      const stream = createReadStream(fullPath)

      stream.on("data", (chunk) => h.update(chunk))
      stream.on("error", reject)
      stream.on("end", () => resolve())
    })

    result[norm] = h.digest("hex")
  }

  return result
}

Когда ИИ‑моделям предлагается ускорить код, почти все они предлагают потоковую передачу файлов (кстати, GPT‑модели усердствуют с этим сильнее, чем другие). Но почему эта идея плоха? Действительно, она была бы уместна, если бы мы обрабатывали огромные файлы. Но большинство файлов, с которыми нам приходится работать, содержат код — то есть текст. В лучшем случае, в файле ещё будут какие‑то шрифты, картинки, может быть, в репозитории будут лежать ещё 1–2 демо‑видео. Поэтому, «кусочная» обработка событий сопряжена с издержками, и польза от неё весьма сомнительна.

Замечание

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

Уровень 3: параллелизм рулит! (джун)

Если поручить джуну ускорить эту функцию, то он (обоснованно) подумает, что самое время применить одну недавно изученную тему: параллелизм.

Вместо того, чтобы обрабатывать файлы последовательно, он попытается обработать их все одновременно!

import { createHash } from "node:crypto"
import { readFile } from "node:fs/promises"
import { join } from "node:path"

export async function computePerFileHashes(
  dir: string,
  fileList: string[],
): Promise<Record<string, string>> {
  if (fileList.length === 0) {
    return Object.create(null)
  }

  const entries = await Promise.all(
    fileList.map(async (file) => {
      const norm = displayPath(file)
      const fullPath = join(dir, file)
      const content = await readFile(fullPath)

      const fileHash = createHash("sha256")
        .update(norm)
        .update(content)
        .digest("hex")

      return [norm, fileHash] as const
    }),
  )

  return Object.fromEntries(entries) as Record<string, string>
}

Действительно, так гораздо быстрее!
Но такой подход привносит несколько проблем:

  • Мы могли бы полностью насытить очередь ввода/вывода в Node.js. Как видите, можно поставить в очередь абсурдно много работы, относящейся к файловой системе. В Node.js операции с файловой системой поддерживаются функцией threadpool из libuv (по умолчанию имеет размер 4). Поэтому давление создать легко, а вот бесконечно высокая пропускная способность при этом совершенно не гарантирована.

  • Кэш диска может быть быстро израсходован, что коренным образом испортит рыбалку производительность чтения.

  • Если вне этого скрипта обрабатывается достаточно много файлов/выполняется много задач, то вся доступная память быстро заполнится, что приведёт к аварийному завершению программы — и я уже не говорю о том, что закончится запас файловых дескрипторов (привет, EMFILE 👋).

Уровень 4: пакетный подход к обработке (мидл)

Проект рос, и какой-то мидл заметил, что в рамках конвейера отказывает один скрипт. Поэтому решил поправить эту ситуацию своими силами.

Он решает применить не голый параллелизм, а обрабатывать файлы пакетами:

import { createHash } from "node:crypto"
import { readFile } from "node:fs/promises"
import { join } from "node:path"

export async function computePerFileHashes(
  dir: string,
  fileList: string[],
): Promise<Record<string, string>> {
  const result: Record<string, string> = Object.create(null)
  const CONCURRENCY = 100

  if (fileList.length === 0) {
    return result
  }

  // Заранее нормализуем пути, чтобы избежать многократных разделений/объединений
  const normalized = fileList.map((rel) => [
    rel,
    displayPath(rel),
  ])

  for (let i = 0; i < normalized.length; i += CONCURRENCY) {
    const batch = normalized.slice(i, i + CONCURRENCY)

    // oxlint-disable-next-line no-await-in-loop : главное не взорвать память, поэтому не перегружаем ее множеством конкурентных операций считывания 
    const partial = await Promise.all(batch.map(async ([ rel, norm ]) => {
      const fullPath = join(dir, rel)
      const content = await readFile(fullPath)
      const fileHash = createHash("sha256")
        .update(norm)
        .update(content)
        .digest("hex")

      return [ norm, fileHash ] as [string, string]
    }))

    for (const [ norm, partialHash ] of partial) {
      result[norm] = partialHash
    }
  }

  return result
}

Здесь мы заимствуем лучшее от двух подходов: одновременно обрабатываем X файлов, но не загружаем в память слишком много файлов из этого проклятого репозитория! Так откуда же взялось это волшебное число 100? Было бы не так уж и неверно сказать, что полностью «с потолка», но в основном оно определяется при помощи тестов. Слишком низко — и вы навредите производительности, слишком высоко — и вы взорвёте память. После прогона множества тестов обычно приходишь к какому-то достойному компромиссу.

Помогает ли на самом деле предварительная нормализация путей? Кто знает 🤷‍♂️.
Можно ли написать это более аккуратно? Вероятно.

Уровень 5: грамотно реализованная конкурентность (сеньор)

Прогоняя код, сеньор замечает признаки несогласованности в периодах выполнения и решает присмотреться к скрипту. Тогда он придумывает, при помощи чего можно ещё сильнее ускорить процесс: набор обработчиков (worker pool).

В принципе, вместо того, чтобы взять из списка 100 файлов, все их обработать, потом взять следующие 100 файлов, а затем снова и снова повторять этот процесс, мы создадим 100 очередей файлов (это будут наши «обработчики»), и обработка каждого файла будет происходить по мере того, как будут освобождаться места для обслуживания. Далее, как только такое место освобождается, туда поступает файл из списка.

Иными словами, вместо «взяли партию 100 штук и дожидаемся, пока будет обработан самый медленный элемент» мы задействуем  сразу 100 обработчиков. Как только обработчик справится с файлом, он приступит к следующему:

import { createHash } from "node:crypto"
import { readFile } from "node:fs/promises"
import { join } from "node:path"

/**
 * Сопоставление с массивом, в котором задан лимит на конкурентность 
 * @param items Массив элементов на обработку
 * @param limit Максимальное количество конкурентно протекающих операций
 * @param fn Асинхронная функция, применяемая к каждому элементу
 * @returns Промис, который разрешается в массив результатов
 */
export async function mapLimit<T, R>(
  items: T[],
  limit: number,
  fn: (item: T) => Promise<R>,
): Promise<R[]> {
  const results: R[] = Array.from({ length: items.length })
  let idx = 0

  async function worker() {
    while (idx < items.length) {
      const current = idx++

      // oxlint-disable-next-line no-await-in-loop
      results[current] = await fn(items[current])
    }
  }

  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))

  return results
}

export async function computePerFileHashes(
  dir: string,
  fileList: string[],
): Promise<Record<string, string>> {
  const result: Record<string, string> = Object.create(null)
  const CONCURRENCY = 100

  if (fileList.length === 0) {
    return result
  }

  const entries = await mapLimit(fileList, CONCURRENCY, async (file) => {
    const norm = displayPath(file)
    const fullPath = join(dir, file)
    const content = await readFile(fullPath)
    const fileHash = createHash("sha256")
      .update(norm)
      .update(content)
      .digest("hex")

    return [ norm, fileHash ] as const
  })

	for (const [ norm, partialHash ] of entries) {
		result[norm] = partialHash
	}

  return result
}

Вот максимум того, что было в моих силах.

На уровне 4 может возникнуть ситуация, когда на обработку одного из элементов уходит целая вечность, при этом обработка остальных 99 уже завершена, и следующий пакет просто… дожидается того последнего 😤. Если такое произойдёт на уровне 5, то этот файл заблокирует одного обработчика, но все остальные продолжат заниматься очередью, как если бы ничего и не произошло.

Заключение

Вот как можно резюмировать каждый из этих 5 уровней:

  • Уровень 1: я сделал цикл for и горжусь этим ^^ (умница, мальчик 🫳)

  • Уровень 2: УРА, ПОТОКИ!!! (отлично подходят для работы с памятью/огромными файлами, но не дают безусловного ускорения при обработке множества маленьких)

  • Уровень 3Promise.all() (пока ваша машина не изойдёт потом 😰)

  • Уровень 4: чёрт, так давайте наплодим сразу тысячи операций чтения (а чтобы частично подстраховаться, объединим их в пакеты)

  • Уровень 5: обеспечиваем занятость X обработчиков, чтобы не приходилось дожидаться завершения наиболее медленных операций в конце пакета (набор обработчиков/очередь с лимитом на конкурентность)

На мой взгляд, основной урок из этого следующий: оптимизация в основном заключается в том, чтобы правильно выбрать, какое именно узкое место мы будем расширять.

Иногда узкое место возникает потому, что «вы просто обрабатываете объекты по одному», бывает «вы сразу обрабатываете слишком много», а иногда «вы хотели справиться побыстрее, а только всё усложнили» — как раз последнее я умею делать мастерски 🥹.

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

Если вы усвоите из этого поста что-то одно, то пусть это будет: сначала замеры, потом мемы (ладно, можно замеры в сочетании с мемами, но именно в таком порядке 😉). В любом случае, нынешняя версия (уровень 5), на которой я остановился — это пока наилучшая известная мне комбинация «быстро», «не взрывается при непрерывной интеграции» и «мне удаётся читать этот код без слёз» (ну примерно).

Если вас интересуют конкретные числа/конфигурация — в репозитории выложены бенчмарки и весь остальной контекст.