Сколько ниток я должен иметь и для чего?

81

Должен ли я иметь отдельные потоки для рендеринга и логики, или даже больше?

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

Я думал о том, чтобы довести это до крайности и создать потоки для всех мыслимых подсистем. Но я беспокоюсь, что это тоже может замедлить ход событий. (Например, разумно ли отделять входной поток от потоков рендеринга или игровой логики?) Может ли необходимая синхронизация данных сделать это бессмысленным или даже медленным?

J RIV
источник
6
какая платформа? ПК, консоль NextGen, смартфоны?
Эллис
Я могу подумать об одной вещи, которая потребует многопоточности; сетей.
Мыльный
прекрати преувеличения, нет "огромного" замедления, когда задействованы блокировки. это городская легенда и предубеждение.
v.oddou

Ответы:

61

Откровенно говоря, общий подход к использованию нескольких ядер просто ошибочен. Разделение ваших подсистем на разные потоки действительно разделит часть работы на несколько ядер, но у нее есть некоторые серьезные проблемы. Во-первых, с ним очень сложно работать. Кто хочет возиться с блокировками, синхронизацией, связью и прочим, когда вместо этого они могут просто писать код для рендеринга или физический код? Во-вторых, подход на самом деле не расширяется. В лучшем случае это позволит вам использовать, возможно, три или четыре ядра, и это если вы действительно знаете, что делаете. В игре только так много подсистем, а из них еще меньше, которые занимают большие куски процессорного времени. Есть пара хороших альтернатив, которые я знаю.

Одним из них является наличие основного потока вместе с рабочим потоком для каждого дополнительного ЦП. Независимо от подсистемы основной поток делегирует изолированные задачи рабочим потокам через своего рода очереди; эти задачи сами могут создавать и другие задачи. Единственной целью рабочих потоков является то, чтобы каждый захватывал задачи из очереди по одному и выполнял их. Тем не менее, наиболее важным является то, что, как только потоку нужен результат задачи, если задача завершена, он может получить результат, а если нет, то может безопасно удалить задачу из очереди и продолжить и выполнить это. Само задание. То есть не все задачи будут планироваться параллельно друг другу. Имея больше задач , чем может выполняться параллельно является хорошимвещь в этом случае; это означает, что он может масштабироваться при добавлении большего количества ядер. Одним из недостатков этого является то, что для разработки достойной очереди и рабочего цикла требуется много усилий, если у вас нет доступа к библиотеке или языковой среде выполнения, которая уже обеспечивает это для вас. Самое сложное - убедиться, что ваши задачи действительно изолированы и надежно защищены от потоков, а также убедиться, что ваши задачи находятся в хорошем положении между крупнозернистым и мелкозернистым.

Другой альтернативой потокам подсистем является распараллеливание каждой подсистемы изолированно. То есть вместо того, чтобы запускать рендеринг и физику в своих собственных потоках, напишите подсистему физики, которая будет использовать все ваши ядра одновременно, напишите подсистему рендеринга, которая будет использовать все ваши ядра одновременно, а затем просто запустите две системы (или с чередованием, в зависимости от других аспектов вашей игровой архитектуры). Например, в подсистеме физики вы можете взять все точечные массы в игре, разделить их между ядрами, а затем заставить все ядра обновлять их одновременно. Каждое ядро ​​может работать с вашими данными в тесных циклах с хорошей локализацией. Этот стиль параллелизма с блокировкой шагов аналогичен тому, что делает GPU. Самое сложное в этом состоит в том, чтобы убедиться, что вы делите свою работу на мелкозернистые куски, чтобы делить ее равномернофактически приводит к одинаковому объему работы на всех процессорах.

Однако иногда из-за политики, существующего кода или других неприятных обстоятельств проще всего дать каждой подсистеме поток. В этом случае лучше избегать создания большего количества потоков ОС, чем ядер, для тяжелых рабочих нагрузок ЦП (если у вас есть среда выполнения с легкими потоками, которые просто сбалансированы между вашими ядрами, это не так уж сложно). Также избегайте чрезмерного общения. Один хороший трюк - попробовать конвейерную работу; каждая основная подсистема может одновременно работать в разных игровых состояниях. Конвейерная конвейерная обработка уменьшает количество коммуникаций, необходимых между вашими подсистемами, так как им не нужен доступ к одним и тем же данным в одно и то же время, и это также может свести на нет часть ущерба, вызванного узкими местами. Например, если ваша физическая подсистема имеет тенденцию занимать много времени и ваша подсистема рендеринга всегда ждет ее, ваша абсолютная частота кадров может быть выше, если вы запустите физическую подсистему для следующего кадра, пока подсистема рендеринга все еще работает на предыдущем Рамка. На самом деле, если у вас есть такие узкие места и вы не можете их устранить каким-либо другим способом, конвейерная обработка может быть наиболее законной причиной для беспокойства с потоками подсистемы.

