Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions tasks/gusev_d_radix_double/common/include/common.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#pragma once

#include <string>
#include <tuple>
#include <vector>

#include "task/include/task.hpp"

namespace gusev_d_radix_double {

using InType = std::vector<double>;
using OutType = std::vector<double>;
using TestType = std::tuple<int, std::string>;
using BaseTask = ppc::task::Task<InType, OutType>;

} // namespace gusev_d_radix_double
9 changes: 9 additions & 0 deletions tasks/gusev_d_radix_double/info.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"student": {
"first_name": "Дмитрий",
"last_name": "Гусев",
"middle_name": "Алексеевич",
"group_number": "3823Б1ФИ1",
"task_number": "3"
}
}
22 changes: 22 additions & 0 deletions tasks/gusev_d_radix_double/mpi/include/ops_mpi.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#pragma once

#include "gusev_d_radix_double/common/include/common.hpp"
#include "task/include/task.hpp"

namespace gusev_d_radix_double {

class GusevDRadixDoubleMPI : public BaseTask {
public:
static constexpr ppc::task::TypeOfTask GetStaticTypeOfTask() {
return ppc::task::TypeOfTask::kMPI;
}
explicit GusevDRadixDoubleMPI(const InType &in);

private:
bool ValidationImpl() override;
bool PreProcessingImpl() override;
bool RunImpl() override;
bool PostProcessingImpl() override;
};

} // namespace gusev_d_radix_double
101 changes: 101 additions & 0 deletions tasks/gusev_d_radix_double/mpi/src/ops_mpi.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#include "gusev_d_radix_double/mpi/include/ops_mpi.hpp"

#include <mpi.h>

#include <algorithm>
#include <cstddef>
#include <utility>
#include <vector>

#include "gusev_d_radix_double/common/include/common.hpp"
#include "gusev_d_radix_double/seq/include/ops_seq.hpp"

namespace gusev_d_radix_double {

GusevDRadixDoubleMPI::GusevDRadixDoubleMPI(const InType &in) {
SetTypeOfTask(GetStaticTypeOfTask());
GetInput() = in;
}

bool GusevDRadixDoubleMPI::ValidationImpl() {
return true;
}

bool GusevDRadixDoubleMPI::PreProcessingImpl() {
return true;
}

bool GusevDRadixDoubleMPI::RunImpl() {
int rank = 0;
int size = 0;
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);

int n = 0;
if (rank == 0) {
n = static_cast<int>(GetInput().size());
}

MPI_Bcast(&n, 1, MPI_INT, 0, MPI_COMM_WORLD);

std::vector<int> send_counts(size);
std::vector<int> displs(size);

int remainder = n % size;
int sum = 0;
for (int i = 0; i < size; ++i) {
send_counts[i] = (n / size) + (i < remainder ? 1 : 0);
displs[i] = sum;
sum += send_counts[i];
}

std::vector<double> local_data(send_counts[rank]);

MPI_Scatterv(GetInput().data(), send_counts.data(), displs.data(), MPI_DOUBLE, local_data.data(), send_counts[rank],
MPI_DOUBLE, 0, MPI_COMM_WORLD);

GusevDRadixDoubleSEQ::RadixSort(local_data);

int step = 1;
while (step < size) {
if (rank % (2 * step) == 0) {
int source = rank + step;
if (source < size) {
int recv_count = 0;
MPI_Recv(&recv_count, 1, MPI_INT, source, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE);

size_t current_size = local_data.size();
local_data.resize(current_size + recv_count);

MPI_Recv(local_data.data() + current_size, recv_count, MPI_DOUBLE, source, 0, MPI_COMM_WORLD,
MPI_STATUS_IGNORE);

auto mid_iter = local_data.begin() + static_cast<std::ptrdiff_t>(current_size);
std::ranges::inplace_merge(local_data, mid_iter);
}
} else if (rank % (2 * step) == step) {
int dest = rank - step;
int count = static_cast<int>(local_data.size());

MPI_Send(&count, 1, MPI_INT, dest, 0, MPI_COMM_WORLD);
MPI_Send(local_data.data(), count, MPI_DOUBLE, dest, 0, MPI_COMM_WORLD);

local_data.clear();
local_data.shrink_to_fit();
break;
}
step *= 2;
}

if (rank == 0) {
GetOutput() = std::move(local_data);
}

return true;
}

bool GusevDRadixDoubleMPI::PostProcessingImpl() {
return true;
}

} // namespace gusev_d_radix_double
161 changes: 161 additions & 0 deletions tasks/gusev_d_radix_double/report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Поразрядная сортировка для вещественных чисел (тип double) с простым слиянием
- Студент: Гусев Дмитрий Алексеевич, 3823Б1ФИ1
- Технология: SEQ, MPI
- Вариант: 20

---

