Вы находитесь на странице: 1из 61

Министерство науки и высшего образования Российской Федерации

Санкт-Петербургский политехнический университет Петра Великого


Институт компьютерных наук и технологий/
Высшая школа программной инженерии
.

Работа допущена к защите


Директор ВШ ПИ
___________ П.Д.Дробинцев
«___»_______________2020 г.

ВЫПУСКНАЯ КВАЛИФИКАЦИОННАЯ РАБОТА


работа бакалавра

ПРОИЗВОДИТЕЛЬНОСТЬ ПАРАЛЛЕЛЬНЫХ
СРЕДСТВ В С++20 (CLANG)
по направлению подготовки (специальности) «09.03.04 Программная
инженерия»
Направленность (профиль) «09.03.04_01 Технология разработки и
сопровождения качественного программного продукта»

Выполнил
студент гр.3530904/60105 Ф. П. Д. Н. Мулонде
Руководитель
доцент С. А. Фёдоров

Консультант
по нормоконтролю Е.Г. Локшина

Санкт-Петербург
2020
САНКТ-ПЕТЕРБУРГСКИЙ ПОЛИТЕХНИЧЕСКИЙ
УНИВЕРСИТЕТ ПЕТРА ВЕЛИКОГО
ИКНТ/Высшая школа программной инженерии

УТВЕРЖДАЮ
Директор ВШПИ

П.Д.Дробинцев
« » 2020г.

ЗАДАНИЕ
на выполнение выпускной квалификационной работы
студенту Мулонде Филипе Педро Ду Нашсименту,
группа 3530904/60105
фамилия, имя, отчество (при наличии), номер группы

1. Тема работы: Производительность параллельных средств


в С++20 (clang).
2. Срок сдачи студентом законченной работы:
_____________________
3. Исходные данные по работе:
1. C++ Concurrency in Action, Second Edition.
2. H.-J. Boehm and S. Adve. Foundations of the C++
concurrency memory model.
3. Working Draft, Standard for Programming
Language C++.
4. Содержание работы (перечень подлежащих разработке
вопросов):
1. Обзор модели памяти параллелизма C ++
2. Оценка производительности параллельных
средств в С++20 (clang).
3. Стоимость создания и уничтожения потоков
4. Стоимость мьютекс и lock_guard
5. Ленивая инициализация
6. Читатель-писатель мьютекс
7. Сравнение производительности четырех разных
версий структуры данных стека: одна 1
последовательная и 3 параллельные версии.
8. Параллельные алгоритмы C ++ и
производительность параллельных версий C ++
алгоритма std::accumulate.
5. Перечень графического материала (с указанием
обязательных чертежей):
_______________________________________________________
_______________________________________________________
_______________________________________________________
_______________________________________________________
_______________________________________________________
_____________________________________________

6. Консультанты по работе: ___________________________


_______________________________________________________
_______________________________________________________
_______________________________________________________
_______________________________________________________

7. Дата выдачи задания ______________________________

Руководитель ВКР С. А. Фёдоров


(подпись) инициалы, фамилия
Задание принял к исполнению ____________________________
(дата)

Студент Ф. П. Д. Н. Мулонде
(подпись) инициалы, фамилия
РЕФЕРАТ
На 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.

This work describes the performance of C++20 parallel