Джейк Макартур
источник
«как только потоку нужен результат задачи, если задача завершена, он может получить результат, а если нет, то может безопасно удалить задачу из очереди и продолжить выполнение этой задачи самостоятельно». Вы говорите о задаче, созданной той же веткой? Если это так, то не имеет ли смысла, если эта задача выполняется потоком, который породил эту задачу?
jmp97
т. е. поток мог, не планируя задачу, выполнить эту задачу сразу.
jmp97
3
Дело в том, что поток не обязательно заранее знает, будет ли лучше запускать задачу параллельно или нет. Идея состоит в том, чтобы спекулятивно инициировать работу, которая вам в конечном итоге понадобится, и если другой поток окажется в режиме ожидания, он может продолжить и выполнить эту работу за вас. Если это не произойдет к тому времени, когда вам понадобится результат, вы можете просто вытащить задачу из очереди самостоятельно. Эта схема предназначена для динамической балансировки рабочей нагрузки между несколькими ядрами, а не статически.
Джейк Макартур
Извините, что так долго возвращался к этой теме. Я не обращаю внимание на gamedev в последнее время. Это, вероятно, лучший ответ, тупой, но конкретный и обширный.
J Riv
1
Вы правы в том смысле, что я пренебрег разговорами о нагрузках ввода-вывода. Моя интерпретация вопроса заключалась в том, что речь шла только о нагрузке на процессор.
Джейк Макартур
30

Есть пара вещей, чтобы рассмотреть. Маршрут потока на подсистему легко обдумать, так как разделение кода довольно очевидно с самого начала. Однако, в зависимости от того, сколько межсетевого взаимодействия необходимо вашим подсистемам, межпотоковое взаимодействие может реально снизить вашу производительность. Кроме того, это масштабируется только до N ядер, где N - это количество подсистем, которые вы абстрагируете в потоки.

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

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

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

Боб Сомерс
источник
Система, о которой вы говорите, очень похожа на систему планирования, упомянутую в ответе «Другого Джеймса», но в этой области она еще хороша, так что +1 добавляет к обсуждению.
Джеймс
3
Вики сообщества о том, как настроить очередь заданий и рабочие потоки, было бы неплохо.
bot_bot
23

На этот вопрос нет лучшего ответа, поскольку он зависит от того, чего вы пытаетесь достичь.

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

Многие игры, как правило, были однопоточными для простоты программирования. Это хорошо для большинства личных игр. Единственное, для чего вам, вероятно, понадобится другой поток, это Сеть и Аудио.

В Unreal есть игровой поток, рендер-поток, сетевой поток и аудио-поток (если я правильно помню). Это довольно стандартно для многих движков текущего поколения, хотя возможность поддержки отдельного потока рендеринга может быть болезненной и требует много работы.

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

Технология, которую я использую (и написал), имеет отдельный поток для работы в сети, ввода, аудио, рендеринга и планирования. Затем он имеет любое количество потоков, которые можно использовать для выполнения игровых задач, и это управляется потоком планирования. Много работы пошла в получении всех нитей , чтобы хорошо играть друг с другом, но это , кажется, работает хорошо и получать очень хорошее использование из системы многоядерной, поэтому , возможно , это миссия выполнены (на данный момент, я мог бы сломать аудио / сетей / input работает только с «задачами», которые могут обновлять рабочие потоки).

Это действительно зависит от вашей конечной цели.

Джеймс
источник
+1 за упоминание о системе планирования .. обычно это хорошее место для центрирования потока / системной связи :)
Джеймс
Почему отрицательный голос, downvoter?
Jcora
12

Поток на подсистему - неправильный путь. Внезапно ваше приложение не масштабируется, потому что некоторые подсистемы требуют намного больше, чем другие. Это был многопоточный подход, принятый Supreme Commander, и он не масштабировался за пределы двух ядер, потому что у них было только две подсистемы, которые занимали значительное количество рендеринга процессора и физики / игровой логики, хотя у них было 16 потоков, остальные потоки едва ли что-то стоило, и в результате игра масштабировалась только до двух ядер.

Что вы должны сделать, это использовать то, что называется пул потоков. Это в некоторой степени отражает подход, принятый на графических процессорах, то есть вы публикуете работу, и любой доступный поток просто приходит и делает это, а затем возвращается к ожиданию работы - думайте о нем как о кольцевом буфере потоков. Этот подход имеет преимущество масштабирования по N-ядру и очень хорошо подходит для масштабирования как с низким, так и с высоким числом ядер. Недостатком является то, что для этого подхода довольно сложно работать с владельцем потока, так как невозможно знать, какой поток выполняет какую работу в любой момент времени, поэтому необходимо очень жестко фиксировать проблемы владения. Это также затрудняет использование таких технологий, как Direct3D9, которые не поддерживают несколько потоков.

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