## 1. Введение
Поразрядная сортировка (Radix Sort) — это алгоритм сортировки, который не использует сравнения элементов между собой. Вместо этого он распределяет элементы по группам ("карманам") в зависимости от цифр в их записи.

Классический Radix Sort работает с целыми неотрицательными числами. Однако в данной работе рассматривается более сложная модификация алгоритма для сортировки вещественных чисел двойной точности (double). Это требует учета стандарта IEEE 754 и специфичной работы с битовыми масками для корректной обработки отрицательных чисел.

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

---

## 2. Постановка задачи
**Исходные данные**: Вектор вещественных чисел $N$ типа double.

**Цель**: Упорядочить элементы вектора по возрастанию.

**Алгоритм**: Least Significant Digit (LSD) Radix Sort с основанием системы счисления 256 (сортировка по байтам).

**Требования**:
- Реализовать последовательную версию алгоритма, способную корректно сортировать double (включая отрицательные числа).
- Реализовать параллельную версию на MPI.
- Реализовать эффективную схему слияния данных от разных процессов.
- Обеспечить корректную работу при неравномерном распределении данных ($N$ не делится нацело на число процессов).

---

## 3. Последовательный алгоритм
Основная сложность сортировки double поразрядным методом заключается в представлении чисел в памяти (IEEE 754). Если просто интерпретировать double как uint64_t, то отрицательные числа окажутся "больше" положительных (из-за знакового бита 1), а порядок отрицательных чисел будет инвертирован.

Алгоритм: Выполняется 8 проходов (так как sizeof(double) == 8 байт). На каждом проходе используется сортировка подсчетом (Counting Sort) по текущему байту.

Временная сложность: $O(N \cdot K)$, где $K$ — количество байт в числе (8). Фактически линейная $O(N)$.

---

## 4. Параллельная схема
- **Роли процессов**:
- Все процессы (Rank 0..P-1): Выполняют локальную сортировку своей части данных.
- Rank 0 (Root): Выполняет распределение данных, сбор отсортированных частей и их итоговое слияние.
- **Распределение данных**:
- Вектор разбивается на $P$ частей.
- Используются массивы смещений (displs) и размеров (send_counts) для обработки случая, когда $N \% P \neq 0$.
- **Коммуникация**:
- Рассылка (Scatter): MPI_Scatterv распределяет исходный массив по процессам.
- Слияние (Merge): Реализовано параллельное древовидное слияние (Parallel Tree Merge). Данные не собираются на одном узле сразу, а сливаются иерархически ($1 \to 0, 3 \to 2$, затем $2 \to 0$ и т.д.).

---

## 5. Реализация
- **Структура проекта:**
- `gusev_d_radix_double/seq/include/ops_seq.hpp` и `gusev_d_radix_double/seq/src/ops_seq.cpp` — однопоточная версия.
- `gusev_d_radix_double/mpi/include/ops_mpi.hpp` и `gusev_d_radix_double/mpi/src/ops_mpi.cpp` — MPI-версия.
- `gusev_d_radix_double/common/include/common.hpp` — общие типы и интерфейсы.
- **Реализационные детали**:
- **Работа с типами.**
Для "сырой" работы с битами используется std::memcpy и uint64_t.
- **Оптимизация слияния.**
Вместо последовательного слияния ($O(N \cdot P)$), реализовано попарное слияние (Tree Merge). На каждом шаге соседние блоки сливаются с помощью std::inplace_merge внутри одного буфера. Это снижает сложность слияния до $O(N \cdot \log P)$ и минимизирует аллокации памяти.
- **Граничные случаи.**
Обработаны пустые вектора и вектора из 1 элемента.

---

## 6. Тестовая конфигурация
- **Оборудование**:
Процессор: Intel(R) Core(TM) Ultra 9 185H (16 ядер, 22 логических процессора)
ОЗУ: 32 ГБ
- **ОС:** Windows 10 IoT Enterprise Subscription 2009
- **Компилятор:** Microsoft Visual C++ версии 19.44.35220
- **MPI:** Microsoft MPI версии 10.1.12498.18
- **Параметры задачи:** $N = 25\,000\,000$ элементов для нагрузочных тестов.

---

## 7. Результаты экспериментов
### 7.1 Верификация
Корректность проверялась с помощью Google Test. Реализовано 15 сценариев тестирования:
- Базовые: Малые, средние, большие векторы.
- Краевые: Пустой вектор, один элемент.
- Специфичные: Только положительные, только отрицательные, нули (проверка -0.0 и +0.0).
- Предварительно отсортированные: Прямой и обратный порядок (Best/Worst case).
- Неравномерные: Размер вектора 123 (не делится нацело, проверка Scatterv).

Результаты сравнивались с std::sort. Все тесты пройдены успешно.

### 7.2 Измерения производительности
Эксперименты по оценке производительности выполнялись на конфигурации, описанной в разделе 6.

