Ответ на вопрос
Кратко и по существу.
1) Модели параллелизма
- Shared memory (общая память):
- Процессы/потоки разделяют адресное пространство (POSIX threads, OpenMP, pthreads).
- Доступ к общим данным — через чтение/запись в общую память; синхронизация — примитивы: мьютексы, семафоры, условные переменные, барьеры, атомарные операции, memory fences.
- Плюсы: низкая задержка обмена, простая передача сложных структур (указатели).
- Минусы: необходимость синхронизации при конкурентном доступе, проблемы видимости и упорядочения операций (memory model).
- Message passing (распределённая/сообщения):
- Каждый процесс имеет собственное адресное пространство; обмен — явной передачей сообщений (MPI, акторная модель).
- Плюсы: явная локализация данных, проще масштабировать по узлам, меньше проблем с совместным доступом.
- Минусы: задержки коммуникации, необходимость декомпозиции задачи и управления коммуникацией.
2) Синхронизация — проблемы и механизмы
- Примитивы: мьютекс (mutual exclusion), read–write locks, семафоры, барьеры, condition variables, атомарные инструкции (compare-and-swap, fetch-and-add).
- Память и упорядочение: даже при синхронизации нужно учитывать memory fences и модель памяти языка/архитектуры — операции могут видеться в разном порядке на разных ядрах.
- Deadlock (взаимная блокировка), livelock, priority inversion — типичные логические ошибки при ненадёжном дизайне блокировок.
- Производительность: блокировки ведут к контеншену; лучше использовать fine-grained locks, lock-free/многоуровневые структуры или алгоритмы с минимальным contention.
3) Видимые эффекты гонок (data races) и их проявления
- Непредсказуемые результаты вычислений (недетерминированность).
- Потерянные обновления: два потока читают одно значение и перезаписывают результат, один апдейт теряется.
- Состояния частично обновлённых структур (коррупция данных).
- Torn reads/writes (разрыв при неблокированном доступе к многобайтовым типам на некорректной архитектуре).
- Наблюдаемое несогласие (stale reads): чтение устаревшего значения из-за отсутствия барьера.
- Системные ошибки: сегфолты, бесконечные циклы при нарушении инвариантов.
- Инструменты для поиска: ThreadSanitizer, Helgrind, статический анализ; тестирование с повторными запусками и стресс-тесты.
4) Когда имеет смысл GPU (CUDA/OpenCL) вместо CPU
- Подходит, если задача обладает:
- Большим количеством независимых однотипных вычислений (data parallel, SIMD).
- Высокой арифметической интенсивностью — много операций на один прочитанный байт памяти.
- Регулярным доступом к памяти (или возможность трансформировать в регулярный).
- Большой степенью параллелизма (тысячи активных потоков).
- Не подходит/мало смысла, если:
- Сильные последовательные зависимости и низкий параллелизм.
- Частые синхронизации между потоками с мелкими задачами.
- Малые задачи с большим накладным расходом на копирование данных CPU↔GPU.
Простейшая формализация экономии: арифметическая интенсивность \(\mathrm{AI}=\dfrac{\text{FLOPs}}{\text{bytes transferred}}\). GPU выгодна, когда \(\mathrm{AI}\) достаточно велика, чтобы скрыть стоимость доступа в глобальную память.
Также полезна оценка по Amdahl'у: при доле параллельной работы \(p\) и числе потоков \(N\)
\[
S(N)=\frac{1}{(1-p)+\dfrac{p}{N}}.
\]
5) Какие изменения в алгоритмах требуются для GPU
- Экспонировать много параллелизма: распараллелить по элементам, блокам, батчам.
- Увеличить арифметическую интенсивность: переструктурировать вычисления так, чтобы на единицу чтения приходилось больше операций (fusion, compute-heavy kernels).
- Обеспечить регулярный, коалесцированный доступ к памяти: выравнивание, упорядоченная загрузка/запись чтобы избежать разрозненных транзакций.
- Использовать локальную (shared/local) память для тайлинга/кэширования: разбивать данные на блоки (tiling), загружать в fast shared memory, выполнять много операций, затем писать назад.
- Минимизировать divergence ветвлений внутри warps/квартетов: переписать условные ветви или разделять данные по паттернам.
- Ограничить синхронизацию: на GPU есть синхронизация внутри блока (CUDA: \(\_\_syncthreads()\)), глобальная синхронизация только при завершении kernel; следовательно, алгоритмы должны быть организованы через последовательные kernel'ы или использовать атомики.
- Избегать частых CPU↔GPU копирований: пакетировать данные, асинхронные копии (streams), overlap вычислений и передачи.
- Выбирать GPU-дружественные алгоритмические варианты: например, для сортировки — radix/bitonic/merge оптимизированные для SIMD; для свёрток — tiled GEMM или FFT-методы; для графов — frontier-based BFS, edge-centric параллелизм.
- Учесть ресурсы: ограничение регистров и shared memory на блок — управлять occupancy (число одновременно исполняемых warps).
- Использовать высокоуровневые примитивы (thrust, cuBLAS, cuDNN, OpenCL primitives) там, где они покрывают задачу.
6) Практические советы
- Профилировать (nvprof, Nsight, профайлеры OpenCL) — выяснить, узкое место в памяти или в вычислениях.
- Начинать с прототипа: простая параллельная версия, затем оптимизировать коалесценцию, shared memory, unrolling.
- Если алгоритм содержит значительные зависимые последовательности — лучше CPU или гибрид CPU+GPU.
- Тестировать на корректность при параллельном исполнении и использовать детекторы гонок.
Кратко: shared memory — удобен, но требует строгой синхронизации; message passing — явная коммуникация и лучшая масштабируемость; гонки дают недетерминированные и/или повреждённые данные; GPU эффективен для большого, регулярного, арифметически интенсивного параллелизма и требует перестройки алгоритма под массовый SIMD-параллелизм, коалесцированный доступ к памяти, тайлинг и минимизацию синхронизаций.
Еще