constructs.
The C++ programming language provides standardized
building blocks for the development of parallel programs in the
language specification. Providing a better integration with compilers
and runtime systems and allows for potentially higher performance
and portability facilitates. From C++11 to C++20 standard, language
constructs have been added along with a memory model, those parallel
constructs allow developers to build parallel programs in a
standardized way. The aim of this work is to expose the C++ memory
model for concurrency, and the performance of different C++ parallel
constructs, based on benchmark is demonstrated the performance of
some of the most used C++ parallel constructs, by doing benchmarks
of different solutions that solve the same problem using different
parallel constructs and memory order, compiled by clang 10.
СОДЕРЖАНИЕ
Введение ........................................................................................ 5
1. Модель памяти c ++ ............................................................. 11
1.1. Атомарные операции в c ++ ......................................... 14
1.2. Производительность, разрешенная моделью памяти .. 14
1.3. Модель памяти c ++ без атомных низких уровней ..... 14
1.4. Оптимизация, разрешенная моделью. ......................... 16
1.5. Эффективность try_lock ............................................... 18
1.6. Стоимость последовательной последовательности
атомарности ............................................................................. 19
1.7. Стоимость принудительной записи-атомарности ....... 19
1.8. Последствия для текущих процессоров....................... 20
1.9. Влияние производительности на определение гонки
данных...................................................................................... 21
1.10. Модель c ++, поддерживающая низкоуровневую
атомизацию .............................................................................. 21
1.11. Отображения компилятора ...................................... 25
2. Производительность параллельных средств в с++20 (clang)
28
2.1. П оказатель, используемая для наблюдения за
результатами ............................................................................ 29
2.2. Сбор образцов .............................................................. 30
2.3. Std::thread и std::jthread................................................. 32
2.4. Std::mutex и std::lock_guard<std::mutex>...................... 33
2.5. Ленивая инициализация ............................................... 33
2.6. Читатель-писатель мьютекс ......................................... 37

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
архитектурах. Одной из причин, по которой современные
производители ЦП строят параллельные системы, является
рассеяние мощности, в то время как количество транзисторов
удваивается с каждым годом и увеличивается
производительность компьютера, мощность ЦП почти
удваивается, это была абсолютно неустойчивая модель питания.
Это было одной из основных причин, побуждающих
производителей процессоров полностью перепроектировать и
оптимизировать свою архитектуру для рассеивания мощности.

Рисунок 1 Голубой цвет(w) на графике показывает, что рассеиваемая


мощность не соответствует закону Мура с 2000 года.

6
Рисунок 2 а) одноядерный процессор и б) многоядерный процессор

Производители процессоров сегодня повышают


