高性能计算复习
并行计算
-
为什么需要并行计算?
总体来说,并行计算的目的是提高计算性能。为了达到这个目的,在计算机发展的各个阶段有着不同的手段。- 提高处理器字长
- 提高芯片集成度
- 微体系结构技术革新
- 提高处理器频率
此后,由于集成度、指令集并行度、存储器速度、功耗和散热等等问题的限制,单核CPU性能达到极限。于是进入了多核、众核CPU时代。
与此同时在应用领域计算规模和复杂度也大幅提高,在此种情况下,唯有并行计算才能满足需求。
答:在计算机处理器单核性能达到极限后发展出了多核、众核系统,在应用领域计算规模和复杂度大幅提升的情况下,唯有将并行计算技术与多核、众核系统结合,才能满足计算需求。 -
并行计算技术的分类
- 按照数据和指令处理分类:
SISD、SIMD、MIMD (I for instructions、 D for data) - 按照并行类型分类:
位级并行、指令级并行、★线程级并行
其中线程级并行又分为:- 数据级并行:一个大的数据块划分为小块,分别由不同的处理器/线程处理
- 任务级并行:一个大的计算任务划分为子任务分别由不同的处理器/线程来处理
- 按照存储访问结构分类:
- 共享内存(所有处理器通过总线共享内存)
- 分布共享内存(每个处理器有本地独享存储器再共享一个全局存储器)
- 分布式内存(每个处理器都使用本地独立的存储器)
- 按照计算特征分类:
- 数据密集型并行计算:数据量极大、但计算相对简单的并行处理 如:大规模Web 信息搜索
- 计算密集型并行计算:数据量相对不是很大、但计算较为复杂的并行处理 如:3-D建模与渲染,气象预报,科学计算……
- 混合型并行计算:如3D电影渲染
- 按照并行程序设计模型/方法分类:
- 共享内存型方式:需要提供数据访问同步控制机制以保证并发安全。
- 消息传递方式:需要提供并行系统中计算节点间数据通信的方法。
- MapReduce方式:为解决前二者在并行程序设计上的缺陷,为程序员提供了一种简易便捷的并行程序框架。
- 按照数据和指令处理分类:
-
本课程的核心内容就是围绕以上三种设计模型/方法的技术问题:
- 共享内存型:共享资源同步机制
- 分布存储型:消息传递机制、并行计算的同步控制(barrier、同步障)
- 共同问题:可靠性与容错技术
而并行计算软件框架平台,如MapReduce就把这些技术问题交由软件实现,将并行程序的底层细节隐藏,大大提高了并行程序设计的便捷性,同时还提供了极高的拓展性和因此带来的系统性能提升。
-
系统性能和并行度评估
- 用标准性能评估(Benchmark)方法评估一个并行计算系统的浮点计算能力。
High-Performance Linpack Benchmark是最为知名的评估工具,TOP500用其进行评估和排名 - 经典的程序并行加速评估公式Amdahl定律:
其中S是加速比、P为程序可并行比例、N为处理器数目
- 根据Amdahl定律:一个并行程序可加速程度是有限制的,并非可无限加速,并非处理器越多越好
- 并行比例vs加速比
- 50%=>最大2倍
- 75%=>最大4倍
- 90%=>最大10倍
- 95%=>最大20倍
- 用标准性能评估(Benchmark)方法评估一个并行计算系统的浮点计算能力。
MPI
Message Passing Interface,基于消息传递的高性能并行计算编程接口。
主要功能:
- 点对点通信
- 阻塞通信
- 非阻塞通信
- 节点集合通信
- 一对多广播通信
- 多点计算同步控制
- 提供对结果的规约(Reduce)计算功能
- 提供用户自定义类型的数据传输
MPI程序结构
#include <mpi.h> //MPI程序头文件
main(int argc, char **argv){
int numtasks, rank;
MPI_Init(&argc, &argv); //初始化MPI程序
//并行计算程序体
MPI_Finalize(); //关闭MPI环境
exit(0);
}
六个API
- MPI_Init (argc, argv) : 初始化MPI,开始MPI并行计算程序体
- MPI_Finalize: 终止MPI并行计算
- MPI_Comm_Size(comm, &size): 确定指定范围内处理器/进程数目
- MPI_Comm_Rank(comm, &rank) : 确定一个处理器/进程的标识号
- MPI_Send (buf, count, datatype, dest, tag, comm) : 发送一个消息
- MPI_Recv (buf, count, datatype, source, tag, comm, status) : 接受消息
- 注意到,上述的Size、Rank函数是通过传入变量指针的方式来返回结果,函数没有显式的返回值。
- 上述的comm是一个通信组(communicator)的标识符,一个process可以从属于多个通信组,MPI中对整个系统有一个默认的缺省通信组
MPI_COMM_WORLD
。 - 一个通信组中的process数量可以通过
MPI_Comm_Size(comm, &size)
获得。 - 一个通信组中的某一个process的编号可以通过
MPI_Comm_Rank(comm, &rank)
获得。 - 在MPI的样例代码中似乎没有提到会开启的process数量,查阅资料发现MPI程序一般是在编译之后通过
mpiexec.exe -n 4 ./yourprogram.exe
的方式执行,这里的-n参数就可以指定在本机上开启的process数量。如果需要在多机上部署,则按照以下指令运行mpiexec.exe –hosts n serverip_1 procnum_1 serverip_2 procnum_2 …serverip_n procnum_n –noprompt yourprogram.exe
。
非阻塞式消息的Send、Recv方法与阻塞式的有差别,需要引入一个request变量,这里不是考试重点,就不细说。
两个示例代码:
- 计算大数组元素平方根的和
#include <stdio.h>
#include <mpi.h>
#include<math.h>
#define N=1002
int main(int argc, char** argv){
int myid, P, source, C=0;
double data[N], SqrtSum=0.0;
MPI_Status status;
char message[100];
MPI_Init(&argc, &argv);MPI_Comm_rank(MPI_COMM_WORLD, &myid);
MPI_Comm_size(MPI_COMM_WORLD,&numprocs); - -numprocs; /*数据分配时除去0号主节点*/
/*0号主节点,主要负责数据分发和结果收集*/
if (myid== 0){
/*数据分发: 0, */
for (int i = 0; i < N; ++i; ) ) {
MPI_Send(data[i], 1, MPI_DOUBLE, i%numprocs+1, 1 ,MPI_COMM_WORLD);
}
/*结果收集*/
for (int source = 1; source <= numprocs; ++source; ) {
MPI_Recv(&d, 1, MPI_DOUBLE, source, 99,MPI_COMM_WORLD, &status);
SqrtSum += d;
}
} else {
/*各子节点接受数据计算开平方,本地累加*/
for ( i = myid-1; i < N; i=i+numprocs ; ){
MPI_Recv(&d, 1, MPI_DOUBLE, 0, 1,MPI_COMM_WORLD, &status); SqrtSum+=sqrt(d);
C++;
}
/*本地累加结果送回主节点*/
MPI_Send(SqrtSum, 1, MPI_DOUBLE, 0, 99,MPI_COMM_WORLD);
printf("I am process %d. I recv total %d from process 0, and SqrtSum=%f.\n", myid, C, SqrtSum);
}
MPI_Finalize();
}
- 利用规约函数计算∫(0~10)x²dx
#define N 100000000
#defined a 0
#defined b 10
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include “mpi.h”
int main(int argc, char** argv){
int myid,numprocs; int i;
double local=0.0, dx=(b-a)/N; /* 小矩形宽度 */
double inte, x;
MPI_Init(&argc, &argv);
MPI_Comm_rank(MPI_COMM_WORLD, &myid);
MPI_Comm_size(MPI_COMM_WORLD,&numprocs);
/* 根据节点数目将N个矩形分为图示的多个颜色组 */
for(i=myid;i<N;i=i+numprocs) {
// 每个节点计算一个颜色组的矩形面积并累加
x = a + i*dx +dx/2; // 以每个矩形的中心点x值计算矩形高度
local +=x*x*dx; // 矩形面积 = 高度x宽度=y*dx
}
/* 规约所有节点上的累加和并送到主节点0 */
MPI_Reduce(&local,&inte,1,MPI_DOUBLE,MPI_SUM, 0, MPI_COMM_WORLD);
/* 主节点打印累加和*/
if(myid==0){
printf("The integal of x*x in region [%d,%d] =%16.15f\n", a, b, inte);
}
MPI_Finalize();
}
MPI优缺点
- 优点
- 灵活性高,适用于各种并行计算任务
- 有完整的规范,可移植性好
- 有多家厂商和机构实现
- 缺点
- 对任务和数据没有良好的划分支持
- 不支持分布式文件系统,进而无法进行分布式数据存储管理
- 通信开销大,当任务复杂、节点数量多时难以处理
- 容灾性差,没有节点恢复机制
- 缺少良好的架构支持,需要程序员考虑很多实现细节
Pthread并发控制
- 自旋锁(忙等待) --能够控制线程进入临界区的顺序
y = Compute ( my_rank );
while ( flag != my_rank ); //通过对flag的等待,保证多个线程对x这个累加值的线性访问
x= x+y ;
flag++;
// flag initialized to 0 by main thread
自旋会大量消耗CPU时间,造成资源浪费。
-
pthread_mutex_t(互斥锁) --无法控制线程进入临界区的顺序
- 创建和初始化
pthread_mutex_t mutex; pthread_mutex_init(&mutex, NULL)
- 加锁
pthread_mutex_lock(&mutex)
- 解锁
pthread_mutex_unlock(&mutex)
- 释放资源
pthread_mutex_destroy(&mutex) //声明线程 pthread_t tid //tfn为线程执行的函数 创建后立刻开启线程 pthread_create(&tid, NULL, tfn, NULL); //等待某一个线程返回 参数为线程标识符、用来接收线程返回值的变量指针 int pthread_join(pthread_t __th, void **__thread_return);
-
Semaphores 信号量
#include <semaphore.h> void main{ sem_t sem; //声明一个信号量 sem_init(&sem,10,10); //初始化信号量 规定最大数值为10 初始大小为10 sem_wait(&sem); //阻塞式地请求一个资源,如果当前sem数值大于0,则会立即返回 sem_post(&sem); //归还一个资源,给当前的sem数值加一 sem_destroy(&sem); //释放资源 } //信号量通常是配合mutex一起使用,作用是控制对mutex进行竞争的线程数量 //例如经典的生产者、消费者问题 /* 在不使用信号量的情况下,对于生产者和消费者线程,都可以竞争mutex,进而可能导致库存为0时请求消费以及库存满时请求加货,这都可以在临界区内通过业务逻辑进行额外判断,但是会造成不必要的开销甚至极端情况下的库存"饥饿"或"溢出"的情况。 在引入信号量后,就可以控制生产者和消费者线程对mutex的竞争,进而保证库存满时不让生产者进来,库存为0时不让消费者进来。 */ sem_t full,empty; pthread_mutex_t mutex; sem_init(&full,10,10); sem_init(&empty,10,0); pthread_mutex_init(&mutex,NULL); int stock=10; //库存变量 //消费者线程 void Consumer(){ while(1){ sem_wait(full); pthread_mutex_lock(&mutex); stock--; pthread_mutex_unlock(&mutex); sem_post(empty); } } //生产者线程 void Producer(){ while(1){ sem_wait(empty); pthread_mutex_lock(&mutex); stock++; pthread_mutex_unlock(&mutex); sem_post(full); } }
-
条件变量
条件变量通常结合互斥锁来使用,作用是让线程在进入临界区且某条件不成立时,进入睡眠状态、释放当前锁,并在其他线程使得该条件成立时被唤醒、重新获得锁。int WaitForPredicate() { // lock mutex (means:lock access to the predicate) pthread_mutex_lock(&mtx); // we can safely check this, since no one else should be // changing it unless they have the mutex, which they don't // because we just locked it. //因为pthread_cond_wait返回0并不一定意味着predicate为true,也有可能是意外的signal、broadcast导致的,所以需要while再次判断。 while (!predicate) { // predicate not met, so begin waiting for notification // it has been changed *and* release access to change it // to anyone wanting to by unlatching the mutex, doing // both (start waiting and unlatching) atomically pthread_cond_wait(&cv,&mtx); } // we own the mutex here. further, we have assessed the // predicate is true (thus how we broke the loop). // You *must* release the mutex before we leave. pthread_mutex_unlock(&mtx); }
-
barrier
- 用忙等待和mutex实现
int counter=0; int tread_count; pthread-mutext_t mutex; void* Tread_Work(){ pthread_mutex_lock(&mutex); count++; pthread_mutex_unlock(&mutex); while(counter<tnread_count); //忙等待 }
- 用信号量实现
int counter=0; int thread_count; sem_t coming_sem; //init to 1 sem_t blocked_sem; //init to 0 void* Thread_Work(){ //当coming_sem为1时,线程能够进入临界区 sem_wait(coming_sem); //是最后一个到达的线程 if(counter==thread_count-1){ //将counter和coming_sem恢复到初始状态 counter=0; sem_post(coming_sem); //循环放行之前阻塞在blocked_sem上的线程 for(int i=0;i<thread_count-1;i++){ sem_post(blocked_sem); } }else{ //不是最后一个线程 counter++; sem_post(coming_sem); //让下一个线程能够进入临界区 sem_wait(blocked_sem); //本线程在这(barrier处)等待 } }
- 用条件变量实现
int counter=0; int thread_count; pthread_mutex_t mutex; pthread_cond_t cond; void* Work_Thread(){ thread_mute_lock(&mutex); counter++; //最后一个线程到达 条件成立 if(counter==thread_count){ counter=0; pthread_cond_broadcast(&cond); }else{ //线程没全到 条件不成立 while(pthread_cond_wait(&cond,&mutex)!=0); } pthread_mutex_unlock(&mutex); }
MapReduce
- 对付大数据处理:分而治之
- 将处理过程抽象为-Map和-Reduce两个部分
- 上升到架构,隐藏并行计算的底层细节
-
什么样的任务适用于并行计算?
- 整个计算任务可以划分成没有依赖关系、可以独立进行的数据块或者子任务。
- 不可分拆的计算任务或相互间有依赖关系的数据无法进行并行计算!
-
MapReduce模型
-
需要实现的接口
map(String input_key, String input_value): // input_key: document name // input_value: document contents for each word w in input_value: EmitIntermediate(w, "1"); reduce(String output_key, Iterator intermediate_values): // output_key: a word // output_values: a list of counts int result = 0; for each v in intermediate_values: result += ParseInt(v); Emit(output_key, result);
Hadoop
Hadoop是一个项目的总称,其中包含HDFS、MapReduce、HBase。
其中HDFS是Google File System (GFS)的开源实现。
MapReduce是Google MapReduce的开源实现。
Hbase是Google BigTable的开源实现。
-
Hadoop的特点
- 扩容能力 Scalable :能科考地存储和处理PB级别的数据
- 成本低 Economical :可以用普通机器组成的服务器群来分发和处理数据,节点数可达数千个。
- 高效率 Efficient :通过分发数据,hadoop可以在所有节点上并行地处理,速度非常快。
- 可靠性 Reliable :hadoop能自动地维护数据的多份复制,在失败后能自动重新部署计算任务。
-
Hadoop的工作流程
- Hadoop把client节点接收到的计算任务成为Job
- JobTracker节点把client节点处的Job取过来,将其继续划分为两类task,分别为MapTask和ReduceTask,并且交由TaskTracker节点进行处理。
- TaskTracker会执行一个具体的task,并且向JobTracker汇报进度信息。
- JobTracker作为整个系统中的任务调度中心,会按照以下顺序进行Job的分配:还未分配过的任务,执行失败的任务,需要进行推测执行的任务。
-
HDFS
HDFS(Hadoop Distributed File System
)是 Hadoop应用中主要有用到的分布式文件系统,它为了可靠性,将数据块进行多块的复制,并且分布到整个服务器集群中,这样MapReduce就可以在其所在的服务器节点上直接拿到数据块并且进行处理。- 特性
- 大容量
- 高容错性
- 高吞吐量
- 数据模型
- 包
- 块
- 文件
- 特性
-
HBase
HBase是一个分布式的、面向列的开源数据库,适合于海量非结构化数据存储。-
Hbase表的特点
- 大:一个表可以有上亿行、上百万列。
- 面向列:面向列(族)进行存储。
- 稀疏:对于为空的列,并不占用存储空间,因此表可以设计地非常稀疏。
-
Hbase的特点
- 基于列式的高效存储
- 强一致的数据访问
- 高可靠
- 高性能
- 可伸缩,自动切分、迁移
- Schema free
-
为什么传统关系型数据库在大数据场景下不适用
- 传统关系型数据库在高并发场景下,一般采用读写分离、分库分表的策略来降低单表的访问压力,但是会带来数据一致性难以保证的问题
- 当面对海量数据时,同样只能采用分库分表的方式来拆分大表,这样才能保证局部数据查询时的效率,但是实现起来复杂、难以维护和迁移。不仅如此,当分表后规模依然很大时,按行存储的关系型数据库进行关联查询会产生非常大的笛卡尔积,时间和空间开销都非常高。
- 为了高可用和可靠性,通常采用主备、主从、多主的方式进行数据备份存储,拓展性差,增加节点和节点宕机需要进行数据迁移。
-
Hbase的存储结构
- HTable
- 对于一张表,需要预先定义所有列族,而某一条具体的记录由
<rowKey,column family,[column,]timestamp >:<value>
的形式存储,其中的timestamp是为了区分一条记录的多个历史版本(即Hbase会存储一个记录的多个历史值)。 - 一张表中的所有记录,会根据rowKey进行字典序排列存储。
- 对于一张表,需要预先定义所有列族,而某一条具体的记录由
- HRegion
- HTable在行方向上会被划分为多个HRegion,这是HBase中表的分布式存储的最小单位。当一个HRegion越来越大并达到阈值之后,就会被RegionServer给划分成两个更小的HRegion。
- Store
- HRegion是分布式存储的最小单位,但不是存储的最小单位。一个HRegion中有多个Store结构,这才是数据在DHFS上的最小存储单元。
- 一个Store中包含一个memStore块和多个storeFile,storeFile以Hfile的格式存储在DHFS上。
- HTable
-
HBase的逻辑结构
- client:
- 访问Hbase的入口,维护着Region位置的缓存信息。
- zookeeper:
- 维护集群中master的唯一存在。
- 维护所有Region的寻址入口。
- 实时监控regionServer的状态,向master通知节点上下线信息。
- 存储Hbase的schema,包括有哪些table、table中有哪些列族。
- master
- 负责给regionServer分配Region。
- 为regionServer集群做负载均衡。
- 发现失效的regionServer时重新将其上的region分配到其他地方。
- 负责DHFS上的垃圾回收。
- 处理schema的更新请求
- regionServer
- 维护master分配的region。
- 处理client对region的IO请求,这个过程不需要master参与。
- 负责切分过大的region。
- client:
-
Spark
Spark基于map reduce 算法模式实现的分布式计算,拥有Hadoop MapReduce所具有的优点;但不同于Hadoop MapReduce的是Job中间输出和结果可以保存在内存中,从而不再需要读写HDFS。
Spark立足于内存计算,相比Hadoop MapReduce,Spark在性能上要高100X倍,而且Spark提供了比Hadoop更上层的API,同样的算法在Spark中实现往往只有Hadoop的1/10或者1/100的长度。
-
几个关键的API
//创建spark环境 JavaSparkContext ctx = new JavaSparkContext("yarn-standalone", "JavaWordCount",System.getenv("SPARK_HOME"), JavaSparkContext.jarOfClass(mysparktest.class)); //读入数据 JavaRDD<String> lines = ctx.textFile(args[1], 1); lines.cache(); //cache,暂时放在缓存中,一般用于哪些可能需要多次使用的RDD,据说这样会减少运行时间 //collect方法,用于将RDD类型转化为java基本类型,如下 //一条数据转换成一个字符串 List<String> line = lines.collect(); for(String val:line) System.out.println(val); //一条数据转换成一个键值对 List<Tuple2<String, Integer>> output = sort.collect(); for (Tuple2<?,?> tuple : output) { System.out.println(tuple._1 + ": " + tuple._2()); } //创建filter 将RDD中的每一条数据进行过滤 将只会返回那些过滤结果为true的记录 JavaRDD<String> contaninsE = lines.filter(new Function<String, Boolean>() { @Override public Boolean call(String s) throws Exception { return (s.contains("they")); } }); //根据自定义的方法将一条RDD数据转换为多条RDD数据 如将一个句子拆分成若干个单词 //FlatMapFunction的两个泛型 分别指的是用来拆分的数据类型和拆分之后的数据集合元素的类型 JavaRDD<String> words = lines.flatMap(new FlatMapFunction<String, String>() { @Override public Iterable<String> call(String s) { String[] words=s.split(" "); return Arrays.asList(words); } }); //map方法 将一条rdd数据映射为一个<key,value>键值对 JavaPairRDD<String, Integer> ones = words.map(new PairFunction<String, String, Integer>() { @Override public Tuple2<String, Integer> call(String s) { return new Tuple2<String, Integer>(s, 1); } }); //reduceByKey方法 将map产生的JavaParRDD进行处理,相同key的数据分为一组,按照reduce的模式进行规约 JavaPairRDD<String, Integer> counts = ones.reduceByKey(new Function2<Integer, Integer, Integer>() { @Override public Integer call(Integer i1, Integer i2) { //reduce阶段,key相同的value怎么处理的问题 return i1 + i2; } }); //reduce方法,所有RDD的数据,第一个元素和第二个元素传入call方法,输出的结果与第三个元素构成参数再次传入call 直到最后两个元素传入call JavaPairRDD<String, Integer> counts = ones.reduce(new Function2<Integer, Integer, Integer>() { @Override public Integer call(Integer i1, Integer i2) { //reduce阶段 return i1 + i2; } }); //对RDD中的数据进行排序 JavaPairRDD<String,Integer> sort = counts.sortByKey();