Почему ваш Parallel.ForEach впустую сжигает CPU — ускоряем обработку данных до 600+ раз

от автора

Практически в каждом высоконагруженном .NET-проекте рано или поздно появляется один и тот же паттерн:

Есть коллекция данных.

Для каждого элемента нужно выполнить дорогую операцию.

Например:

  • вычислить хэш;

  • получить эмбеддинг;

  • обратиться к ИИ-модели;

  • построить отчёт;

  • обработать изображение;

  • выполнить финансовый расчёт;

  • провести нормализацию данных.

На первый взгляд решение очевидно:

Parallel.ForEach(items, item =>{    Process(item);});

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

Например:

  • один и тот же пользователь встречается десятки раз;

  • один и тот же документ приходит из разных источников;

  • одинаковые сообщения попадают в очередь;

  • одинаковые строки требуют одинакового преобразования.

В такой ситуации Parallel.ForEach честно пересчитывает всё заново.

Даже если вычисление уже выполнялось секунду назад.

Даже если результат уже лежит в памяти.

Даже если в коллекции 99% одинаковых данных.

Именно эту проблему решает Principium.Parallel.

Когда это действительно нужно

Библиотека не пытается заменить TPL.

Если у вас уникальные данные и дешёвая обработка — используйте обычный Parallel.ForEach.

Но есть несколько сценариев, где выигрыш может достигать десятков и сотен раз.

Сценарий 1. Генерация эмбеддингов

Допустим, вы строите RAG.

В коллекции 100 000 документов.

Из них 80 000 уже индексировались раньше.

Без кэша:

100 000 вызовов embedding model

С Principium:

20 000 вызовов embedding model

20 000 вызовов embedding model

Остальное берётся из памяти.

Сценарий 2. Массовая обработка сообщений

В очереди постоянно встречаются повторяющиеся события:

User 1 updatedUser 1 updatedUser 1 updatedUser 1 updatedUser 1 updated

В большинстве случаев интересует только последнее состояние.

Повторные вычисления просто сжигают CPU.

Сценарий 3. ETL и Data Processing

При импорте миллионов записей часто встречаются повторяющиеся ключи.

Типичный код выглядит так:

Parallel.ForEach(rows, row =>{    Normalize(row);});

На практике половина процессорного времени уходит на обработку одинаковых данных.

Сценарий 4. Работа с LLM

Если вычисление стоит дорого:

payload => llm.Generate(payload)

то даже небольшое количество дубликатов превращается в огромные потери времени и денег.

Что предлагает Principium

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

Под капотом существуют три режима.

ParallelOnly

Используется при низком количестве дубликатов.

Максимально похож на обычный Parallel.ForEach.

Дубликатов мало→ кэш не нужен→ просто параллельное выполнение

CacheOnly

Используется при среднем количестве дубликатов.

Сначала проверяем кэшПотом считаем только отсутствующие значения

CoalesceAndCache

Используется при высокой дупликации.

Дубликаты схлопываются+используется кэш+вычисления выполняются параллельно

Именно этот режим даёт максимальный выигрыш.

Установка

Через .NET CLI:

dotnet add package Principium.Parallel

Через Package Manager:

Install-Package Principium.Parallel

Подключаем пространство имён:

using Principium;

Первый пример

Допустим, есть список пользователей.

var users = Enumerable.Range(1, 10000)      .Select(x => $"User_{x}")      .ToList();

Обработка:

var results = Paralleling.ForEach(      users,      keySelector: x => x,      payloadSelector: x => x,      work: value =>    {            Thread.Sleep(10);            return value.ToUpperInvariant();      });

Результат:

Console.WriteLine(results["User_100"]);

Пример с дубликатами

record UserEvent(int UserId, string Payload);var events = new[]{    new UserEvent(1, "A"),    new UserEvent(1, "B"),    new UserEvent(1, "C"),    new UserEvent(2, "D")};

Запуск:

var result = Paralleling.ForEach(      events,      x => x.UserId,      x => x.Payload,      payload =>    {            return payload.ToUpperInvariant();      });

Для UserId = 1 останется только последнее значение.

То есть будет соблюдена семантика:

Last Write Wins (LWW)

Настройка

Поведение можно изменить через PrincipiumOptions.

var options = new PrincipiumOptions{    SampleSize = 4096,    LowDupThreshold = 0.05,    HighDupThreshold = 0.80,    RequireLww = true,    Ttl = TimeSpan.FromMinutes(10),    CacheCapacity = 100000};

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

var result = Paralleling.ForEach(      source,      x => x.Id,      x => x.Payload,      Process,      options);

Переиспользование кэша между вызовами

Самый интересный режим.

Можно передать adaptiveKey:

var result = Paralleling.ForEach(      source,      x => x.Id,      x => x.Payload,      Process,      adaptiveKey: "orders");

Теперь внутренний движок и кэш будут использоваться повторно.

Если данные приходят пачками и содержат повторения, производительность может вырасти на порядок.

Что происходит внутри

Для каждого значения строится 128-битный отпечаток:

FNV-128 fingerprint

Затем происходит:

  1. Проверка кэша.

  2. Проверка срока жизни.

  3. Проверка совпадения отпечатка.

  4. Возврат результата без повторного вычисления.

По умолчанию сравнение строгое.

Также можно использовать Hamming Distance для нестрогого совпадения.

var options = new PrincipiumOptions{    HammingThreshold = 8};

Бенчмарки

Тестовый стенд:

Windows 11.NET 810 000 элементовТяжёлая CPU-bound нагрузка

Результаты:

Сценарий

Parallel.ForEach

Dict LWW

MemoryCache

Principium Cold

Principium Warm

99% дубликатов

682 ms

14 ms

15 ms

19 ms

1 ms

90% дубликатов

632 ms

80 ms

81 ms

619 ms

4 ms

50% дубликатов

651 ms

376 ms

361 ms

2884 ms

74 ms

10% дубликатов

593 ms

384 ms

399 ms

4242 ms

318 ms

0% дубликатов

1013 ms

485 ms

397 ms

4589 ms

929 ms

Почему Principium Cold медленнее

На это стоит обратить внимание.

Многие смотрят только на колонку Cold и делают неправильный вывод.

Cold означает:

Каждый запуск создаётся новый Engine+Новый кэш+Новая структура анализа

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

Фактически это стресс-тест внутренних накладных расходов.

В реальных системах почти всегда используется Warm-сценарий.

Почему Principium Warm быстрее

Посмотрим на сценарий с 99% дубликатов.

Обычный Parallel.ForEach:

682 ms

Principium Warm:

1 ms

Ускорение:

682x

Причина проста.

Вместо 10 000 вычислений происходит около 100.

Остальное возвращается из памяти.

Где это даёт максимальный эффект

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

На практике большинство высоконагруженных систем постоянно работают с повторяющейся информацией.

AI и LLM

Самый очевидный пример последних лет.

Допустим, вы генерируете эмбеддинги для документов:

embeddingModel.GenerateEmbedding(text);

Документ уже индексировался вчера.

Потом сегодня.

Потом после редактирования базы знаний.

Потом после очередного деплоя.

Обычный пайплайн будет генерировать эмбеддинг заново.

Principium просто вернёт результат из кэша.

Если один вызов OpenAI стоит несколько сотен миллисекунд и деньги за токены, экономия получается не только по времени, но и по бюджету.

RAG-системы

Практически любой корпоративный RAG регулярно переиндексирует документы.

Например:

  • инструкции;

  • договоры;

  • техническую документацию;

  • базу знаний.

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

Дедупликация вычислений здесь даёт один из самых больших эффектов.

ETL и Data Engineering

Классическая ситуация:

Есть CSV на несколько миллионов строк.