производительность процессора и уменьшают рассеяние
мощности за счет создания многоядерных процессоров. Число
транзисторов, предсказанных по закону Мура, теперь
используется производителями процессоров не для увеличения
тактовой частоты, а для увеличения числа ядер в кристалле
процессора.
Но с точки зрения производительности многоядерные
процессоры ведут себя по-разному в зависимости от вида
выполняемой ими работы, многочисленные независимые задачи
являются идеальным случаем, тогда как монолитные задачи,
которые не могут быть разделены, не являются подходящей
задачей для многоядерных систем, на них производительность
хуже по сравнению с тем, если выполняется в одноядерной
системе.
Большинство задач требуют некоторой связи между
ядрами, а стоимость этой связи ограничивает
производительность многоядерных систем.
Связь между ядрами в большинстве многоядерных
микроархитектур осуществляется путем совместного
использования памяти. Чтобы отправить информацию из одного
ядра в другое, одно ядро выполняет запись в адрес памяти или в
буфер ЦП, а другое ядро выполняет чтение с того же адреса
памяти или из кэша одного из ЦП, который имеет последнее
значение из последней записи в этот адрес памяти или буфер ЦП,
7
запись и чтение из основной памяти обходятся дороже, связь
между ядрами возможна благодаря протоколам согласованности
в основных ЦП. Память чрезвычайно медленная, по сравнению с
вычислениями, производители ЦП ввели в систему памяти кэши
и буферы, чтобы уменьшить задержку DRAM.
В течение многих лет исследований в области
оптимизации компиляции и систем выполнения ЦП
исследователи обеих сторон обнаружили множество
оптимизаций для однопоточной системы, таких как буферы,
умозрительные исполнения, инструкции по переупорядочению
компилятора и т. Д. Эти оптимизации не влияют на работу в
одноядерной системе, но они влияют на многоядерное
выполнение.
И чтобы обеспечить хорошее интуитивное мышление
для параллельных выполнений, производители процессоров и
компиляторов должны отказаться от некоторых очень хороших
механизмов оптимизации, но они отказались отказаться от этих
оптимизаций, и из-за этого очень трудно рассуждать о
параллельных выполнениях. Но поставщики компиляторов
должны делать, если в спецификации языка так сказано.
Один фрагмент кода может иметь более 1 способа
выполнения (недетерминированное выполнение) из-за
аппаратной и оптимизацию компиляторов (если это разрешено
языком) и чередования потоков.
Стандарт C ++ 1998 года не поддерживает потоки, и
порядок выполнения необходим только для сохранения значения
однопоточной системы. Мало того, что модель памяти не
определена формально, невозможно писать многопоточные
приложения без специфичных для компилятора расширений
стандарта C ++ 1998 года.
Поставщики компиляторов могут свободно добавлять
расширения к языку, а распространенность API C для
многопоточности, например, в стандарте POSIX C и API
Microsoft Windows, побудила многих поставщиков компиляторов
C++ поддерживать многопоточность с различными
8
расширениями для разных платформ. OpenMP был и остается
альтернативой для многих программистов.
Предоставление стандартизированной модели памяти и
параллельной примитивной семантики для параллельных
программ на языке и его стандартной библиотеки имеет ряд
преимуществ перед другими решениями. Тесная интеграция с
компиляторами и системами времени выполнения обеспечивает
потенциально более высокую производительность, а
переносимость облегчает широкое использование.
От C++ 11 до недавно ратифицированного стандарта C++
20, языковые конструкции были добавлены вместе с моделью
памяти, чтобы предоставить разработчику формализованную
семантику для модели памяти и параллельных примитивов,
потенциально устраняя необходимость в сторонних решениях.
Тем не менее, поскольку распараллеливание направлено
на высокую производительность, необходимо изучить качество
реализации этих стандартизированных средств, чтобы
определить их пригодность для замены устоявшихся решений.
С увеличением количества ядер в процессорах
исследователи продолжают искать более эффективные способы
использования этих ресурсов, что является непростой задачей.
Комитет C ++ также занимается превращением языка C ++ в
инструмент, который эффективно использует эти ресурсы.
Одним из преимуществ параллелизма является необходимость
повышения производительности, поэтому необходимо знать
производительность параллельных конструкций, которые будут
использоваться для повышения производительности программы,
поскольку они могут добавить ненужные затраты
производительности. Цель этой работы – оценить
производительность параллельных инструментов на языке C ++.
Для достижения поставленной цели в работе были
поставлены следующие задачи:
1) Обзор модели памяти параллелизма C ++
2) Стоимость создания и уничтожения потоков
3) Стоимость мьютекс и lock_guard
9
4) Ленивая инициализация
5) Читатель-писатель мьютекс
6) Сравнение производительности четырех разных
версий структуры данных стека: одна 1
последовательная и 3 параллельные версии.
7) Параллельные алгоритмы C ++ и
производительность параллельных версий C ++
алгоритма std::accumulate.

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
Объекты имеют следующие свойства:
• Имя (однако это необязательно)
• Хранение и его срок хранения
• Тип
• Продолжительность жизни
• Значение

Некоторые из этих объектов являются простыми


значениями фундаментальных типов, тогда как другие являются
экземплярами пользовательских классов. У некоторых есть
подобъекты, а у других нет.
Операции с памятью рассматриваются как операции с
абстрактными ячейками памяти. Каким бы ни был его тип, объект
хранится в одной или нескольких ячейках памяти, каждая ячейка
памяти является либо объектом (или подобъектом) скалярного
типа, таким как float и int, либо последовательностью смежных
битовых полей.
Хотя смежные битовые поля являются отдельными
объектами, они все равно считаются одним и тем же местом в
памяти. Стандарт C ++ был изменен, чтобы определить
отношение частичного порядка sequenced-before для операций с
памятью в одном потоке, отношение является частичным, потому
что C ++ все еще не определяет порядок оценки аргумента
Действие памяти в C ++ состоит из:
1. Тип действия, состоящий из операций синхронизации:
lock, unlock, atomic store, atomic load, atomic ready modify
write. И операции с данными: обычное чтение и запись.
2. Метка, обозначающая соответствующую программную
точку.
3. Значения, которые читаются и пишутся.
Модель памяти C ++ определяет выполнение одного
потока как набор действий с памятью вместе с частичным
порядком, соответствующим последовательности перед
упорядочением.
15
Определить <T как чередование действий в данном
потоке. Определить последовательное согласованное
выполнение программы как набор исполнений потоков, вместе с
общим порядком <T для всех действий с памятью, все это
возможно потому, что: каждое выполнение потока является
непротиворечивым, T согласованным с последовательным перед
порядком: если A последовательно перед A, то A <T B, каждая
операция атомарной и неатомарной загрузки считывает значение
из последней предыдущей записи в одну и ту же ячейку памяти в
соответствии с <T.
Конфликт определяется как одновременный доступ к
одной и той же ячейке памяти двумя разными потоками, и по
крайней мере один из них является неатомарной записью.
Гонка данных типа 1 существует в последовательном
выполнении последовательности, если две операции памяти из
разных потоков конфликтуют.
Затем определяется модель памяти [8]: если программа
(на заданном входе) имеет последовательное выполнение с
гонкой данных (тип 1), то ее поведение не определено. Если
программа свободна от данных, их последовательное
выполнение гарантировано.
1.4. Оптимизация, разрешенная моделью.

