В этой статье я попробую заглянуть за пределы возможностей языка JavaScript и оценить, как производительность может существенно различаться при написании выразительного, декларативного и лаконичного кода по сравнению с оптимизированным. На примере функции, определяющей, является ли строка палиндромом, я покажу несколько вариантов решения задачи с замерами времени на исполнение. Затем напишу модуль на C, который буду вызывать наряду с методами на JavaScript для замера скорости. Проведу низкоуровневые оптимизации. Все это стало возможно благодаря развитию ИИ.

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

const isPalindromeJSBase = (s) => {
    const clear = s.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
    return clear === [...clear].reverse().join('');
}

Возьмем ее за основу.

Далее будут применены оптимизации:

Оптимизация 1: Ускорение за счет исключения ненужных преобразований и использования регулярных выражений.

export function isPalindromeJSFast(s) {
  let left = 0;
  let right = s.length - 1;

  while (left < right) {
      while (left < right && !isAlphanumeric(s[left])) {
          left++;
      }
      while (left < right && !isAlphanumeric(s[right])) {
          right--;
      }
      if (s[left] !== s[right]) {
          if (s[left].toLowerCase() !== s[right].toLowerCase()) {
              return false;
          }
      }
      left++;
      right--;
  }
  return true;
}

function isAlphanumeric(c) {
  return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
}

Эта оптимизация позволяет ускорить выполнение функции в 4.5 раза!

Оптимизация 2: Допущение об одинаковом регистре

Делаю допущение, что в большинстве случаев строки будут в одном регистре, и только при несовпадении привожу их к единому регистру:

// isPalindromeJSFast
    if (s[left] !== s[right]) {
          if (s[left].toLowerCase() !== s[right].toLowerCase()) {
              return false;
          }
      }

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

Оптимизация 3: Использование C для максимальной производительности

С помощью ChatGPT я написал модуль на C, который показывает уже 20-кратное улучшение производительности по сравнению с базовой функцией. Данный модуль можно использовать в JS-коде. Также можно использовать в TypeScript, покрыв типами.
Код на C можно посмотреть в репозитории в закрепе.

Оптимизация 4: Использование SIMD-инструкций

На MacBook Air M1 с использованием SIMD-инструкций (аналоги AVX в x86)удалось добиться ускорения в 224 раза по сравнению с базовой функцией. Работоспособность в x86 не проверял, в коде есть условия для перевода в x86.

Результаты

  • isPalindromeJSFast: ускорение в 4.5 раза.

  • isPalindromeJSSuperFast: ускорение в 7 раз.

  • вынос расчетов в C-модуль: ускорение в 20 раз.

  • SIMD-инструкции: ускорение в 224 раза (на MacBook Air M1).

Условия тестирования

Данные для тестирования брались с учетом того, что длина строки была 10_000_000 символов, а время замеров производилось за 50 прогонов. Перед замером времени выполнения коду давалась возможность "подогреться", чтобы оптимизаторы смогли сделать необходимые оптимизации. Перед тестом MacBook Air M1 давали остыть.

224-кратное ускорение — это очень большая разница, но она важна при очень больших значениях. В реальных условиях такой производительности достичь сложно, особенно на небольших данных, как жонглирование с JSON-ками, где оптимизация особо не поможет. Например, бекенд на Nest.js создает столько лишних телодвижений, что мои вкрапления кода кажутся мизерными. Пока до меня доходит HTTP-запрос, созданный библиотекой node:http, на него накладывают логику от Express.js, далее Nest.js оборачивает их в Observable (RxJS).

Например, в бенчмарке:

  • Node.js получает 64 779 очков

  • Fastify — 56 794 очков

  • Nest.js — 35 814 очков (вероятно под капотом Express.js)

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

Разработчик среднего уровня на JavaScript может написать код, который будет лишь в 2-3 раза медленнее C (что очень даже хорошо). Если же важна исключительная производительность, то добро пожаловать в мир C, где уже придется учитывать архитектуры процессоров, но зато с помощью магии оптимизации на низкоуровневом C можно достичь значительного ускорения, на которое компиляторы JavaScript не способны. Это не значит, что я откажусь от регулярных выражений. С лавинообразным ростом использования эмоджи, только благодаря новым функциям в regex можно точно определить длину строк, содержащих эмоджи, и выполнять текстовую верстку. Операторы rest и spread - это дар с небес, позволяющий не запоминать новые методы массивов, объектов или строк.

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

Комментарии (3)