Я пишу плагин для Rhino, который анимирует некоторые данные с трассировкой лучей. Rhino занимается всем рисованием; Мне просто нужно предоставить ему что-нибудь для рисования.
Следующая функция Animate вызывается из потока пользовательского интерфейса с помощью Task.Run(() => Animate(doc, cts.Token, Embree);. Внутри функции есть цикл while, который повторяется до тех пор, пока не будет выброшен CancellationToken или lengthtraveled > maxTimeLength (оба устанавливаются потоком пользовательского интерфейса). Цикл while вычисляет каждый кадр анимации; т. е. шаг отдельных точек на их пути. Когда граница достигнута, Embree вычисляет пересечение приближающегося луча с границей и определяет направление и длину отраженного луча.
Я хотел бы воспользоваться преимуществами многоядерного процессора, стоящего передо мной, и разделить основной цикл for (int i = 0; i < sphereNum; i++) (отмечен ниже) на несколько параллельных потоков.
В настоящее время я достигаю частоты обновления около 45 Гц в одном потоке, но было бы неплохо, если бы она была более производительной для менее производительных (но все же многоядерных) машин.
У меня сложилось впечатление, что Parallel.For(int fromInclusive, int toExclusive, Action<int> body) требует изрядного количества накладных расходов и будет запускать новый набор потоков каждый раз, когда его вызывают, что кажется расточительным, поскольку я знаю, что одна и та же процедура будет просто повторяться до тошноты. Так почему бы не загрузить несколько потоков, каждый из которых вычисляет часть точек (25 000+), а затем ждет отрисовки данных, после чего будет дана команда для расчета следующего шага анимации.
Вот функция Animate:
private void Animate(RhinoDoc doc, CancellationToken cts, EMBContainer Embree)
{
double refreshInterval = 1.0 / 45.0;
int refreshMS = (int)(refreshInterval * 1000.0);
double slowdownFactor = 1.0 / 20.0;
double Speed = 10.0;
double spherestep = refreshInterval * slowdownFactor * Speed;
double lengthtraveled = 0.0;
int sphereNum = 25000;
spheres = new RaySphereData[sphereNum];
points = new Rhino.Geometry.PointCloud(Enumerable.Repeat(StartPoint, sphereNum));
int refreshcounter = 1, resettozero = 0;
long waited = 0;
System.Numerics.Vector3 start, direction;
var vectors = StartingVectors();
start = StartPoint.;
for (int i = 0; i < sphereNum; i++)
{
direction = vectors[i];
Embree.RTCRayHit hits = Embree.Intersect(start, direction);
points[i].Location = StartPoint;
spheres[i].step = direction;
spheres[i].step *= spherestep;
spheres[i].pathlength = hits.Ray.tfar;
spheres[i].done = false;
hits.Hit.Normalize();
spheres[i].nextdirection = Vector3.Reflect(direction, hits.Hit.normal);
spheres[i].nextstart = start + direction * hits.Ray.tfar;
}
Stopwatch stopwatch2 = new Stopwatch();
Stopwatch stopwatch3 = new Stopwatch();
double interimlength;
Stopwatch stopwatch = Stopwatch.StartNew();
Stopwatch stopwatch1 = Stopwatch.StartNew();
while (true)
{
stopwatch2.Start();
if (cts.IsCancellationRequested) return;
var delay = Task.Delay(refreshMS);
doc.Views.Redraw();
lengthtraveled += spherestep;
if (lengthtraveled > maxTimeLength)
{
return;
}
else
{
// start multi-threading here; i.e. send Monitor, Barrier or Semaphore signal to the animation frame compute function
for (int i = 0; i < sphereNum; i++)
{
if (lengthtraveled > spheres[i].pathlength)
{
do
{
interimlength = lengthtraveled - spheres[i].pathlength;
direction = spheres[i].nextdirection;
start = spheres[i].nextstart;
var hits = Embree.Intersect(start, direction);
spheres[i].pathlength += hits.Ray.tfar;
spheres[i].step = direction.ToVector3d();
spheres[i].step.Unitize();
points[i].Location = spheres[i].nextstart.ToPoint3d() + (spheres[i].step * interimlength);
spheres[i].nextstart = start + direction * hits.Ray.tfar;
hits.Hit.Normalize();
spheres[i].nextdirection = Vector3.Reflect(direction, hits.Hit.normal);
}
while (lengthtraveled > spheres[i].pathlength);
spheres[i].step *= spherestep;
}
else
{
points[i].Location += spheres[i].step;
}
}
// end multi-threading
}
stopwatch3.Start();
await delay;
stopwatch3.Stop();
stopwatch2.Stop();
waited += stopwatch3.ElapsedMilliseconds;
stopwatch3.Reset();
stopwatch2.Reset();
if (refreshcounter % 100 == 0)
{
stopwatch1.Stop();
refreshInterval = (stopwatch1.ElapsedMilliseconds - waited) / (1000.0 * refreshcounter);
refreshMS = (int)(refreshInterval * 1000.0);
spherestep = refreshInterval * slowdownFactor * Speed;
for (int i = 0; i < sphereNum; i++)
{
spheres[i].step.Unitize();
spheres[i].step *= spherestep;
}
stopwatch1.Start();
}
refreshcounter++;
}
}
private struct RaySphereData
{
public double pathlength;
public Rhino.Geometry.Vector3d step;
public System.Numerics.Vector3 nextstart;
public System.Numerics.Vector3 nextdirection;
}
Я изучил Barrier, Monitor и SemaphoreSlim, но либо из-за моего непонимания, либо из-за недостаточности документации по этим функциям .NET я не смог заставить их работать. Есть ли предложения? (Кроме того, если есть какие-нибудь лучшие предложения по адаптации частоты обновления, я полностью за.)
Полезное чтение (проверьте все ответы!): stackoverflow.com/questions/12405938/…
Впечатление не совсем неверное, всего лишь на 99,99%. Запуск и планирование 25 тысяч потоков сопряжены с огромными накладными расходами. Вы не можете одновременно запускать больше потоков, чем имеется ядер. Parallel.For по умолчанию использует столько рабочих задач, сколько имеется ядер, или, точнее, число, возвращаемое Environment.ProcessorCount. Это создает впечатление блокировки. PFOR разделяет данные и использует одну рабочую задачу для обработки каждого раздела. Он не создает новые потоки каждый раз, он использует текущий поток и потоки многократного использования для пула потоков для рабочих задач.
Parallel.For/ForEach создан именно для такого типа до неловкости параллельных задач. Фактически, трассировка лучей — это один из первых примеров, использованных для демонстрации параллельного подхода. Еще в 2007 году
Более свежий пример трассировки лучей в WinForms представлен в .NET Core с параллельным анимированным прыгающим мячом с трассировкой лучей , но фактический цикл трассировки лучей остается прежним - PFOR на столбцах, чтобы воспользоваться преимуществами локальности данных.
Rhino использует .NET 8, версию .NET Core. Почему вы используете тег .NET Framework 4.8?
Только Rhino8 использует .NET 8; Rhino 6, 7 и 8 используют .NET Framework 4.8. МакНил рекомендует ориентироваться на .NET Framework 4.8 в целом для более широкой совместимости.
Это не меняет принцип работы Parallel.For, но означает, что вы теряете множество улучшений в SIMD и числовых вычислениях в целом. Вы проверили пример трассировки лучей и проверили эффект DOP? Вы пробовали распараллеливать построчно? Если вы перейдете от 45 для последовательного режима к 60 для параллельного, вы получите ужасную производительность. Это всего лишь 30% улучшение на машине, которая, вероятно, имеет как минимум 2 ядра, в решении проблемы, которая должна привести как минимум к двукратному ускорению за счет распараллеливания. На кваде 30% это просто ужас. Это означает, что все, что вы пробовали, требует огромных затрат на синхронизацию.
@NoseHornScribe Я загрузил образец Raytracing, и на моей 6-ядерной машине улучшение составило не менее 400%. От 1,4 FPS при последовательном рендеринге до 5,6 FPS при полном параллелизме.
@Panagiotis Улучшение частоты с 45 Гц до 60 Гц включает в себя существенные (но все же очень эффективные) накладные расходы, которые Rhino накладывает на свои процедуры рисования экрана, как в строке doc.Views.Redraw(); Когда-нибудь я перейду на Rhino8 и посмотрю, получу ли я некоторое улучшение производительности от обновленная версия .NET. Я не знаю наверняка, использует ли RhinoCommon SIMD за кулисами для различных векторных математических функций, но однажды я провел быстрый тест, который намекнул, что это так.





У меня сложилось впечатление, что
Parallel.Forтребует значительных накладных расходов и будет запускать новый набор потоков при каждом вызове.
Ваше впечатление ошибочно. По умолчанию метод Parallel.For использует текущий поток, а также потоки из ThreadPool . Вы можете изменить значение по умолчанию, предоставив собственный TaskScheduler , но для этого нет причин. Я бы посоветовал вам указать MaxDegreeOfParallelism, чтобы параллельный цикл не насыщал ThreadPool:
ParallelOptions parallelOptions = new()
{
MaxDegreeOfParallelism = Environment.ProcessorCount
};
Parallel.For(0, 25000, parallelOptions, i =>
{
// ...
});
Если ваше приложение интенсивно использует ThreadPool, вы можете рассмотреть возможность увеличения порога для немедленного создания потока с помощью API ThreadPool.SetMinThreads.
Так почему бы не загрузить несколько потоков, каждый из которых вычисляет часть точек [...]?
Потому что маловероятно, что ваш индивидуальный подход будет лучше, чем инструменты, предоставляемые стандартными библиотеками .NET. Скорее всего будет хуже, касательно либо производительности, либо поведения, либо корректности, либо всего вышеперечисленного.
Спасибо. Хорошее и понятное объяснение системы. Используя Parallel.For, я мог увеличить частоту примерно с 45 Гц до 60 Гц.
Впечатление неверное. В примере .NET Raytracing параллелизм приводит к повышению производительности на 300 % по сравнению с последовательным рендерингом.
Parallel.For был создан для сокращения, если не полного устранения, накладных расходов. При параллельной обработке максимальное улучшение производительности, которого вы можете достичь, ограничено нераспараллеливаемой частью кода. Чем выше потребность в синхронизации, тем меньшую производительность вы можете получить.
В аналогичном вопросе Результаты параллельного и последовательного вычислений непонятны, правильное использование Parallel.For при умножении матриц и забота о локальности данных привели к 9-кратному улучшению на 6-ядерной машине с гиперпоточностью без использования операций SIMD. Эти дополнительные 50% — это то, чего можно было бы ожидать от гиперпоточности.
BenchmarkDotNet=v0.13.1, OS=Windows 10.0.22621
Intel Core i7-10850H CPU 2.70GHz, 1 CPU, 12 logical and 6 physical cores
.NET SDK=6.0.302
[Host] : .NET 6.0.7 (6.0.722.32202), X64 RyuJIT
DefaultJob : .NET 6.0.7 (6.0.722.32202), X64 RyuJIT
Method | Mean | Error | StdDev | Ratio |
----------------- |----------:|---------:|---------:|------:|
SerialMult | 288.63 ms | 5.736 ms | 8.585 ms | 1.00 |
ParallelMult | 75.92 ms | 1.311 ms | 1.162 ms | 0.26 |
ParallelMultTemp | 49.63 ms | 0.873 ms | 0.817 ms | 0.17 |
ParallelMultVect | 33.04 ms | 0.588 ms | 0.521 ms | 0.11 |
Параллельно. Для трассировки лучей.
Parallel.For идеально подходит для решения невероятно параллельных задач, таких как трассировка лучей, где на вычисления для каждого пикселя не влияют вычисления для других пикселей. Фактически, трассировка лучей — это один из первых Parallel.For примеров, восходящий к статье журнала MSDN Magazine за 2007 год . Аналогичный пример для WinForms на .NET Core (включая .NET 5 и более поздние версии) можно найти в репозитории .NET Samples: .NET Core параллельный анимированный прыгающий мяч с трассировкой лучей
В этом примере метод параллельного рендеринга прост:
internal void RenderParallel(Scene scene, int[] rgb, ParallelOptions options)
{
try
{
Parallel.For(0, _screenHeight, options, y =>
{
int stride = y * _screenWidth;
Camera camera = scene.Camera;
for (int x = 0; x < _screenWidth; x++)
{
Color color = TraceRay(new Ray(camera.Pos, GetPoint(x, y, camera)), scene, 0);
rgb[x + stride] = color.ToInt32();
}
});
}
catch (OperationCanceledException)
{
// Catch this to prevent the UI from crashing, we know a cancellation will occur.
}
}
Прирост производительности составил более 300% — примерно с 5 FPS при последовательной обработке до как минимум 15 FPS при полном параллелизме.
Местоположение данных
По сути, этот код параллельно обрабатывает отдельные строки, а не отдельные точки. Parallel.For работает путем разделения данных на столько разделов, сколько имеется ядер, и использования одной рабочей задачи для каждого раздела. Это сводит необходимость синхронизации между потоками к минимуму. Распараллеливая вдоль строк, код увеличивает кэш обращений к кешу, поскольку точки в строке имеют более высокую вероятность совместной загрузки в кеш ЦП.
В приведенном выше тесте использование локальной временной копии повысило производительность с 3 раз почти до 6 раз. Использование локальной копии строки привело к окончательному улучшению в 9 раз.
Другими словами, использование Parallel.For(0, 25000 — плохая идея, поскольку оно предотвращает разбиение и приводит к худшему поведению кэширования, а также не позволяет одновременно вычислить количество точек, превышающее количество ядер. По крайней мере, это не создаст больше потоков, чем ядер.
Параллельно.Для исполнения
Рабочие задачи не выполняются в новых потоках. Они выполняются в многоразовых потоках, поступающих из пула потоков. Parallel.For также использует текущую цепочку, что создает впечатление блокировки. Чтобы избежать блокировки пользовательского интерфейса, цикл рендеринга примера выполняется в фоновой задаче :
Task.Factory.StartNew(RenderLoop,_cancellation.Token, _cancellation.Token)
...
По умолчанию Parallel.For использует столько рабочих процессов, сколько имеется ядер, или, точнее, он использует Environment.ProcessorCount для установки начального MaxDegreeOfParallelism. На 8-ядерной машине это 8. На 8-ядерной с Hyperthreading это 16.
Вы можете изменить количество рабочих, изменив свойство ParallelOptions.MaxDegreeOfParallelism. В примере начальное значение равно стандартному количеству ядер:
private int _degreeOfParallelism = Environment.ProcessorCount;
А как насчет 25 тысяч потоков?
С другой стороны, запуск 25-килобайтных потоков приводит к огромным накладным расходам, поскольку все эти потоки стремятся запланироваться на одних и тех же ядрах, в результате чего их стеки каждый раз загружаются и выгружаются. Размер стека по умолчанию в .NET составляет 4 МБ, поэтому потоки 25 КБ приведут к загрузке и выгрузке большого количества данных только для запуска потока. И все это просто для запуска 8 потоков одновременно.
Хуже того, нет определенного порядка в планировании потоков, что означает, что точки обработки по сути случайны, что увеличивает вероятность промахов в кэше.
Ваше впечатление может быть ошибочным?
Parallel.Forиспользует (и повторно использует) потоки из пула потоков. В некоторых случаях может быть полезно ограничить количество потоков, используя свойствоMaxDegreeOfParallelismпараметраParallelOptions. Поскольку это будет намного проще, чем альтернативы, которые вы предлагаете, почему бы вам не попробовать (и, возможно, оценить, чтобы настроить значениеMaxDegreeOfParallelism)?