int Op1 =10; int Op1 = 10; std::atomic Op1 =10;


int Op2 = 10; std::atomic Op2(x); int Op2 = 10;

std::mutex m; std::mutex m; std::mutex m;


int Op1 =10; m.lock(); m.lock();
m.lock(); int Op2=10; int Op2=10;
int Op2=10; m.unlock(); m.unlock();
m.unlock(); int Op1 =10; m.lock();
int Op1=10;
m.unlock();
Таблица 1 Оптимизация, разрешенная моделью
16
Эта модель дает некоторую свободу компиляторам и
системам выполнения для некоторого переупорядочения памяти,
чтобы скрыть задержки. Система может свободно
переупорядочить последовательность Op1 перед Op2, если
переупорядочение не влияет на правильность выполнения одного
потока и: Op1 является операцией с данными, а Op2 является
синхронизацией чтения, Op1 записывает синхронизацию, а Op2
является операцией с данными или Op1 и Op2 являются данными
без перед ними синхронизируется последовательность
синхронизации, в этом примере мы также можем использовать
lock как синхронизацию чтения и unlock как синхронизацию
записи. Также разрешается переупорядочение
последовательности Op1 до последовательности Op2, если Op1
является данными, а Op2 - запись операции lock или Op1 - unlock,
а Op2 - чтение или запись lock.
Порядок памяти по умолчанию для атомарных операций
C++ - последовательная согласованность, аппаратная запись
должна выполнять атомарную запись по умолчанию, но другой
класс низкоуровневой атомарной записи не должен выполняться
атомарно в смысле протокола когерентности кэша.
Существенные ограничения налагаются моделью для операций
синхронизации, выполнение операций синхронизации должно
быть последовательным согласованием по отношению друг к
другу.
Ограничения налагаются на общие переменные,
операции с такими местами, как переменные локальной области
действия функции, не затрагиваются и могут быть
оптимизированы.

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);} );

Таблица 2 Эффективность Try_lock

Для последовательного выполнения согласования


утверждение в потоке 2 не может сработать, потому что в потоке
1 должно соблюдаться упорядоченное отношение перед тем, как
отношение в потоке 1. Но, если компилятор или оборудование
переупорядочивают инструкции в потоке 1, утверждение в
потоке 2 может сработать.
Для ограничения такого переупорядочения на многих
архитектурах требуется ограничение памяти перед блокировкой.
Добавление ограждения памяти перед блокировкой может
удвоить стоимость приобретения блокировки. Операции с thread2
также могут быть переупорядочены.
Модель памяти предотвращает такого рода связь между
потоками, чтобы избежать не только накладных расходов на
дополнительные ограждения, но и избежать сложности
определения различных типов синхронизации для определения
гонки данных.
Таким образом, модель памяти C ++ не дает никаких
гарантий, что попытка блокировки будет успешной, если
блокировка доступна, ожидается, что память будет таким
образом внезапно давать сбой Try_lock. Это решение избавляет
от необходимости обеспечивать забор и при этом поддерживать
простое определение гонки данных.
18
1.6. Стоимость последовательной последовательности
атомарности
Модель памяти C ++ требует, чтобы атомарные операции
по умолчанию казались последовательно согласованными, в
принципе, влияние этих требований на производительность
должно учитываться только операциями синхронизации, но, к
сожалению, большинство современных процессоров ISA не
различают операции синхронизации напрямую.
Наиболее распространенный способ ограничить
переупорядочение, выполняемое компилятором и
оборудованием, — это использовать инструкции для ограждений
или барьеров памяти. Кроме того, некоторые процессоры по
умолчанию обеспечивают определенные упорядочения, что
устраняет необходимость в ограждениях для этих случаев.
1.7. Стоимость принудительной записи-атомарности
Int x= 0; Int y= 0;
Thread1 Thread2 Thread 3 Thread 4

