
В среде выполнения задач ИИ для Firefox можно задействовать сразу множество потоков в выделенном процессе логического вывода, чтобы ускорить выполнение таких операций на ЦП. В среде WASM/JS можно создать SharedArrayBuffer и обрабатывать содержимое этого буфера сразу несколькими потоками. Такая рабочая нагрузка поддаётся конкурентному распределению на несколько ядер ЦП.
Ниже показано, сколько времени и при каком количестве потоков понадобилось на выполнение задач на MacBook M1 Pro. У этой модели 10 физических ядер, а работа велась с моделью PDF.js, преобразующей картинку в тексте и генерирующей текст, подставляемый на место картинки при необходимости. Задача решалась при разных степенях конкурентности:

Итак, если распределить задачи на несколько потоков — правила игры меняются! Но по мере того, как потоков становится всё больше, выполнение постепенно замедляется, до тех пор, пока работа затягивается настолько, что вообще без использования потоков она шла бы даже быстрее.
В этой статье мы попытаемся ответить на вопрос: как подобрать оптимальное количество потоков для выполнения задачи?
Сравнение физических и логических ядер
Согласно относительно свежим обнародованным данным по ПК, 81% пользователей браузера Firefox работают с ЦП Intel, 14% с AMD, а остальные — в основном на устройствах с Apple.
На всех современных ЦП логических ядер (также называемых «потоками») предоставляется больше, чем физических ядер. Всё дело в современных технологиях, таких, как гиперпоточность от Intel или одновременная многопоточность (SMT) от AMD.
Например, на процессоре Intel Core i9-10900K имеется 10 физических ядер и 20 логических.
Если поднять столько же потоков, сколько у вас есть логических ядер, то может наблюдаться значительный выигрыш в производительности, особенно, если выполнение каких-то задач зависит от скорости ввода/вывода, или если процессор может эффективно переплетать инструкции.
Однако при решении вычислительно-тяжёлых задач (например, сложном логическом выводе при машинном обучении) не так полезно иметь больше потоков, чем у вас имеется физических ядер. Из-за этого отдача снижается, и даже может падать производительность. Дело в таких факторах, как издержки на планирование потоков и конкуренцию за кэш (cache contention).
Ядра бывают разными
На Apple Silicon у вас не просто есть некоторое количество ядер; эти ядра к тому же отличаются по свойствам. Есть такие высокопроизводительные ядра, которые специально предназначены для сложной работы, а в других ядрах акцент делается на эффективности — они оптимизированы так, чтобы при работе потребляли как можно меньше энергии.
Например, в чипах Apple M1 Pro есть как высокопроизводительные ядра (8), так и эффективные (2). Всего на устройстве 10 физических ядер, но 8 первых предназначены для выполнения тяжеловесных задач, а остальные 2 обычно выполняют фоновые задачи, на которые требуется не так много ресурсов.
Когда ваш компьютер занимается машинным обучением, он всегда работает под нагрузкой, и в таких случаях лучше задействовать высокопроизводительные ядра, а эффективным устроить передышку, так, чтобы на них решались только фоновые задачи или выполнялись системные процессы.
Аналогичным образом отличаются ядра и в процессорах Intel, и это различие особенно хорошо заметно, начиная с их архитектуры 12-го поколения «Alder Lake».
На этих процессорах есть «производительные» ядра (P-cores), предназначенные для решения затратных однопоточных задач, а также «эффективные» (E-cores), работающие над фоновыми задачами и занятые менее интенсивными нагрузками. Производительные ядра могут задействовать технологию гиперпоточности от Intel (то есть, на каждом P-ядре могут выполняться два логических потока), а на всех эффективных ядрах обычно работает по одному потоку. Благодаря такому гибридному подходу на ЦП можно оптимизировать как энергопотребление, так и производительность. Для этого нужно назначать задачи именно на те ядра, которые лучше всего для них подходят.
В архитектурном отношении Android близок к Apple Silicon, так как на большинстве устройств с architecture, Android используется архитектура ARM big.LITTLE (или DynamIQ), в которой предусмотрено два типа ядер: «big» и «LITTLE».
На процессоре Qualcomm для мобильных устройств может быть три типа ядер: «Prime», «Performance» и «Efficiency». Совсем недавно на некоторых моделях телефонов, например, Samsung Galaxy S24, появились и ядра четвёртого типа (Exynos 2400), поэтому возможных комбинаций стало ещё больше.
Итак, все производители ЦП делают ядра, ориентированные на высокую производительность, и ядра, ориентированные на эффективность:
- Производительность: “P-Core”, “big”, “Prime”, “Performance”
- Эффективность: “E-Core”, “LITTLE”, “Efficiency”
Но, если вы попытаетесь распределить рабочую нагрузку сразу по всем имеющимся ядрам (производительным и эффективным) на максимальной мощности, то, возможно, увидите:
- Неоптимальное планирование потоков, так как задачи будут перескакивать между более медленными эффективными ядрами и более быстрыми производительными ядрами.
- Конкуренцию за разделяемые ресурсы, в частности, за память, шину, кэш.
- В особо тяжёлых случаях: пропуск тактов из-за перегрева, срабатывающий, если система достигает своей предельной допустимой температуры. В таком случае тактовая частота процессора планово снижается, чтобы таким образом охладить процессор.
В свою очередь, на AMD нет эффективных ядер. На некоторых ЦП, например, на Ryzen 5 8000 комбинируется два размера ядер — Zen 4 и Zen 4c, но вторые не являются «эффективными» в том смысле, как описано выше, и тоже могут задействоваться для решения тяжеловесных задач.
navigator.hardwareConcurrency
В браузере есть единственный простой API, который можно вызывать для такой работы: navigator.hardwareConcurrency
Он возвращает, сколько доступных логических ядер у вас есть. Поскольку это единственный такой API, доступный в вебе, многие библиотеки по умолчанию используют navigator.hardwareConcurrency как базовое средство для обеспечения конкурентности.
Не рекомендуется использовать это значение напрямую, поскольку, как было сказано выше, можно перегрузить потоки. Кроме того, этому API ничего не известно о текущем уровне активности в системе.
Именно поэтому в формулу ONNX ставится количество логических ядер, делённое на два, и это значение никогда не должно составлять более 4:
Math.min(4, Math.ceil((navigator.hardwareConcurrency || 1) / 2));
В принципе, эта формула работает хорошо, но на некоторых устройствах с ней не удастся эффективно использовать все ядра. Например, на Apple M1 Pro для решения задач машинного обучения можно применить до 8 ядер, а не 4.
Другая крайность — это такой чип как i3-1220p от Intel, который можно задействовать, например, в интерпретаторе команд для тестирования кода под Windows 11…
В нём 12 логических и 10 физических ядер, причём, 8 из этих логических ядер эффективные, а 2 – высокопроизводительные. Если бы мы применили к этому чипу формулу ONNX, то он работал бы с 4 потоками, но 2 подошло бы лучше.
API navigator.hardwareConcurrency хорош в качестве отправной точки, но это грубый инструмент. Он не всегда даст вам действительно лучший вариант конкурентности для конкретного устройства и конкретной рабочей нагрузки.
MLUtils.getOptimalCPUConcurrency
В случаях, когда невозможно добиться наилучшего значения в каждый конкретный момент, не рассматривая активность всей системы в целом, лучше посмотреть, сколько в системе физических ядер и не использовать «эффективные». Возможно, так удастся выйти на лучшее значение.
Например, Llama.cpp, определяясь с подходом к конкурентности, смотрит, сколько в системе физических ядер, с учётом пары оговорок:
- На любой архитектуре x86_64 она вернёт количество производительных ядер
- Под Android и на любых устройствах с архитектурой, основанной на aarch64, например, на Apple Silicon, она укажет, сколько есть производительных ядер на трёхслойных чипах.
NS_IMETHODIMP MLUtils::GetOptimalCPUConcurrency(uint8_t* _retval) {
ProcessInfo processInfo = {};
if (!NS_SUCCEEDED(CollectProcessInfo(processInfo))) {
return NS_ERROR_FAILURE;
}
#if defined(ANDROID)
// Под Android можно использовать «большие» и «средние» ЦП.
uint8_t cpuCount = processInfo.cpuPCount + processInfo.cpuMCount;
#else
# ifdef __aarch64__
// На aarch64 (like macBooks) стоит избегать работы с эффективными ядрами и придерживаться «больших» ЦП.
uint8_t cpuCount = processInfo.cpuPCount;
# else
// на x86_64 всегда будем использовать все имеющиеся физические ядра.
uint8_t cpuCount = processInfo.cpuCores;
# endif
#endif
*_retval = cpuCount;
return NS_OK;
}
Далее эту функцию не составляет труда использовать прямо из JS, а в Firefox она предоставляется прямо в браузере. С её помощью можно сконфигурировать конкурентность, приступая к логическому выводу:
let mlUtils = Cc["@mozilla.org/ml-utils;1"].createInstance(Ci.nsIMLUtils);
const numThreads = mlUtils.getOptimalCPUConcurrency();
Итак, мы отказались от использования navigator.hardwareConcurrency, и лучше будем работать с этим новым API.
Заключение
Стараясь подобрать оптимальное количество потоков, мы стали смотреть на вещи гораздо реалистичнее. Но остаётся учесть и ещё один фактор. Система будет использовать ЦП и для работы с другими приложениями, поэтому сохраняется опасность его перегрузить.
Чем больше потоков используется, тем больше памяти понадобится нам в нашей среде с WASM, и постепенно эта проблема может стать серьёзной. В зависимости от конкретной рабочей нагрузки, каждый новый поток может добавлять до 100 МиБиБ физической памяти, которая будет перетекать в среду выполнения. Это просто издержки, над уменьшением которых можно работать. Но на устройствах, где не так много памяти, лучший вариант – так или иначе, ограничивать конкурентность.
Что касается возможностей машинного обучения в Firefox, в среде этого браузера предусмотрены разнообразные аппаратные профили, поэтому можно тестировать код примерно на тех показателях производительности, которые характерны для интересующих нас пользовательских устройств.
Аппаратный ландшафт активно развивается. Например, на новейших устройствах от Apple в своё время стал применяться новый набор инструкций, AMX. Он был проприетарный, но позволял значительно повысить производительность по сравнению с Neon. Теперь на смену ему пришёл официальный API под названием SME. Аналогично, в некоторых смартфонах появляются ядра новых типов, и это, конечно, влияет на подсчёт, сколько ядер и в каких случаях стоит использовать.