假期突然延长,为了不荒废人生,决定趁这两天补一下课,把之前没有修过的并行与分布式计算补习一下。
这门课主要教了MPI, Pthread, OpenMP和CUDA,内容围绕着并行计算和高性能计算展开,比较繁杂,知识点很琐碎,但实际上很深入很难的东西倒是没有。我是奔着学习技术来的,那自然是适当忽略理论部分,多写代码。
MPI是分布式内存设计,Pthread和OpenMP都是共享内存设计, Pthread比起 OpenMP更底层,设计起来自由度高但也更困难,所以我选择只学MPI和OpenMP。两者都花了两天左右的时间学习,过程中一边看书一边敲代码,感觉学习效率比开学时间高了不少(
这里我就用两篇博客来介绍两个简单的项目,简单讲一下一些OpenMP的使用方法和并行设计的理念。
Open Multi-Processing的缩写,是一个应用程序接口(API)。它是一个可以被编译器链接的库,它的并行是编译器决定的,所以使用起来很方便。它比起MPI的优点很明显,在编写程序时,可以动态的产生多线程,使用完了就可以合并回主线程,灵活度高;在多线程运行时还能共同使用公有变量,操作方便。像MPI这种从头并行到尾的,就需要我们更费力地思考要添加怎样的分支语句,要怎样分发数据才能抑制内存的浪费。还有一点,就是它在Windows的IDE上使用更加方便,对初学者更友好。
最常用的语句有
pragma omp parallel num_threads(X) //线程并行
pragma omp parallel for num_threads(X) //for循环并行
pragma omp critical //声明临界区,用于保护变量
这里用一个简单的例子介绍一下并行的数据结构设计思想。
考虑这样一个问题:我们有多个文件,每个文件中有特殊的数据要交给一种处理程序去处理。那么在多核处理器上,如何编写程序高效地解决问题呢?
首先考虑单处理器的情形,我们处理文件的方式自然是把文件的信息从头到尾进行读取,然后一段一段地交给处理程序进行处理,如果处理程序忙不过来,那么就需要队列让这些数据在此等待。
但是在多核处理器上,就有剩出来的核处于空闲状态。我们希望让这些多出来的核也去执行读文件和处理数据的工作。那我们的问题就来了,在多核并发地读取文件的时候,我们该如何正确的设计队列对数据排序呢?
我们回忆一下队列的结构,我们对队列的操作主要就是出队pop和入队push两种而已。数据域一般保存一个队首,一个队尾用来操作。于是我们发现,push和pop是可以并行的!(除了队列长度为1或为0的状态),这样我们完全可以用不同的线程,一边入队,一边出队。
再进一步思考,push入队的操作时间必然远小于线程读文件的时间。也就是线程在读文件时,完全可以让其他已经读好一段数据的线程去执行push,只要线程足够多,单位时间push的量就能足够大。所以,我们可以用多线程去并行读取文件,而串行执行push操作。
再再进一步思考,即使我们push的足够快,但读完文件后如果处理程序没能及时处理完队列内的数据,我们的任务还是没结束,我们还是要等待处理程序把队列处理干净。前面说过读文件和push可以并行,那么处理数据和pop一样可以。所以我们可以设计多线程去并行处理数据,串行pop操作。
至此我们的任务已经很明确了
push和pop同时进行只需要考虑一种特殊情况,即队列为空和队列长度为1时。队列为空时,pop不能进行;队列长为1时,push和pop互斥。
还有一点,一般队列需要保存队列长度以便使用。然而push和pop的并行会对length造成同时操作,进而让length变得不可预期。因此有必要用enqueue和dequeue两个变量来表示length,以避免这一冲突。
同时请求pop(push)时,根据队列的实现方式有不同的处理方案。如果是循环数组实现,则可以通过特殊指定下标来实现同时pop(push),如果是链表实现,则多个操作必须串行执行,它们是互斥的。
这里我们进一步明确问题: 有N个文件,内部的数据形式如下
destination //目标队列
aaa bbbb ccccc dddddd // 一些由空格分割的单词
destination //目标队列
aaa bbbb ccccc dddddd // 一些由空格分割的单词
...
包含这样的多组数据,每组的含义是:把这行单词字符串发送给destination号处理程序的队列。现在设计N个线程,一对一并行地读这些文件,并在读的过程中把字符串发送给destination号队列。此外,再设计M个线程,每个线程对应一个队列,对队列中的字符串进行分割处理,在处理时不断打印单词到stdout中。
很明显,我们可以用线程号指定每个线程是应该读文件还是处理字符串,还可以用线程号指定每个线程要处理的是哪个文件、哪个队列。文件和队列都可以用数组存储,用下标访问。其中队列和文件都应该被设计为共享变量;当队列长度为1时,push和pop是critical的;当队列被多个线程push时,应该设置线程锁,以避免多个线程同时push一个队列造成的不好影响。
cpp代码如下,这里的队列使用链表实现
#include
#include
#include
#include
#include
#include
using namespace std;
/*
问题:实现并发访问和处理的队列,队列接受字符串。生
产者4个线程,消费者4个线程,生产者从文件中读取文本行
并把它插入目标序号的队列。同时,消费者从队列重复地读取
message,并用split函数分词,在分词的过程中不断输出它,
直到生产者的文件都为空且消费者队列也被处理完毕。
分析:
每个消费者对应一个队列,不同队列可以被同时访问,但
一个队列不能同时被多个生产者访问,这里需要上特殊的线程锁。
生产者和生产者,消费者和消费者之间不需要同步,因为任务结束条件
都是一样的all(file clear and queue empty),只需要在8个线程的
任务结束阶段设置一个barrier即可。
允许队列被同时push和pop,如果用链表实现queue,则删除队首元素
和在队尾增加元素并不冲突,但是同时修改队列长度n会造成冲突,所以
我们用enqueue和dequeue两个参数描述队列长度,就不会造成冲突。
文件格式:文件两行为一组,第一行为0-3的编号,表示要发送到的目标
消费者线程;第二行为一段含空格的字符串,即需要发送的消息字段。
*/
void Split(string info) {
//cout << info + '\n';
string word;
for (char ch : info) {
if (ch != ' ')
word += ch;
else {
cout << word + '\n';
word.clear();
}
}
cout << word + '\n';
}
struct Node
{
string info;
Node* next = nullptr;
};
struct Queue
{
Node* head, *tail;
int Enqueue, Dequeue;
omp_lock_t lock;
Queue()
{
head = tail = nullptr;
Enqueue = Dequeue = 0;
omp_init_lock(&lock);
}
};
void push(Queue* q, string s) {
Node* n = new Node;
n->info = s;
if (!q->tail) {
q->head = n;
q->tail = n;
}
else {
Node* old_tail = q->tail;
q->tail = n;
old_tail->next = q->tail;
}
q->Enqueue++;
}
string pop(Queue* q) {
if (!q->head)
return "";
else {
Node* newHead = q->head->next;
string msg = q->head->info;
delete q->head;
if (q->tail == q->head)
q->tail = newHead;
q->head = newHead;
q->Dequeue++;
return msg;
}
}
static bool empty(Queue* q)
{
return (q->head == nullptr);
}
int length(Queue* q) {
Node* n = q->head;
int len = 0;
while (n)
{
n = n->next;
len++;
}
return len;
}
bool Done(ifstream files[])
{
for (int i = 0;i < 4;i++) {
if (files[i].eof())
continue;
else
return false;
}
return true;
}
static omp_lock_t lock, lock2;
int main()
{
omp_init_lock(&lock);
omp_init_lock(&lock2);
ifstream files[4] = {
ifstream("file0.txt"),
ifstream("file1.txt"),
ifstream("file2.txt"),
ifstream("file3.txt")
};
Queue queues[4];
//string msg;
//while (getline(files[0], msg))
//{
// cout << msg << endl;
//}
# pragma omp parallel num_threads(8)
{
int my_rank = omp_get_thread_num();
if (my_rank < 4) {
int dest;string msg;
while (getline(files[my_rank], msg)) {
dest = msg[0] - '0';
getline(files[my_rank], msg);
omp_set_lock(&(queues[dest].lock));
push(&queues[dest], msg);
//printf("Push line into queue %d\nNow length of queue %d is %d\n", dest, dest, length(&(queues[dest])));
omp_unset_lock(&(queues[dest].lock));
}
}
else {
int num = my_rank - 4;
string msg;
while (!(Done(files) and empty(&queues[num]))) {
if (empty(&queues[num]))
continue;
else {
omp_set_lock(&(queues[num].lock));
msg = pop(&queues[num]);
Split(msg);
omp_unset_lock(&(queues[num].lock));
//printf("Queue %d has dealed message...\nlength is %d\n", num, length(&queues[num]));
}
}
}
# pragma omp barrier
}
}
简单测试性能就可以发现,随着线程数增加,处理效率成倍提升。
本文只是以队列为例子,介绍了OpenMP的使用和并行数据结构的设计方法。下一篇博客我会讲一下如何把常见的算法并行化。