Thread1( Thread2( Thread3([&] () { Thread4([&] () {


[&] () { [&] () { intr1=x; intr3=y;
x = 1; y = 1; std::atomic_thread_ std::atomic_thread_
}); }); fence(); fence();
int r2 = y; int r4 = x ;
}); });

Таблица 3 стоимость записи атомарности

На таблице 3 обозначены независимые операции чтения-


независимости-записи (IRIW). Заборы гарантируют, что
операции чтения будут выполняться в программном порядке, но
это не гарантирует последовательной согласованности, если
записи выполняются неатомное.
Например, если записи в X и Y распространяются в
разных порядках в потоки T3 и T4, результат на рисунке,
нарушающий последовательную согласованность, может
19
произойти, потому что T3 видит новое значение X и старое
значение Y и наоборот для T4.
Системы с протоколами аннулирования на основе
владения и одноядерные /однопоточные процессоры могут
избежать непоследовательных последовательных результатов
простым способом.
Но в системах с протоколом согласованности кэша на
основе каталога очень дорого поддерживать последовательное
выполнение последовательности для этих примеров.
Появление одновременной многопоточности (SMT),
когда потоки могут совместно использовать кэш данных или
очереди хранения, еще больше усложняет оптимизацию
приведенного выше примера. Большинство программистов
согласны с тем, что код IRIW не представляет полезной идиомы
программирования, и вводят ограничения для обеспечения
последовательной согласованности для это кажется чрезмерным
и ненужным.
1.8. Последствия для текущих процессоров
Порядок памяти по умолчанию для атомарного элемента
по умолчанию - последовательная согласованность, под
влиянием работы над моделью памяти C ++, спецификации
упорядочения памяти AMD и Intel теперь предоставляют четкий
способ гарантировать последовательную согласованную
семантику.
Но поставщики оборудования требуют от поставщиков
компиляторов отображения атомарных записей в инструкции
xchg, а оборудование теперь должно обеспечивать гарантию того,
что xchg будет выполняться атомарно, а xchg также неявно
обеспечивает семантику Store | Load fence, решение о
преобразовании xchg было лучшим выбором. по двум основным
причинам: (1) лучше платить штраф за запись, чем за чтение,
потому что чтение происходит чаще, и (2) неявное ограничение,
предоставляемое xchg, дешевле, чем явное ограничение во
многих процессорах сегодня. Но большинство компьютеров
сегодня обеспечивают атомарность записи по умолчанию.
20
1.9. Влияние производительности на определение гонки
данных
Уровень системы аппаратного исполнения:
Семантика для гонки данных в C ++ не определена, и
одной из причин этого решения является производительность:
текущие оптимизации компилятора, и мало что можно получить,
разрешив гонки на атомах низкого уровня.
Мало того, что использование семантики для гонки
данных, такой как в Java, может значительно увеличить
стоимость тех же конструкций C ++.
Исходный код компилятора до исходного уровня:
Стандарт C ++ полностью запрещает гонки данных на
исходном уровне, поставщики компиляторов должны прекратить
использовать технику оптимизации, которая может вводить
гонки данных. Важные оптимизации, такие как умозрительное
чтение и запись, должны быть запрещены.
1.10. Модель C ++, поддерживающая
низкоуровневую атомизацию
Существуют какие-то идиомы или часть приложения,
которым не требуется дорогостоящее поведение
последовательной согласованности, для этих идиом модель
памяти C ++ обеспечивает низкоуровневые атомарные операции,
которые позволяют программисту явно указывать ограничения
упорядочения памяти, например, счетчики, которые
увеличиваются несколькими потоками, но читаются только после
завершения всех потоков. В этом примере семантика
последовательного выполнения непротиворечивости требует
больших затрат. Чтобы разрешить низкоуровневую атомарность,
потребовалась другая спецификация для последовательной
согласованности, эта спецификация определяет выполнение
программы, которое должно быть:
1) Набор потоковых исполнений
2) W отображает атомные и неатомные чтения на
атомные и неатомные записи.

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) Порядок модификации, общий порядок для одной
переменной.