DeadMG
источник
Проще говоря: графические процессоры не используют пулы потоков, а планировщик потоков реализован на аппаратном уровне, что делает создание новых потоков и переключение потоков очень дешевым, в отличие от процессоров, где создание потоков и переключение контекста обходятся дорого. См., Например, Руководство программиста Nvidias CUDA.
Нильс
2
+1: лучший ответ здесь. Я бы даже использовал более абстрактные конструкции, чем пулы потоков (например, очереди заданий и рабочие), если ваша инфраструктура это позволяет. Гораздо проще думать / программировать в этих терминах, чем в чистых потоках / блокировках / и т.д. Плюс: разделение вашей игры на рендеринг, логику и т. Д. - это нонсенс, поскольку рендеринг должен ждать завершения логики. Скорее создайте задания, которые на самом деле могут выполняться параллельно (например: вычислить AI для одного NPC для следующего кадра).
Дейв О.
@DaveO. Твой «плюс» так и есть, правда.
Инженер
11

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

  1. Знайте свои данные и храните их в памяти в соответствии с вашими потребностями обработки. Это позволяет планировать параллельные вычисления без необходимости синхронизации. К сожалению, в большинстве случаев это довольно сложно достичь, так как к данным часто обращаются из разных систем в непредсказуемое время.

  2. Определите четкое время доступа к данным. Вы можете разделить свой основной тик на x фаз. Если вы уверены, что поток X читает данные только в определенной фазе, вы также знаете, что эти данные могут быть изменены другими потоками в другой фазе.

  3. Двойной буфер ваших данных. Это наиболее простой подход, но он увеличивает задержку, поскольку поток X работает с данными из последнего кадра, а поток Y подготавливает данные для следующего кадра.

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

Чтобы принять во внимание некоторые аппаратные ограничения, вы должны стараться никогда не переподписывать свое оборудование. Под переподпиской я подразумеваю иметь больше программных потоков, чем аппаратных потоков вашей платформы. Особенно на архитектурах PPC (Xbox360, PS3) переключение задач действительно дорого. Конечно, это нормально, если у вас есть несколько переподписанных потоков, которые запускаются только в течение небольшого промежутка времени (например, один раз за кадр). Если вы ориентируетесь на ПК, вам следует помнить, что количество ядер (или лучше HW) -Threads) постоянно растет, поэтому вы хотели бы найти масштабируемое решение, которое использует преимущества дополнительного CPU-Power. Таким образом, в этой области вы должны попытаться разработать свой код как можно более на основе задач.

DarthCoder
источник
3

Общее правило для многопоточности приложения: 1 поток на ядро ​​ЦП. На четырехъядерном ПК это означает 4. Как было отмечено, XBox 360, тем не менее, имеет 3 ядра, но по два аппаратных потока каждое, в данном случае 6 потоков. В такой системе, как PS3 ... ну, удачи в этом :) Люди все еще пытаются понять это.

Я бы посоветовал проектировать каждую систему в виде отдельного модуля, который вы могли бы использовать, если хотите. Обычно это означает наличие очень четко определенных путей связи между модулем и остальной частью двигателя. Мне особенно нравятся процессы, доступные только для чтения, такие как рендеринг и аудио, а также процессы «мы там еще», такие как чтение входных данных плеера для того, чтобы что-то пропустить. Чтобы затронуть ответ, данный AttackingHobo, когда вы рендеринге 30-60 кадров в секунду, если ваши данные устарели на 1 / 30-1 / 60th секунды, это на самом деле не отвлекает от отзывчивого ощущения вашей игры. Всегда помните, что основное различие между прикладным программным обеспечением и видеоиграми - делать все 30-60 раз в секунду. На этой же ноте, однако,

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

Джеймс
источник
2
Xbox360 имеет 2 аппаратных потока на ядро, поэтому оптимальное количество потоков - 6.
DarthCoder
Ах, +1 :) Я всегда был ограничен сетевыми областями 360 и PS3, хе-хе :)
Джеймс
0

Я создаю один поток на каждое логическое ядро ​​(минус один для учета основного потока, который, кстати, отвечает за рендеринг, но в остальном действует также как рабочий поток).

Я собираю события устройства ввода в реальном времени по всему кадру, но не применяю их до конца кадра: они будут действовать в следующем кадре. И я использую аналогичную логику для рендеринга (старое состояние) против обновления (новое состояние).

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

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

Учитывая, что у меня есть три такие очереди, все потоки, кроме одного, могут потенциально останавливаться ровно три раза за кадр (ожидая, пока другие потоки завершат все невыполненные задания, выпущенные с текущим уровнем приоритета).

Это кажется приемлемым уровнем неактивности потоков!

Homer
источник
Мой кадр начинается с MAIN-рендеринга OLD STATE с этапа обновления предыдущего кадра, в то время как все остальные потоки немедленно начинают вычислять состояние NEXT-кадра, я просто использую События, чтобы удвоить изменения состояния буфера до точки в кадре, где никто больше не читает ,
Гомер
0

Обычно я использую один основной поток (очевидно) и добавляю поток каждый раз, когда замечаю падение производительности примерно на 10–20 процентов. Чтобы скрыть это, я использую инструменты производительности visual studio. Обычные события - это (не) загрузка некоторых областей карты или выполнение тяжелых вычислений.

Ленард Аркин
источник