Внутри постоянно встречаются одинаковые записи клиентов.

CustomerId = 123CustomerId = 123CustomerId = 123CustomerId = 123

Нормализация адресов.

Очистка данных.

Проверка справочников.

Расчёт показателей.

Один и тот же результат вычисляется снова и снова.

Финансовые системы

Риск-модели, скоринг, антифрод.

В течение дня один и тот же клиент может появляться в десятках операций.

Без дедупликации вычисления повторяются многократно.

При дорогих расчётах это превращается в прямые затраты на инфраструктуру.

Логистика

Расчёт маршрутов.

Проверка складских остатков.

Расчёт тарифов доставки.

Очень часто одинаковые грузы или заказы обрабатываются многократно разными сервисами.

Кэширование вычислений позволяет снять существенную нагрузку с CPU.

Обработка изображений

Например:

DetectFaces(image);

или

GenerateThumbnail(image);

Если одно изображение встречается несколько раз, повторные вычисления не имеют смысла.

Видеоаналитика

Видеокадры часто обрабатываются пакетами.

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

IoT и телеметрия

Датчики нередко отправляют одинаковые данные тысячи раз подряд.

Например:

Температура = 21.4Температура = 21.4Температура = 21.4Температура = 21.4

Повторная обработка таких сообщений не несёт дополнительной ценности.

Очереди сообщений

Kafka.

RabbitMQ.

Azure Service Bus.

Практически в любой распределённой системе периодически возникают повторные события.

Особенно после ретраев и восстановления после сбоев.

Если обработчик тяжёлый, стоимость повторного выполнения быстро становится заметной.

Batch-задачи

Ночные пересчёты.

Отчёты.

Агрегации.

Подготовка витрин данных.

Именно здесь часто встречаются сценарии с дупликацией 80–99%, где Principium показывает максимальный выигрыш.

Интеграция в ASP.NET Core

Регистрация:

builder.Services.AddSingleton<MyProcessor>();

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

public class MyProcessor{      public Dictionary<int,string> Process(          IEnumerable<MyItem> items)    {          return Paralleling.ForEach(                items,                x => x.Id,                x => x.Payload,                HeavyWork,                adaptiveKey: "main-pipeline");    }}

Кэш будет использоваться между HTTP-запросами.

Когда НЕ стоит использовать Principium

Есть ситуации, где обычный Parallel.ForEach лучше.

Например:

  • дубликатов нет;

  • вычисления очень дешёвые;

  • результат никогда не повторяется;

  • данные одноразовые.

В этом случае накладные расходы анализа будут выше выгоды.

И это нормально.

Библиотека рассчитана именно на сценарии с повторяемостью данных.

Сравнение подходов

Подход

Повторное использование результатов

LWW

Кэш

Автоадаптация

Parallel.ForEach

Нет

Нет

Нет

Нет

Dictionary + Parallel

Частично

Да

Нет

Нет

MemoryCache

Да

Нет

Да

Нет

Principium.Parallel

Да

Да

Да

Да

Где взять

NuGet:

https://www.nuget.org/packages/Principium.Parallel

GitHub (бенчмарки):

https://github.com/likeslines-maker/Principium.Parallel

Библиотека полностью бесплатна для:

  • тестирования;

  • исследований;

  • обучения;

  • прототипирования;

  • разработки;

  • нагрузочного тестирования;

  • проверки концепций (PoC).

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

Резюме

Principium.Parallel — это адаптивный движок обработки коллекций для .NET, который автоматически выбирает между параллелизмом, кэшированием и дедупликацией.

Он особенно полезен когда:

  • данные содержат много повторов;

  • вычисления дорогие;

  • важна Last-Write-Wins семантика;

  • нужно переиспользовать результаты между запусками.

В сценариях с высокой дупликацией выигрыш может достигать сотен раз относительно обычного Parallel.ForEach.

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

ссылка на оригинал статьи https://habr.com/ru/articles/1041524/