3) Последовательность деблокирования, усилено


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

1.11. Отображения компилятора


На этапе проектирования модели памяти существовали
таблицы, отображающие атомарный доступ или функции в
ожидаемые реализации машинных инструкций на различных
25
целевых архитектурах: x86, Power, Arm. Эти таблицы относятся
к относительной стоимости примитивов и помогают понять
наименее распространенный порядок, предоставляемый каждым
из них. В приведенной ниже таблице показана реализация
примитивов C ++ 11 с различными вариантами выбора порядка
памяти по сравнению с архитектурами x86, Power, ARM и
Itanium. В таблице ниже приведены уточнения и расширения
ранних отображений на этапе проектирования.
C/C++11 X86
load relaxed mov (from memory)
load consume mov (from memory)
load acquire mov (from memory)
load seq_cst mov (from memory)
store relaxed mov (into memory)
store release mov (into memory)
store seq_cst xchg,mov(into memory),
fence acquire mfence
fence release ⟨ignore⟩
fence acq_rel ⟨ignore⟩
fence seq_cst ⟨ignore⟩
mfence
Таблица 4 Отображения компилятора X86
C/C++11 Power
load relaxed ld
load consume ld + keep dependencies
load acquire ld; cmp; bc; isync
load seq_cst hwsync; ld; cmp; bc; isync
store relaxed st
store release lwsync; st
store seq_cst hwsync; st
fence acquire lwsync
fence release lwsync
fence acq_rel lwsync
fence seq_cst hwsync
Таблица 5 Отображения компилятора POWER
26
C/C++11 ARM
load relaxed ldr
load consume ldr + keep dependencies
load acquire ldr; teq; beq; isb
load seq_cst ldr; dmb
store relaxed str
store release dmb; str
store seq_cst dmb; str; dmb
fence acquire dmb
fence release dmb
fence acq_rel dmb
fence seq_cst dmb
Таблица 6 Отображения компилятора ARM

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 не существует правильного ответа на вопрос,
какой из них выбрать, подавляющее большинство приложений,
связанных с вычислениями, имеют нормальное распределение,
но нет простого способа сравнить распределения.
Один из методов рекомендации, чтобы определить, какая
версия программы быстрее, состоит в том, чтобы взглянуть на
дистрибутивы разных версий одной и той же программы, и мы
можем сделать вывод с достаточно высоким уровнем
достоверности.

Рисунок 3 пример использования боксплота

Бокплоты широко используются для сравнения


распределений множества тестов на одном графике. Можно
сказать, что в std:: Once_flag зеленое поле быстрее синего.
2.2. Сбор образцов
Все тесты, выполненные в этом тезисе, я собрал образцы
с помощью библиотеки бенчмарков Google. При тестировании
производительности программы я использовал только одну
функцию бенчмарка. Когда исполняется бинарный тест, каждая
функция бенчмарка запускается последовательно. Количество
итераций, которые нужно выполнить, определяется
динамически: несколько раз выполняется тест, измеряется время,
необходимое для обеспечения статистической стабильности
30
конечного результата. Таким образом, более быстрые тестовые
функции будут выполняться для большего числа итераций, чем
более медленные тестовые функции, и, таким образом,
сообщается о количестве итераций.
Во всех случаях количество итераций, для которых
выполняется эталонный тест, определяется количеством
времени, которое занимает эталонный тест. Конкретно, число
итераций составляет, по крайней мере, одну, не более 1e9, до тех
пор, пока время ЦП не станет больше минимального времени или
пока время настенных часов не станет 5-кратным минимальным
временем. Минимальное время устанавливается для каждого
теста путем вызова MinTime для зарегистрированного объекта
теста. Несмотря на то, что для обеспечения стабильного
результата тест производительности Google может пройти более
тысячи итераций, я рассмотрел этот результат как образец,
поэтому 25 раз проводил один и тот же тест производительности,
чтобы собрать 25 образцов для каждой версии программы.
Но первые 8 собранных образцов не принимаются во
внимание из-за количества шума, который может быть там.
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

