Академический Документы
Профессиональный Документы
Культура Документы
ПРОИЗВОДИТЕЛЬНОСТЬ ПАРАЛЛЕЛЬНЫХ
СРЕДСТВ В С++20 (CLANG)
по направлению подготовки (специальности) «09.03.04 Программная
инженерия»
Направленность (профиль) «09.03.04_01 Технология разработки и
сопровождения качественного программного продукта»
Выполнил
студент гр.3530904/60105 Ф. П. Д. Н. Мулонде
Руководитель
доцент С. А. Фёдоров
Консультант
по нормоконтролю Е.Г. Локшина
Санкт-Петербург
2020
САНКТ-ПЕТЕРБУРГСКИЙ ПОЛИТЕХНИЧЕСКИЙ
УНИВЕРСИТЕТ ПЕТРА ВЕЛИКОГО
ИКНТ/Высшая школа программной инженерии
УТВЕРЖДАЮ
Директор ВШПИ
П.Д.Дробинцев
« » 2020г.
ЗАДАНИЕ
на выполнение выпускной квалификационной работы
студенту Мулонде Филипе Педро Ду Нашсименту,
группа 3530904/60105
фамилия, имя, отчество (при наличии), номер группы
Студент Ф. П. Д. Н. Мулонде
(подпись) инициалы, фамилия
РЕФЕРАТ
На 58 с., 32 рисунков, 9 таблицы
КЛЮЧЕВЫЕ СЛОВА: ПАТОКИ, ПАРАЛЛЕЛИЗМ,
ПРОИЗВОДИТЕЛЬНОСТЬ, C++ 20, МОДЕЛИ ПАМЯТИ,
ПАРАЛЛЕЛЬНЫЕ ЯЗЫКИ ПРОГРАММИРОВАНИЯ.
Эта работа описывает производительность
параллельных конструкций C ++ 20.
Язык программирования C ++ предоставляет
стандартизированные строительные блоки для разработки
параллельных программ в спецификации языка. Обеспечивает
лучшую интеграцию с компиляторами и системами времени
выполнения и обеспечивает потенциально более высокую
производительность, что облегчает переносимость. Со
стандартов C ++ 11 до C ++ 20 были добавлены языковые
конструкции и модель памяти, эти параллельные конструкции
позволяют разработчикам создавать параллельные программы
стандартизированным способом. Целью данной работы является
раскрытие модели памяти C ++ для параллелизма и
производительности различных параллельных конструкций C
++, основанных на тестах, демонстрируется производительность
некоторых из наиболее часто используемых параллельных
конструкций C ++, путем тестирования различных решений,
которые решают одну и ту же проблему с использованием
различных параллельных конструкций и порядка памяти,
скомпилированных clang 10.
ABSTRACT
58 pages, 32 figures, 9 tables.
KEYWORDS: THREADS, CONCURRENCY, PARALLELISM,
PERFORMANCE, BENCHMARK, C++20, MEMORY MODELS,
PARALLEL PROGRAMMING LANGUAGES.
3
2.7. Измерение производительности параллельной
структуры данных ................................................................... 40
3. Производительность параллельных алгоритмов C ++ ....... 49
4. Анализ результатов ............................................................. 52
4.1. Std::thread и std::jthread ................................................ 52
4.2. Std::mutex и std::lock_guard<std::mutex> ..................... 52
4.3. Ленивая инициализация .............................................. 53
4.4. Читатель-писатель мьютекс ........................................ 53
4.5. Измерение производительности параллельной
структуры данных ................................................................... 54
4.6. Производительность параллельных алгоритмов C ++ 55
Заключение ................................................................................. 56
библиографический список ........................................................ 58
4
ВВЕДЕНИЕ
Эмпирическое наблюдение (Закон Мура ), изначально
сделанное Гордоном Муром, согласно которому (в современной
формулировке) количество транзисторов, размещаемых на
кристалле интегральной схемы, удваивается каждые 24 месяца.
Часто цитируемый интервал в 18 месяцев связан с
прогнозом Давида Хауса из Intel, по мнению
которого, производительность процессоров должна удваиваться
каждые 18 месяцев из-за сочетания роста количества
транзисторов и увеличения тактовых частот процессоров.
В прошлых компаниях, таких как Intel, AMD, IBM и др., чтобы
предлагать более быстрые машины, производители процессоров
использовали количество транзисторов, предсказанных по
закону Мура, для производства оборудования с постоянно более
высокими тактовыми частотами.
В последнее время, чтобы получить возможность
задействовать на практике ту дополнительную вычислительную
мощность, которую предсказывает закон Мура, стало
необходимо задействовать параллельные вычисления. На
протяжении многих лет производители процессоров постоянно
увеличивали тактовую частоту а также внедряли векторизацию и
параллелизм на уровне инструкций, так что на новых
процессорах старые однопоточные приложения выполнялись
быстрее без каких-либо изменений в программном коде.
Примерно с середины десятилетия 2000-х годов по
разным причинам производители процессоров разрабатывают в
основном векторные многояденые архитектуры, и для получения
всей выгоды от возросшей производительности ЦП программы
должны переписываться в соответствующей манере. Однако не
каждый алгоритм поддается распараллеливанию, определяя,
таким образом, фундаментальный предел эффективности
решения вычислительной задачи согласно закону Амдала.
Появление повсеместного параллелизма привело к
фундаментальным изменениям во всех современных
5
архитектурах. Одной из причин, по которой современные
производители ЦП строят параллельные системы, является
рассеяние мощности, в то время как количество транзисторов
удваивается с каждым годом и увеличивается
производительность компьютера, мощность ЦП почти
удваивается, это была абсолютно неустойчивая модель питания.
Это было одной из основных причин, побуждающих
производителей процессоров полностью перепроектировать и
оптимизировать свою архитектуру для рассеивания мощности.
6
Рисунок 2 а) одноядерный процессор и б) многоядерный процессор
10
1. МОДЕЛЬ ПАМЯТИ C ++
Разработка параллельного программного обеспечения в
стандарте C++ 1998 не была определена комитетом по
стандартизации C++, новый стандарт C++ был опубликован в
2011 году и внес много изменений, которые сделали работу с C++
проще и продуктивнее.
Язык программирования может не определять модель
памяти, и язык стал подвержен непоследовательному
(ослабленному или слабому) поведению, которое может быть
введено аппаратными средствами и оптимизацией компилятора,
но разрешение разных оптимизаций на разных аппаратных
средствах и компиляторах влияет на переносимость рограммы:
разные варианты оптимизации компилятора или разные
процессоры могут разрешать разные варианты поведения одной
и той же программы. Также затрудняет анализ, отладку и
тестирование параллельного приложения. В этом случае
поставщики компиляторов и процессоров несут ответственность
за обеспечение того, чтобы каждая платформа соответствовала
гарантиям, предоставляемым моделью памяти.
Любой параллельный язык программирования должен
включать минимальный набор гарантий, позволяющих
программисту лучше понимать выполнение своих программ,
несколько потоков должны иметь возможность согласовывать
состояние данного фрагмента данных.
В слабой (или расслабленной) модели памяти, такой как
основные процессоры, это дорогостоящая операция: она требует
некоторого уровня глобальной синхронизации, использования
протоколов согласованности. Модель языковой памяти может
обеспечивать сильный, слабый или оба порядка упорядочения
памяти. Сильно упорядоченные модели памяти, такие как
последовательная согласованность, в которой для всех потоков
исполнения существует единый общий порядок памяти, имеют
преимущество в удобстве использования, обучаемости и
читаемости: программистам легко рассуждать о выполнении
11
своих программ. Но для обеспечения последовательной
согласованности языковая спецификация вынуждает
производителей процессоров и поставщиков компиляторов
ограничивать некоторые важные оптимизации, и это достигается
главным образом с использованием дорогих заборов.
Языки программирования также могут иметь слабую
модель памяти, но с возможностью эффективной реализации на
слабых процессорах, таких как Intel, ARM и Power, эта модель
делает параллелизм очень сложным и только для экспертов, даже
эксперты в C ++ также склонны ошибаться.
Мы также можем иметь как слабое, так и сильное
упорядочение памяти в одной и той же программе, слабая модель
памяти может позволить программисту определять более
сильное упорядочение в частях программы с помощью мьютекса,
заборов, барьеров и переменных условий, также программист
отвечает за реализация явной синхронизации для обеспечения
корректности, не внося слишком много и снижая
производительность.
Спецификации языка могут обеспечить более надежные
гарантии и при этом иметь эффективную способность
реализации, требуя определенной дисциплины
программирования. Программисты обязаны избегать
определенных действий, чтобы соблюдать строгие гарантии,
если программист не соблюдает дисциплину, то язык
предоставляет более слабые гарантии.
Модель памяти должна быть разработана с учетом
использования языка программирования, потому что все эти
модели памяти имеют компромиссы.
C ++ 11 был первым стандартом языка программирования
C ++, предоставившим компоненты в библиотеке для написания
многопоточных приложений. Одним из наиболее важных
дополнений к языку является новая модель многопоточной
памяти. Хорошо определенная модель памяти обеспечивает
абстракцию всех различных платформ, на которых может
выполняться программа, и ограничивает их поведение. Модель
12
памяти крайне необходима, чтобы гарантировать поведение
блоков параллелизма в языке, таких как мьютекс, барьер,
семафоры и т. Д.
Модель памяти задается в двух аспектах:
1. Основные структурные аспекты, которые определяют,
как вещи выкладываются в память.
2. Параллелизм и однопотоковые аспекты, которые
обеспечивают порядок доступа к памяти в одном потоке
и поведение строительных блоков параллелизма.
На разработку модели памяти c ++ 11 большое влияние
оказали работы Adve, Charachorloo и Hill [4, 2, 3], которые
предложили модель памяти, которая обеспечивает
последовательную согласованность для программ, не
использующих гонки данных.
После этой работы в 2018 году [5] Boehm и Adve описали
упрощенную дисциплину: программисты могут получать доступ
к гоночным объектам только через атомарную библиотеку и
избегать гонок данных на всех других объектах. Если эта
дисциплина нарушается, то доступ к одной и той же ячейке
памяти двумя разными потоками, а если один из них - запись, то
вся программа имеет неопределенное поведение.
Такой выбор дизайна портит возможность отладки. Эта
модель программирования не позволяет программистам
рассуждать о гонке данных в своих программах с точки зрения
предполагаемой модели систем, содержащей компилятор, и
деталей аппаратного обеспечения.
Boehm и Adve предоставили критерии [9,6], в
соответствии с которыми программы, выполняемые в их
расслабленной памяти, ведут себя в соответствии с
последовательной согласованностью [8], и это стало целью
разработки модели памяти C ++ 11: программы без гонки данных,
которые избегают используя низкоуровневый атомарность,
должен выполняться последовательно последовательным
образом. Это позволяет программистам, которым не требуется
13
атомарный низкоуровневый уровень для достижения
максимальной производительности, с интуитивно понятной
моделью памяти.
1.1. Атомарные операции в C ++
Атомная операция — это неделимая операция, которую
можно использовать для написания четко определенного
гоночного кода. Невозможно наблюдать такие операции,
наполовину выполненные из любого потока в системе. Когда
одно из ядер в системе начинает выполнение атомарной
операции записи, это ядро должно завершить эту инструкцию без
возможности другого ядра в системе начать запись или чтение из
той же атомарной переменной, в то время как первое ядро еще не
завершено, другими словами, атомарные операции чтения и
записи стерилизуются, но если все операции в одной и той же
переменной являются операциями чтения, то они разрешены.
1.2. Производительность, разрешенная моделью памяти
Из-за таких факторов, как рассеяние мощности,
производительность отдельных процессора сейчас ограничена,
производители процессоров все больше полагаются на
многоядерные процессоры для обеспечения будущих улучшений
производительности. Обмен данными между потоками является
одним из наиболее распространенных способов связи между
потоками.
1.3. Модель памяти C ++ без Атомных Низких уровней
Структурные аспекты модели памяти важны для
параллелизма, гарантия, обеспечиваемая моделью
многопоточной памяти, опирается на структурные аспекты
памяти. Все данные в программе C ++ состоят из объектов.
Стандарт C ++ определяет объект как «область хранения».
А именно, программа может делать следующее с объектами [1]:
• Создайте
• уничтожить
• Ссылаться на
• Манипулирование
• Доступ
14
Объекты имеют следующие свойства:
• Имя (однако это необязательно)
• Хранение и его срок хранения
• Тип
• Продолжительность жизни
• Значение
17
1.5. Эффективность Try_lock
int p; std::mutex m;
Thread1 Thread2
std::thread std::thread
thrread1([&](){ thrread2([&](){
m.lock(); while (m.trylock())
p = 8; m.unlock();
}); std::assert(p==8);} );
21
Нерефлексивный общий порядок атомарных операций,
предназначенных для отражения глобального порядка, в котором
они выполняются.
Модель определяет атомарное, неатомное чтение или
блокировку получения как операцию получения в месте чтения,
а атомарную и неатомарную запись или разблокировку в качестве
релиза в месте записи. И действие памяти на A для
синхронизации с действием памяти на B, если B является
операцией получения в определенном местоположении, A
является операцией освобождения в том же местоположении, и
W (B) = A.
Случается до (<hb) быть наименьшим отношением к
действиям памяти, таким, что:
• Если последовательность a перед b, то перед b
происходит a.
• Если a синхронизируется с b, то перед b происходит a.
• Если a происходит до b, а b происходит до c, то a
происходит до c.
Модель определяет видимые побочные эффекты,
связанные с атомарными, неатомарными нагрузками или
операцией чтения-изменения-записи или блокировкой b для
обновления (атомарная, неатомарная запись, операция чтения-
изменения-записи или разблокировка) a к тому же
местоположению l, так что a происходит до b, но нет
промежуточного обновления c l, чтобы a происходило до c и c до
b.
Модель определяет выполнение программы как
согласованное, если:
1) Каждый выполняемый поток внутренне согласован,
учитывая значения, считанные из памяти.
2) Порядок <s соответствует тому, что происходит раньше,
если a происходит до b, то a <s b.
3) Для каждой неатомной нагрузки l W (a) является
видимым побочным эффектом по отношению к l, если
22
4) нет гонки данных и это единственный видимый
побочный эффект, соответствующий l)
5) Для каждой неатомарной загрузки или атомарной
операции чтения-изменения-записи a, W (a) является
последним предшествующим обновлением в <s в том же
месте.
6) Операции блокировки и разблокировки для каждого
отдельного замка полностью упорядочены в
зависимости от того, что произошло раньше, и чередуют
каждый такой отдельный порядок. Кроме того, каждая
операция блокировки последовательно перед
следующей операцией разблокировки в этом общем
порядке, если она есть. Блокировки снимаются только
эквайрингом.
Последовательное выполнение гонки данных типа 2,
если два доступа к данным в одну и ту же область памяти
неупорядочены, происходит раньше. Теперь мы можем указать
модель памяти C как:
Если программа (на заданном входе) имеет согласованное
выполнение с гонкой данных (тип 2), то ее поведение не
определено.
В противном случае программа (на том же входе) ведет
себя в соответствии с одним из ее последовательных
выполнений. Модель памяти обеспечивает низкоуровневые
атомарные операции, которые явно параметризованы
относительно ограничения порядка памяти. Аргумент порядка
памяти определяет, сколько порядка доступа создаст при
выполнении. Есть шесть вариантов порядка памяти :
memory_order_seq_cst,
memory_order_acq_rel,
memory_order_acquire,
memory_order_release,
memory_order_consume, and
memory_order_relaxed.
Этот список примерно в порядке, от сильного к слабому
и от дорогого к дешевому.
23
memory_order_seq_cst- Операция загрузки с этим
порядком памяти выполняет операцию получения, хранилище
выполняет операцию освобождения, а операция чтения-
изменения-записи выполняет как операцию получения, так и
операцию освобождения, плюс существует единый общий
порядок, в котором все потоки наблюдают все модификации в тот
же порядок.
memory_order_acq_rel - Операция чтения-изменения-
записи с этим порядком памяти является и операцией получения,
и операцией освобождения. Никакие операции чтения или записи
в памяти в текущем потоке не могут быть переупорядочены ни
до, ни после этого хранилища. Все записи в других потоках,
которые выпускают одну и ту же атомарную переменную, видны
до модификации, а модификация видна в других потоках,
которые получают ту же атомарную переменную.
memory_order_release -Операция записи с этим
порядком памяти выполняет операцию освобождения: после
этого хранилища нельзя переупорядочивать операции чтения или
записи в текущем потоке. Все записи в текущем потоке,
упорядоченные до того, как эта атомарная операция видима в
других потоках, которые получают ту же атомарную
переменную, и записи, которые переносят зависимость в
атомарную переменную, становятся видимыми в других потоках,
которые потребляют тот же атомарный.
memory_order_acquire- Операция загрузки с этим
порядком памяти выполняет операцию получения в уязвимом
месте памяти: никакие операции чтения или записи в текущем
потоке не могут быть переупорядочены до этой загрузки. Все
записи до этой атомарной переменной в других потоках, которые
выполняют операцию освобождения в той же самой атомарной
переменной, видны в текущем потоке.
memory_order_consume- Операцию приказывают
выполнить, как только все обращения к памяти в потоке
24
освобождения, которые несут зависимость от операции
освобождения (и которые имеют видимые побочные эффекты в
потоке загрузки), произошли.
memory_order_relaxed- нет ограничений синхронизации
или упорядочения, налагаемых на другие операции чтения или
записи, гарантируется только атомарность этой операции.
std::atomic x.load(memory_order_relaxed)
Это позволяет переменной x переупорядочивать
операции памяти в порядке следования, с точки зрения модели
памяти, это указывает на то, что загрузка никогда не является
операцией получения, и, следовательно, не вносит вклад в
упорядочение синхронизации.
Для Операций чтение-модификация-запись, программисты
могут указать, действует ли операция как операция
приобретения, операции освобождения, ни (ослаблены), или
обоих.
Для поддержки атомарного уровня низкого уровня
модель памяти была изменена:
1) Общий порядок S операций синхронизации содержит
только высокоуровневые (последовательно
согласованные) атомарные операции и может также быть
указан как memory_order_sq_cst в качестве параметра.
2) Порядок модификации, общий порядок для одной
переменной.
C/C++11 Itanium
load relaxed ld.acq
load consume ld.acq
load acquire ld.acq
load seq_cst ld.acq
store relaxed st.rel
store release st.rel
store seq_cst st.rel; mf
fence acquire ⟨ignore⟩
fence release ⟨ignore⟩
fence acq_rel ⟨ignore⟩
fence seq_cst mf
Таблица 7 Отображения компилятора ITANIUM
27
2. ПРОИЗВОДИТЕЛЬНОСТЬ
ПАРАЛЛЕЛЬНЫХ СРЕДСТВ В С++20
(CLANG)
Эксперименты проводятся на Intel core I7, работающем
под управлением Ubuntu 20.04 LTS. Спецификация компьютера
описана в таблице 8. Автором были проведены эксперименты с
гиперпоточностью, активированной для одних импликаций и
деактивированной для других.
процессор Intel core i7
Тактовая частота 2.800 GHz
ядро 4
Логические процессоры 8
RAM 16
L1 cache 250KB
L2 cache 1.0MB
L3 cache 6.0MB
Таблица 8 Спецификация компьютера.
Все тесты были выполнены в clang версии 10.0.1, тест
производительности был выполнен с использованием
библиотеки бенчмарков Google. также использовалось perf для
грантополучателя, чтобы не было никаких оптимизаций для
объектов, которые тестируются. Чтобы узнать стоимость std::
thread, std:: jthread и мьютексов, мы используем следующие
конфигурации:
1. Отключить TurboBoost
2. Отключить гиперпоточность
3. Установите регулятор масштабирования на
«производительность»
4. Установить привязку к процессору
5. Установить приоритет процесса
6. Отключить рандомизацию адресного пространства
Для остальной части теста использовались только
следующие конфигурации:
28
1. Отключен TurboBoost
2. Установите регулятор масштабирования на
«производительность»
3. Отключить рандомизацию адресного пространства
2.1. П оказатель, используемая для наблюдения за
результатами
Тест производительности выполняется потому, что
необходимо знать производительность программы, функции,
стоимость создания и уничтожения объектов и т. Д. Также
необходимо сравнить производительность разных решений,
которые решают одну и ту же программу, в этом тезисе я измерил
стоимость создания и уничтожения некоторых объектов, но
большинство тестов - это производительность разных решений,
использующих разные параллельные конструкции, по сравнению
с последовательная версия программы, а также
производительность разных параллельных версий, которая
решает одну и ту же проблему. Наиболее рекомендуемый способ
сравнения результатов эталонного теста различных версий одной
и той же программы или объектов состоит в том, чтобы
выполнить один и тот же эталонный тест несколько раз, чтобы
иметь несколько измерений для эталонного теста для каждой
версии программы или объектов, а затем выбрать способ, чтобы
сравнить наборы различных версий программы или объектов,
чтобы решить, какая из них быстрее.
Рекомендуется не полагаться на одну метрику (минимум
/среднее/медиана и т. Д.). Но классические статические методы
не всегда хорошо работают в мире производительности, что
усложняет проблему автоматизации. Но ручное вмешательство
является необходимостью в этом случае, что делает его очень
трудоемким. Самый простой способ автоматизировать такое
сравнение - объединить каждое измерение и выбрать одно
репрезентативное значение из наборов каждой версии
программы или объектов. Это может быть Среднее, Медиана или
Минимум.
29
Но даже для этих 3 не существует правильного ответа на вопрос,
какой из них выбрать, подавляющее большинство приложений,
связанных с вычислениями, имеют нормальное распределение,
но нет простого способа сравнить распределения.
Один из методов рекомендации, чтобы определить, какая
версия программы быстрее, состоит в том, чтобы взглянуть на
дистрибутивы разных версий одной и той же программы, и мы
можем сделать вывод с достаточно высоким уровнем
достоверности.
std::stack lock_based
стандартный атомный Расслабленный атомный
31
Например, на приведенном выше графике имеется 16
образцов для каждой из 4 разных версий одной и той же
программы.Результат каждой выборки отображается в
наносекундах, на этом графике мы можем увидеть
производительность каждой версии программы, которая решает
одну и ту же проблему, 16 раз, и мы можем сказать, что версия
std::stack является самой медленной, расслабленной атомарной
версия является второй самой медленной, а остальные версии
программы в этом случае работают почти одинаково. Именно так
следует наблюдать результат теста на графиках, подобных этим,
которые использовались в этой работе.
2.3. std::thread и std::jthread
C++20 предоставляет два способа создания потока,
используя std::thread или std::jthread, знание стоимости создания
и уничтожения потока очень важно при разработке
высокопроизводительных параллельных приложений, потому
что стоимость создания и уничтожение потока играет важную
роль в разработке приложения, потому что стоимость создания
потока очень высока во многих параллельных системах.
19.10
19.00
18.90
18.80
18.70
18.60
18.50
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
33
std::vector<int>v;
int result = 0;
bool testa =0;
void ReadVector(int read){
for(int i = 1; i <= read; i++) {
result = result + v.at(0);} }
void pushVector(){
for(int i =1; i <= 10; i++){
v.push_back(i);}
testa = 1;
}
void execute(int x) {
if(!testa)
pushVectorSeq();
ReadVector(x);
}
34
std::once_flag resource_flag;
void execute(int x) {
std::call_once(resource_flag,pushVector);
ReadVector(x);
Рисунок 10 Тест 1: Чтение вектора 1 раз, используя 1 поток для каждой версии
программы
35
Рисунок 11 Тест 2: Чтение вектора 1000 раз, используя 10 потоков для каждой
версии программы
Рисунок 12 Тест 3: Чтение вектора 1000 000 раз, используя 10 потоков для
каждой версии программы.
36
2.6. Читатель-писатель мьютекс
В параллельных программах, где потоки очень редко
записывают в общие данные, std::mutex поможет
синхронизировать потоки, но значительно снизит
производительность программы.
std::mutex entry_mutex;
class r_update
{
std::map<std::string, int64_t> entries;
public:
int64_t find_entry(std::string const& domain) const{
std::lock_guard<std::mutex> lk(entry_mutex);
std::map<std::string, int64_t>::const_iterator const it =
entries.find(domain);
result = result + it->second;
return (it == entries.end()) ? 0 : it->second;}
void update_or_add_entry(std::string const& domain, int_fast64_t const&
details) {
std::lock_guard<std::mutex> lk(entry_mutex);
entries[domain] = details;
}
};
37
class r_update_shared
{
std::map<std::string, int64_t> entries;
mutable std::shared_mutex entry_mutex;
public:
int64_t find_entry(std::string const& domain) const
{
std::shared_lock<std::shared_mutex> lk(entry_mutex);
std::map<std::string, int64_t>::const_iterator const it =
entries.find(domain);
result = result + it->second;
return (it == entries.end()) ? 0 : it->second;
}
};
38
Рисунок 15 Тест 1: чтение и запись 1 раз и использование только одного потока
для каждой версии программы.
39
Рисунок 17 Test 3: чтение и запись 50 000 раз и использование 5 потоков для
чтения и 5 потоков для записи для каждой версии программы.
40
Рисунок 18 структура данных стека
Рисунок 19 std::stack
41
которая поддерживает перенос элементов данных в стек и их
извлечение.
struct empty_stack : std::exception
{
const char* what() const throw()
{
return "empty stack";
}
}
template<typename T>
class lock_based_stack
{
private:
std::stack<T> data;
mutable std::mutex m;
public:
threadsafe_stack() {}
threadsafe_stack(const threadsafe_stack& other)
{
std::lock_guard<std::mutex> lock(other.m);
data = other.data;
}
threadsafe_stack& operator=(const threadsafe_stack&) = delete;
Рисунок 20 lock_based_stack
42
void increase_head_count(counted_node_ptr& old_counter) {
counted_node_ptr new_counter;
do {
new_counter=old_counter;
++new_counter.external_count;
}
while(!head.compare_exchange_strong(old_counter,new_counter));
old_counter.external_count=new_counter.external_count;
}
std::atomic<counted_node_ptr> head;
public:
~lock_free_stack() {
while(pop());
}
void push(T const& data) {
counted_node_ptr new_node;
new_node.ptr=new node(data);
new_node.external_count=1;
new_node.ptr->next=head.load();
while(!head.compare_exchange_weak(new_node.ptr->next,new_node));
}
std::shared_ptr<T> pop() {
counted_node_ptr old_head=head.load();
for(;;)
{
increase_head_count(old_head);
node* const ptr=old_head.ptr;
if(!ptr) {
return std::shared_ptr<T>();
}
if(head.compare_exchange_strong(old_head,ptr->next))
{
std::shared_ptr<T> res;
res.swap(ptr->data);
int const count_increase=old_head.external_count-2;
if(ptr->internal_count.fetch_add(count_increase)==
-count_increase)
{
delete ptr;
}
return res;
}
else if(ptr->internal_count.fetch_sub(1)==1)
{
delete ptr;
}
}
}
};
43
Четвертая версия программы - реализация стека без
блокировок, использующая атомарную библиотеку C ++ с
упорядоченным упорядочением памяти.
template<typename T>
class lock_free_stack_relaxed
{
private:
struct node;
struct counted_node_ptr
{
int external_count;
node* ptr;
};
struct node
{
std::shared_ptr<T> data;
std::atomic<int> internal_count;
counted_node_ptr next;
node(T const& data_):
data(std::make_shared<T>(data_)),
internal_count(0)
{}
};
std::atomic<counted_node_ptr> head;
void increase_head_count(counted_node_ptr& old_counter)
{
counted_node_ptr new_counter;
do
{
new_counter=old_counter;
++new_counter.external_count;
}
while(!head.compare_exchange_strong(
old_counter,new_counter,
std::memory_order_acquire,
std::memory_order_relaxed));
old_counter.external_count=new_counter.external_count;
}
public:
~lock_free_stack_relaxed() {
while(pop());}
void push(T const& data){
counted_node_ptr new_node;
new_node.ptr=new node(data);
new_node.external_count=1;
new_node.ptr-
>next=head.load(std::memory_order_relaxed);
while(!head.compare_exchange_weak(
new_node.ptr->next,new_node,
std::memory_order_release,
std::memory_order_relaxed));}
44
std::shared_ptr<T> pop() {
counted_node_ptr old_head=
head.load(std::memory_order_relaxed);
for(;;){
increase_head_count(old_head);
node* const ptr=old_head.ptr;
if(!ptr){
return std::shared_ptr<T>();}
if(head.compare_exchange_strong(
old_head,ptr->next,std::memory_order_relaxed)){
std::shared_ptr<T> res;
res.swap(ptr->data);
int const count_increase=old_head.external_count-2;
if(ptr->internal_count.fetch_add(
count_increase,std::memory_order_release)==-
count_increase){
delete ptr;}
return res;}
else if(ptr->internal_count.fetch_add(
-1,std::memory_order_relaxed)==1) {
ptr->internal_count.load(std::memory_order_acquire);
delete ptr; }
}
}
};
20004
15004
10004
5004
45
300000
250000
200000
150000
100000
50000
0
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
std::stack Lock_based
стандартный атомный Расслабленный атомный
Рисунок 24 Тест 2: запись 1000 раз в стек для последовательной версии стека,
а также запись 1000 раз в стек для всех параллельных версий программы и
использование 10 потоков, каждый поток записывает 100 раз в стек.
3000000
2500000
2000000
1500000
1000000
500000
0
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
46
140
120
100
80
60
40
20
0
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
std::stack lock_based
стандартный атомный Расслабленный атомный
250000
200000
150000
100000
50000
0
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
std::stack lock_based
стандартный атомный Расслабленный атомный
Рисунок 27 Тест 5: чтение 1000 раз в стеке для последовательной версии стека,
а также чтение 1000 раз в стеке для всех параллельных версий программы и с
использованием 10 потоков, каждый поток читает в стек 100 раз.
47
900000
800000
700000
600000
500000
400000
300000
200000
100000
0
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
Рисунок 28 Тест 6: чтение 1 000 000 раз в стеке для последовательной версии
стека, а также чтение 1 000 000 раз в стеке для всех параллельных версий
программы и с использованием 10 потоков каждый поток читает в стеке 100
000 раз.
48
3. ПРОИЗВОДИТЕЛЬНОСТЬ
ПАРАЛЛЕЛЬНЫХ АЛГОРИТМОВ C ++
В проекте стандарта C ++ 17 многие существующие
алгоритмы были обновлены и перегружены политикой
выполнения.
std::execution::seq- это тип политики выполнения,
используемый как уникальный тип для устранения
неоднозначности перегрузки параллельного алгоритма и требует,
чтобы выполнение параллельного алгоритма не было
распараллелено.
std::execute::par- это тип политики выполнения,
используемый как уникальный тип для устранения
неоднозначности перегрузки параллельного алгоритма и
указания на то, что выполнение параллельного алгоритма может
быть распараллелено.
std::execution::par_unseq-это тип политики
выполнения, используемый в качестве уникального типа для
устранения неоднозначности перегрузки параллельного
алгоритма и указания на то, что выполнение параллельного
алгоритма может быть распараллелено и векторизовано.
Один из самых мощных алгоритмов - это std::reduce, этот
новый алгоритм предоставляет параллельную версию
std::accumulate. std::accumulate возвращает сумму всех элементов
в данном диапазоне или любую двоичную операцию.
Чтобы увидеть производительность параллельных
алгоритмов C ++, был проверен производительность 4 версий
одной и той же программы, программы, которая возвращает
сумму всех элементов в векторе, первые версии -
последовательное выполнение с использованием std::accumulate ,
и остальные 3 версии используют std::reduce используя разные
политики выполнения, и для всех версий программы мы
выполняем с различным размером данных в векторе.
49
auto size;
std::vector<double> v(size, 10);
auto res = std::reduce(std::execution::seq, v.begin(), v.end(), 0.0);
auto res = std::accumulate(v.begin(), v.end(), 0.0);
auto res = std::reduce(std::execution::seq, v.begin(), v.end(), 0.0);
auto res = std::reduce(std::execution::par, v.begin(), v.end(), 0.0);
auto res = std::reduce(std::execution::par_unseq, v.begin(), v.end(), 0.0);
Рисунок 29 std :: Accumulate и его параллельные версии
8.8
8.6
8.4
8.2
8
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
std::acumulate std::execution::seq
std::execution::par std::execution::par_unseq
48.5
48
47.5
47
46.5
46
45.5
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
std::acumulate std::execution::seq
std::execution::par std::execution::par_unseq
50
110000
105000
100000
95000
90000
85000
80000
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
std::acumulate std::execution::seq
std::execution::par std::execution::par_unseq
51
4. АНАЛИЗ РЕЗУЛЬТАТОВ
4.1. std::thread и std::jthread
Как показано на рисунке 5, для создания и уничтожения
std::thread требуется меньше времени, чем для std::jthread, это
потому, что std::jthread облегчает жизнь программиста,
предоставляя очень хороший API прерываний, который
улучшает программируемость, стоимость создания и
уничтожения std::thread находится в интервале от 12 126 до 12
244 нс, а std:: jthread - от 12 468 до 12 568 нс.
Нужно быть очень осторожным, когда выбираешь, какой
объект потока использовать, std :: jthread должен быть только
тогда, когда необходимо использовать его API прерывания, иначе
std :: thread, вероятно, будет иметь лучшую производительность,
как показано на рисунке 5.
4.2. std::mutex и std::lock_guard<std::mutex>
Результат измерения этих параллельных конструкций
показан на рисунке 6. Стоимость парных операций блокировки
и разблокировки std :: mutex находится в интервале от 17 нс до
18,8 нс, а стоимость парных операций блокировки и
разблокировки внутри объекта std::guard std :: mutex> составляет
18,7 нс. без изменений в 16 выполнении одного и того же теста,
стоимость парной операции std :: mutex try_lock и unlock
составляет 19, без изменений в 16 выполнении одного и того же
теста.
На рисунке 6 показано, что при использовании std::
lock_guard над простым мьютексом, вероятно, очень низкие
издержки или их вообще нет, рекомендуется использовать std::
lock_guard поверх простого мьютекса, поскольку std :: lock_guard
менее подвержен ошибкам.
Но при сравнении производительности std :: mutex и std ::
lock_guard по сравнению с try_lock, мы ясно видим, что функция
try_lock объекта std :: mutex дороже, потому что модель памяти
допускает сбой try_lock, даже когда никакой другой поток не
52
имеет приобрел замок. Рекомендуется использовать Try_lock
вместо Lock только там, где это крайне необходимо.
4.3. Ленивая инициализация
В результате, представленные фигурами 10,11,12,
представляющими разные объемы данных для обработки и
разное количество потоков, во всех случаях производительность
std :: Once_flag была лучше, чем std::mutex, это связано с тем, что
std::mutex, вероятно, отображается в примитивную
операционную систему mutex, std::mutex может делать вызов
ядра в некоторых ситуациях, и это снижает производительность
std :: mutex, но результат на рисунке 12 показывает некоторые
приближения производительности std::mutex, когда размер
обрабатываемых данных составляет 1000 000, может
потребоваться или не потребоваться дополнительная проверка
для полной уверенности в производительности этих объектов, но
в этой работе ясно, что при 500 000 операций записи и чтения std:
: Once_flag имеют лучшую производительность.
4.4. читатель-писатель мьютекс
Из рисунков 15, 16, 17 можно увидеть
производительность std :: shared_lock <std :: shared_mutex> по
сравнению с std :: lock_guard <std :: mutex>, для чтения и записи
один раз, мы видим, что std :: shred_lock <std :: shared_mutex>
имеет лучшую производительность, потому что, вероятно, std ::
lock_guard <std :: mutex> может вызывать ядро, но когда размер
данных до 1000, чтобы выполнить 500 операций чтения и записи,
результат показал на рисунке 16 std :: lock_guard <std :: mutex> на
удивление лучше, но на рисунке 17 с размером данных 1000000
для выполнения 500 000 операций чтения и записи std ::
shared_lock <std :: shared_mutex> много лучшая
производительность, так что можно сделать вывод, что std ::
shared_lock <std :: shared_mutex> имеет лучшую
производительность по сравнению с std :: lock_guard <std ::
mutex> при 500 000 операций чтения и записи, которые включают
все 3 теста, результат на рисунке 16 можно принять, потому что
std :: shared_lock <std :: shared_mutex> был включен в язык C ++,
53
чтобы использоваться только в ситуациях, когда запись операции
очень редки, но в этой работе производительность std ::
shared_lock <std :: shared_mutex> была измерена, чтобы узнать
его поведение при равном количестве операций записи и чтения,
чтобы увидеть масштабируемость std :: shared_lock <std ::
shared_mutex>, и вывод заключается в том, что
масштабируемость объекта под одинаковым (500 000) числом
операций чтения и записи хороша.
4.5. Измерение производительности параллельной
структуры данных
Из рисунков 23, 24, 25 можно увидеть
производительность операции push для 4 различных версий
структуры данных стека. Можно увидеть, что std :: stack в
последовательной версии программы имеет лучшую
производительность при передаче 1000 элементов. , рисунки 23 и
24. Реализация lock_based имеет очень большие издержки по
сравнению с остальной частью всех других версий программы,
рисунки 23 и 24, из-за высокой стоимости мьютекса. На рисунке
23 две версии атомарной реализации стека имеют почти
одинаковую производительность, но можно увидеть, что когда
размер элементов, которые нужно выдвинуть, равен 1000,
рисунок 24, расслабленные атомарные операции имеют лучшую
производительность и теряют только по сравнению с
последовательной версией Программа, из рисунка 25 можно
увидеть, что последовательная версия программы имеет очень
плохую производительность, версия Threadsafe_stack имеет
лучшую производительность, чем 2 атомные версии, и можно
сделать вывод, что при 1000 000 push в стек структура данных 3
параллельных версии push-реализации имеют очень высокую
производительность по сравнению с последовательной версией.
Для реализации функции Pop в 4 версиях программы
можно видеть, что последовательная версия программы имеет
высокую производительность по сравнению с параллельными
версиями (рис. 26, 27) из-за стоимости создания и уничтожения
54
потоков и накладные расходы на синхронизацию, это
справедливо также для реализации функции push 3 параллельных
версий стека, и lock_based имеет лучшую производительность 2-
атомной версии, рис. 26, 27, и все параллельные версии
программы имеют высокую производительность по сравнению с
последовательной версией при извлечении 1000 000 из стека.
4.6. Производительность параллельных алгоритмов C ++
Параллельная версия std::accumulate действительно не
показала какого-либо значительного улучшения
производительности по сравнению с параллельной версией,
только на рисунке 29, где можно увидеть, что параллельная и
векторная реализация std::accumulate имеет лучшую
производительность, чем Последовательная версия и до 1000 000
элементов для суммирования, последовательная версия имеет
лучшую производительность.
55
ЗАКЛЮЧЕНИЕ
С увеличением количества ядер в процессорах
исследователи продолжают искать более эффективные способы
использования этих ресурсов, что является непростой задачей.
Комитет C ++ также занимается превращением языка C ++ в
инструмент, который эффективно использует эти ресурсы.
Одним из преимуществ параллелизма является необходимость
повышения производительности, поэтому необходимо знать
производительность параллельных конструкций, которые будут
использоваться для повышения производительности программы,
поскольку они могут добавить ненужные затраты
производительности. Цель этой работы - оценить
производительность параллельных инструментов на языке C ++.
Цель данной работы была достигнута, решая следующие
задачи:
1) Обзор модели памяти параллелизма C ++:
Модель памяти является наиболее важным
компонентом языка параллельного
программирования, в этой работе была
проанализирована реализация модели памяти C ++
для параллелизма, оптимизации, допускаемой
моделью, стоимость стандартного атомарного кода,
эффективность try_lock, а также различное
упорядочение памяти в модели памяти.
2) Стоимость создания и уничтожения потоков:
Стоимость создания и уничтожения потоков очень
важно знать для разработки параллельного
приложения, в этой работе представлено измерение
производительности двух объектов класса, которые
можно использовать для создания потоков в C ++, std
:: thread и std :: jthread.
3) Стоимость мьютекс и lock_guards
Мьютекс - это объект, используемый для обеспечения
синхронизации между потоками, которые совместно
56
используют глобальную переменную. C ++ также
предоставляет объект класса lock_guards, который
обеспечивает более безопасный способ
использования мьютекса. В этой работе
представлены измерения производительности
простого мьютекса и lock_guard, чтобы увидеть если
есть какие-либо издержки с использованием std ::
lock_guard.
4) Ленивая инициализация:
В данной работе представлены измерения
производительности двух способов реализации
ленивой инициализации в C ++.
5) Читатель-писатель мьютекс:
В этой работе представлены измерения
производительности std::shared_lock с
использованием std :: shared_mutex и std::lock_guard с
использованием std::mutex при выполнении
одинакового количества операций чтения и записи.
6) Сравнение производительности четырех разных
версий структуры данных стека: одна 1
последовательная и 3 параллельные версии.
7) Параллельные алгоритмы C ++ и
производительность параллельных версий C ++
алгоритма std::accumulate.
57
БИБЛИОГРАФИЧЕСКИЙ СПИСОК
1. https://blog.panicsoftware.com/objects-their-lifetimes-and-
pointers/
2. S. V. Adve. Designing Memory Consistency Models for
Shared-memory Multiprocessors. PhD thesis, Madison, WI,
USA, 1993. UMI Order No. GAX94-07354.
3. S. V. Adve and K. Gharachorloo. Shared memory consistency
models: A tutorial Computer, 29(12):66–76, Dec. 1996.
4. S. V. Adve and M. D. Hill. Weak ordering — a new definition.
In Proc. ISCA, 1990.
5. H.-J. Boehm and S. Adve. Foundations of the C++
concurrency memory model. In Proc. PLDI, 2008
6. H.-J. Boehm. A memory model for C++: Strawman proposal.
http://www.openstd.org/jtc1/sc22/wg21/docs/papers/2006/n1
942.html. WG21/N1942.
7. H.-J. Boehm. Threads cannot be implemented as a library. In
Proc. PLDI, 2005.
8. H.-J. Boehm and S. Adve. Foundations of the C++
concurrency memory model. In Proc. PLDI, 2008.
9. H.-J. Boehm, D. Lea, and B. Pugh. Memory model for
multithreaded C++: August
2005 status update. http://www.open-
std.org/jtc1/sc22/wg21/docs/papers/ 2005/n1876.pdf.
WG21/N1876
10. L. Lamport. How to make a multiprocessor computer that
correctly executes multiprocess programs. IEEE Transactions
on Computers, C-28(9):690–691, 1979.
11. S. V. Adve and K. Gharachorloo. Shared memory
consistency models:A tutorial. IEEE Computer, 29(12):66–
76, 1996.
12. Anthony Williams - C++ Concurrency in Action, Second
Edition.
13. The C11 and C++11 Concurrency Model. Mark John Batty.
PhD thesis, University of Cambridge, 2014.
58