Измерения времени выполнения проводились для последовательной (SEQ) и параллельной (MPI) версий при различном количестве процессов. Результаты представлены в таблице ниже.

| Mode | Count | Time, s | Speedup | Efficiency |
|------|-------|---------|----------|--------------|
| seq | 1 | 1.03859 | 1.000 | N/A |
| mpi | 2 | 1.03443 | 1.004 | 50.2% |
| mpi | 4 | 0.96254 | 1.079 | 27.0% |
| mpi | 8 | 1.11908 | 0.928 | 11.6% |

**Анализ результатов:**
- Memory Bound природа задачи: Поразрядная сортировка (Radix Sort) — это алгоритм с линейной вычислительной сложностью $O(N)$. Арифметическая интенсивность (отношение вычислений к обращениям в память) крайне низка. Скорость работы алгоритма на современных процессорах полностью определяется пропускной способностью шины памяти.
- При запуске 1 процесса, вся пропускная способность канала памяти доступна одному ядру.
- При запуске 2 процессов (Speedup 1.004), два ядра начинают конкурировать за доступ к RAM. Суммарная пропускная способность системы не удваивается, а лишь немного возрастает или делится между ядрами. В результате время практически не изменилось по сравнению с последовательной версией.
- При 4 процессах (Speedup 1.079), конкуренция усиливается. Небольшое ускорение (~8%) достигнуто, вероятно, за счет более эффективного использования кэшей L2/L3 разных ядер (данные разбиты на куски по ~47 МБ, что лучше помещается в иерархию памяти).
- При 8 процессах (Speedup 0.928), накладные расходы на MPI (пересылка данных через разделяемую память) и когерентность кэшей начинают доминировать. Система переходит в режим "трэшинга" шины памяти, и время выполнения становится даже больше, чем у последовательной версии.
- Для алгоритмов класса $O(N)$ на Shared Memory архитектуре (один многоядерный ПК) масштабируемость ограничена аппаратной пропускной способностью памяти. MPI-реализация "упирается" в Memory Wall. Эффективное распараллеливание Radix Sort возможно либо на распределенных системах (кластерах), где каждый узел имеет свою независимую память, либо на GPU, где пропускная способность памяти на порядок выше.

---

## 8. Выводы
- Реализован оптимизированный (in-place) алгоритм Radix Sort для double.
- Реализована параллельная MPI-версия с использованием Tree Merge и Zero-allocation техник.
- Эксперименты на 25 млн элементов показали, что на одной машине алгоритм демонстрирует насыщение производительности уже при 2-4 процессах.
- Доказано, что для Memory Bound задач линейной сложности основным бутылочным горлышком является шина памяти, а не вычислительная мощность ядер.

---

## 9. Литература
- Материалы курса "Параллельное программирование"
- Справочная документация Open MPI
- Документация Microsoft MPI
- Документация Microsoft Visual C++
- Стандарт IEEE 754

---

## 10. Приложение
- In-place преобразование в SEQ:
```cpp
void GusevDRadixDoubleSEQ::RadixSort(std::vector<double>& data) {
size_t n = data.size();
std::vector<uint64_t> raw_data(n);
// Копирование вместо reinterpret_cast для безопасности
std::memcpy(raw_data.data(), data.data(), n * sizeof(double));

// In-place Bit Flipping
for (size_t i = 0; i < n; ++i) {
uint64_t mask = 0x8000000000000000ULL;
if ((raw_data[i] & mask) != 0) {
raw_data[i] = ~raw_data[i];
} else {
raw_data[i] |= mask;
}
}

// ... Radix Sort logic ...
}
```
- Zero-allocation Merge в MPI:
```cpp
// При слиянии расширяем вектор и принимаем данные в конец
size_t current_size = local_data.size();
local_data.resize(current_size + recv_count);
MPI_Recv(local_data.data() + current_size, recv_count, MPI_DOUBLE, ...);

// Слияние без дополнительного буфера
std::inplace_merge(local_data.begin(), local_data.begin() + static_cast<std::ptrdiff_t>(current_size), local_data.end());
```
26 changes: 26 additions & 0 deletions tasks/gusev_d_radix_double/seq/include/ops_seq.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#pragma once

#include <vector>

#include "gusev_d_radix_double/common/include/common.hpp"
#include "task/include/task.hpp"

namespace gusev_d_radix_double {

class GusevDRadixDoubleSEQ : public BaseTask {
public:
static constexpr ppc::task::TypeOfTask GetStaticTypeOfTask() {
return ppc::task::TypeOfTask::kSEQ;
}
explicit GusevDRadixDoubleSEQ(const InType &in);

static void RadixSort(std::vector<double> &data);

private:
bool ValidationImpl() override;
bool PreProcessingImpl() override;
bool RunImpl() override;
bool PostProcessingImpl() override;
};

} // namespace gusev_d_radix_double
Loading
Loading