std::stack lock_based
стандартный атомный Расслабленный атомный

Рисунок 4 пример коллекции образцов

31
Например, на приведенном выше графике имеется 16
образцов для каждой из 4 разных версий одной и той же
программы.Результат каждой выборки отображается в
наносекундах, на этом графике мы можем увидеть
производительность каждой версии программы, которая решает
одну и ту же проблему, 16 раз, и мы можем сказать, что версия
std::stack является самой медленной, расслабленной атомарной
версия является второй самой медленной, а остальные версии
программы в этом случае работают почти одинаково. Именно так
следует наблюдать результат теста на графиках, подобных этим,
которые использовались в этой работе.
2.3. std::thread и std::jthread
C++20 предоставляет два способа создания потока,
используя std::thread или std::jthread, знание стоимости создания
и уничтожения потока очень важно при разработке
высокопроизводительных параллельных приложений, потому
что стоимость создания и уничтожение потока играет важную
роль в разработке приложения, потому что стоимость создания
потока очень высока во многих параллельных системах.

Рисунок 5 Тест std::thread and std::jthread


32
2.4. std::mutex и std::lock_guard<std::mutex>
Std::mutex является примитивом синхронизации,
используемым для обеспечения синхронизации между потоками,
std::lock_guard является шаблоном класса, который реализует
идиому RAII для мьютекса, и цель измерения
производительности этих параллельных конструкций состоит в
том, чтобы узнать, есть ли какие-либо издержки в реализации
std::lock_guard.

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

std::mutex lock and unlock std::lock_guard<std::mutex>


std::mutex try_lock and unlock()

Рисунок 6 Тест std::mutex и std::lock_guard<std::mutex>

2.5. Ленивая инициализация


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

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);
}

Рисунок 7 последовательная ленивая инициализация

Чтобы преобразовать этот код в параллельную версию,


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

std::atomic<bool> inita = false;


void execute(int x) {
std::unique_lock<std::mutex> lk(m);
if(!inita)
{
pushVector();
}
lk.unlock();
ReadVector(x);
}

Рисунок 8 ленивая инициализация на основе блокировки

Но есть проблема с кодом выше, этот код вызывает


ненужную социализацию потоков, в C++ есть возможность
написать лучшую параллельную версию программы выше, C++
предоставляет std::Once_flag и std::call_once, чтобы помочь в
таком ситуации, потому что это очень распространено.

34
std::once_flag resource_flag;

void execute(int x) {

std::call_once(resource_flag,pushVector);
ReadVector(x);

Рисунок 9 std :: call_once ленивая инициализация

Производительность двух функций была измерена, чтобы


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

Рисунок 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;

}
};

Рисунок 13 Защита редко обновляемых структур данных, реализация


мьютекса

Приведенный выше код является реализацией


программы, которую потоки очень редко будут записывать в
совместно используемые данные, и более часто потоки будут
читать совместно используемые данные, использование
std::mutex устраняет возможный параллелизм, когда потоки
только читают совместно используемые данные. , Библиотека C
++ предоставляет мьютексы, которые позволяют нескольким
потокам выполнять операции чтения одновременно, и только
один поток имеет доступ к общему ресурсу, когда один из
потоков выполняет запись в общие данные.

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;
}

void update_or_add_entry(std::string const& domain, int_fast64_t const&


details) {
std::lock_guard<std::shared_mutex> lk(entry_mutex);
entries[domain] = details;

};

Рисунок 14 Защита редко обновляемых структур данных, реализация общего


мьютекса

Приведенная выше реализация той же программы


использует std::shared_mutex, которая обещает дать лучшую
производительность, чем реализация, использующая std:: mutex,
когда потоки чаще читают общие данные и редко пишут в них.
Производительность двух версий программы была
измерена до известной, что является более быстрым,
производительность измерялась разными размерами входных
данных и различным числом потоков.
Знание того, что для очень большого числа операций
чтения и только нескольких операций записи std :: shared_lock с
std :: shared_mutex принесут большую победу над версиями std::
lock_guard с std::mutex, поэтому тесты были выполнены, чтобы
узнать производительность обеих версий программы, когда они
выполняют одинаковое количество операций чтения и записи

38
Рисунок 15 Тест 1: чтение и запись 1 раз и использование только одного потока
для каждой версии программы.

Рисунок 16 Тест 2: чтение и запись 50 раз и использование 5 потоков для


чтения и 5 потоков для записи для каждой версии программы.

39
Рисунок 17 Test 3: чтение и запись 50 000 раз и использование 5 потоков для
чтения и 5 потоков для записи для каждой версии программы.

2.7. Измерение производительности параллельной


структуры данных
Стек — это базовая структура данных, которую
логически можно представить как линейную структуру,
представленную реальным физическим стеком или кучей,
структуру, в которой вставка и удаление элементов происходит
на одном конце, называемом вершиной стека. Эта структура
используется во всем программировании. Базовая реализация
стека также называется LIFO (Last In First Out) для демонстрации
способа доступа к данным, поскольку, как мы увидим,
существуют различные варианты реализации стека.

40
Рисунок 18 структура данных стека

На практике очень распространено использование


структур данных, таких как стек, для обмена данными между
несколькими потоками. Было измерено 4 варианта реализации
стека, чтобы узнать, какая из них быстрая, с разными размерами
ввода данных и количеством потоков. Целью этих тестов
производительности является демонстрация возможностей
различных параллельных конструкций C++.Первая версия стека
является последовательной, это класс std::stack из стандартной
библиотеки C++. Класс std::stack является контейнерным
адаптером, который предоставляет программисту
функциональность стека, в частности, структуры данных LIFO
(последний пришел, первый вышел). Приведенный ниже код
является последовательной версией структуры данных стека,
помещающей значения x в стек.
std::stack<int> stk;

Рисунок 19 std::stack

Вторая версия стека - lock_based_stack, реализация


параллельного стека на основе блокировки, мьютекс
используется для синхронизации доступа к общим данным. Это
поток безопасная структура данных, похожая на 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;

void push(T new_value)


{
std::lock_guard<std::mutex> lock(m);
data.push(std::move(new_value));
}

Рисунок 20 lock_based_stack

Третья версия программы - реализация стека без


блокировки, использующая стандартную атомарную библиотеку
C ++.
template<typename T>
class lock_free_stack {
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)
{}
};

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;
}
}
}
};

Рисунок 21 стандартная атомарная реализация

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; }
}
}
};

Рисунок 22 расслабленная атомарная реализация

Производительность 4 версий программы была измерена,


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

20004

15004

10004

5004

std::stack Lock_based стандартный атомный Расслабленный атомный

Рисунок 23 Тест 1: Однократная запись в стек для всех версий программы и


использование только одного потока для всех параллельных версий программы.

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

std::stack Lock_based standard atomic relaxed atomic

Рисунок 25 Тест 3: запись 1000000 раз в стек для последовательной версии


стека, а также запись 1000 000 раз в стек для всех параллельных версий
программы и использование 10 потоков, каждый поток записывает 100000 раз
в стек.

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
стандартный атомный Расслабленный атомный

Рисунок 26 Тест 4: одно чтение в стеке для всех версий программы и


использование только одного потока для всех параллельных версий программы.

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

std::stack lock_based стандартный атомный Расслабленный атомный

Рисунок 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

Рисунок 30 Тест 1 суммирование 100 элементов в векторе

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

Рисунок 31 Тест 2 суммирование 1000 элементов в векторе

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

Рисунок 32 Тест 3 суммирование 1000000 элементов в векторе

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