OpenMP是基于编译制导的共享内存编程模型,是对C语言的扩展,通过编译制导指令和API接口实现程序并行运行,包括编译制导指令、运行库和环境变量。在编译开始阶段就需要对OpenMP制导指令进行编译,生成应用程序时也需要OpenMP库的多线程或进程的支持。
从OpenMP/C代码到应用程序可以直接编译,也可以先将编译制导部分编译为标准C代码再使用C标准编译器编译。前者可以降低代码优化的难度提高优化的效果,后者则可以简化编译器的实现也便于理解OpenMP。
本文主要讨论OpenMP的编译,采用后者重点考虑OpenMP/C代码到C代码的转换。
OpenMP编译器实现OpenMP编译制导部分C代码到标准C代码的编译,其编译过程和其他编译器相似,有典型的八个部分组成:词法分析、语法分析、语义分析、中间代码生成、代码优化、目标代码生成、符号表管理、错误处理。
词法分析使用Lex实现,Lex通过词法规则描述文件生成词法分析器进行词法分析,OpenMP所涉及的关键字不多,只是需要与标准C代码区分,需要通过#pragma omp来识别OpenMP关键字。
语法分析使用语法分析工具Yacc实现,Yacc将语义动作结合进了语法分析过程,语法分析最终将生成语法树。
Yacc使用语法规则描述文件生成语法解析器进行语法分析,部分语法规则如下:
statement:
labeled_statement
{ $$ = $1; }
| expression_statement
{ $$ = $1; }
|openmp_construct
{ $$ = OmpStmt($1);
$$->1 = $1->1;
}
...
;
statement可以由多种非终结符推导出,openmp_construct即为OpenMP构造。
openmp_construct:
parallel_construct
{ $$ = $1; }
| for_construct
{ ... }
...
OpenMP构造包括多种语法结构,如parallel、for、sections等
parallel_construct:
;parallel_directive structured_block
{
$$ = OmpConstruct(DCPARALLEL, $1, $2);
$$->1 = $1->1;
}
parallel构造由parallel制导指令后跟C语言结构块构成。
parallel_directive:
PRAGMA_OMP OMP_PARALLEL parallel_clause_optseq '\n'
{ $$ = OmpDirective(DCPARALLEL, $3); }
;
parallel制导指令由PRAGMA_OMP后跟OMP_PARALLEL再后跟parallel制导指令子句序列构成,PRAGMA_OMP和OMP_PARALLEL是词法分析返回的终结符,分别对应#pragma omp和parallel,parallel_clause_optseq为非终结符还需要进一步的语法解析。
通常的编译器中间代码表示是“三地址码”,三地址码的表示方式便于中间代码的优化和汇编/机器码的生产。此处的讨论使用语法树作为中间代码表示,因为语法树更容易生成C代码,而且OpenMP制导指令到标准C的转换涉及的是线程的并发执行问题,并不需要三地址码便于优化的特点。
AST节点有五大类:语句、表达式、类型说明、声明、OpenMP制导
此处仅说明OpenMP制导节点,其最上层为ompcon_节点(对应指针ompcon),一个OpenMP构造对应一个ompcon节点,包含一个ompdir节点和零个或多个ompclause节点
ompcon_结构体
struct ompcon_
{
enum dircontype type; /*制导指令类型14种*/
ompdir directive;/*制导指令*/
aststmt body;/*代码块*/
aststmt parent;
int l,c;
symbol file;
};
typedef struct ompcon_ * ompcon;
制导指令类型对应parallel、for、sections、section、single、parallel for、parallel sections、master、critical、atomic、ordered、barrier、flush、threadprivate共14种,代码块为aststmt类型,对应语句节点类型。
ompdir_结构体
struct ompdir_
{
enum dircontype type;
ompclause clauses;/*子句列表*/
ompcon parent;/*上层的ompcon结构体指针*/
union
{
symbol region;/*用于critical区域名称*/
astdecl varlist; /*theadprivate、flush指令的变量声明*/
}u;
int l,c;
symbol file;
};
typedef struct ompdir_ * ompdir;
ompclause_结构体
struct ompclause_
{
enum clausetype type;/*15种子句类型之一*/
int subtype;/*13种子类型之一*/
ompdir parent;
union
{
astexpr expr;/*条件表达式用于if子句*/
astdecl varlist; /*变量列表用于private等子句*/
struct
{
ompclause elem;
ompclause next;
}list; /*多个子句列表*/
}u;
int l,c;
symbol file;
};
typedef struct ompclause_ * ompclause;
节点创建函数:分配结构体内存、元素赋值、返回节点指针
ompcon OmpConstruct(enum dircontype type, ompdir dir, aststmt body);
ompdir OmpDirective(enum dircontype type, ompclause cla);
ompclause OmpClause(enum clausetype type, int subtype, astexpr expr, astdecl varlist);
此外还有一些其它函数完成节点删除、子树拼接及其它辅助功能。
AST中间表示通过语法制导翻译过程完成,语法制导翻译是通过向文法产生式附加一些规则得到中间语法树。
以下通过OpenMP的for节点生成AST,做简单说明。
for对应语法规则和语义动作(.y文件)
omp_for_specific_iteration_statement:
FOR '(' init_stmt ';' cond_expr ';' incr_expr ')' statement
{
$$ = For($3, $5, $7, $9);
}
;
For是一个宏定义,实际调用Iterationstatement().
#define For(init, cond, incr, body) Iterationstatement(SFOR, init, cond, incr, body)
Iterationstatement定义如下:
aststmt Iterationstatement(int subtype, aststmt init, astexpr cond, astexpr incr, aststmt body)
{
aststmt n = Statement(ITERATION, subtype, body);
n->u.iteration.init = init;
n->u.iteration.cond = cond;
n->u.iteration.incr = incr;
return (n);
}
OMPi的符号处理和一般的编译器一样,负责处理编译过程遇到的所有语法符号及相应属性,涉及大量字符串的处理,由于字符串处理开销比较大,而且符号表需要频繁查找读取,故符号表通常以哈希表的方式实现。
符号表管理包括底层的字符串表的管理和高层的符号表的管理。字符串表记录所有符号的字符串信息;符号表记录符号的各种属性和字符串指针,其中一个比较重要的属性就是作用域。
符号表的操作也对应包括字符串表的操作和符号表的操作。字符串表主要是插入和查找操作,对新扫描到的字符串计算哈希值,然后在字符串表中查找,找到则返回字符串位置,没找到则插入该字符串返回插入位置。符号表的操作类似,主要不同是符号作用域的处理,不同作用域上的同名变量是互不相关的,内层作用域定义的变量在外层是不可见的,退出作用域时需要删除该作用域内的符号。
该步骤以AST中间表示作为输入,并产生标准C语言代码。
AST中间表示首先需要进行一些简单的裁剪、拼接等整形操作,然后对OpenMP的构造节点进行变换,可能涉及子树或节点的删除、插入、移动、修改等操作,部分节点可能还需要分离并分别插入到AST的不同地方,变换后的AST所有的OpenMP构造节点转换成了类似标准C的函数调用、表达式等类型的C语言节点,从而可以方便的遍历AST输出标准C代码字符串。
AST变换过程涉及很多的复杂操作,其中主要的任务在ast_stmt_xform()函数中完成。该函数根据遍历(对AST的递归过程)到的节点类型,调用相应的变换函数,对应OpenMP节点,调用节点对应的ast_omp_xform(t)函数。ast_omp_xform函数内部再根据OpenMP节点类型进一步调用对应的变换函数如xform_for(t), xform_atomic(t)等。
以最简单的atomic的变换函数为例说明
void xform atomic(aststmt * t)
{
aststmt s = (*t)->u.omp->body, parent = (*t)->parent, v;
int stlist;
v = ompdir_commented((*t)->u.omp->directive);
stlist = ((*t)->parent->type == STATEMENTLIST ||
(*t)->parent->type == COMPOUND);
(*t)->u.omp->body = NULL;
ast_free(*t);
*t = BlockList(
BlockList(
BlockList(
BlockList( v, Call0_stmt("ort_atomic_begin") ),
s),
Call0_stmt("ort_atomic_end") ),
linepragma(s->l + 1 -(!stlist), s->file));
if (!stlist)
*t = Compound(*t);
}ast_stmt_parent(parent, *t);
从函数执行可以看出,变换过程删除了OpenMP其它节点部分,仅保留了代码块语法树部分,然后重新生成了包含ort_atomic_begin等openmp库函数调用对应的语法树结构并和保留的代码块语法树结构进行重新组合生成了新的语法树节点替换原来的OpenMP节点。
对于OpenMP代码
#pragma omp atomic
XXXXX;
变换后的AST实际输出的代码如下:
/* #pragma omp atomic */
ort_atomic_begin();
XXXXX;
ort_atomic_end();
atomic的AST变换过程十分简单,其对应的输出代码也很简单,而对于parallel、for等指令变换过程就比较复杂了,需要处理并行域内代码的函数封装、任务函数调用、变量数据环境的处理等问题。此处仅列出带schedule子句的for指令的代码变换:
源代码
#pragma omp for schedule(static, 5)
for (i = 0; i <= 100; i++)
{
XXXXXX;
}
变换后的代码
{
int i;
int from_ = 0, to_ = 0, step_;
struct _ort_gdopt_ gdopt_;
int nchunks_, chid_, TN_, StCtN_;
step_ = 1;
ort_entering_for(1, 0, 0, step_, &gdopt_);
TN_ = omp_get_num_threads();
StCtN_ = step_ * 5 * TN_;
ort_init_static_chunksize(0, (100)+1, step_, 5, &nchunks_, &from_);
from_ -= StCtN_;
to_ = from_ + step_ * (5);
for (chid = omp_get_thread_num(); chid_ < nchunks_; chid_ += TN_)
{
from_ += StCtN_;
to_ += StCtN;
if ((100) + 1 < to_)
to_ = (100) + 1;
for (i = from_; i < to_; i++)
{
XXXXXX;
}
}
ort_leaving_for();
}
AST代码的输出过程比较简单,根据语法树节点的类型,以特定的格式通过printf输出一定格式的字符串即可。
代码优化在变换后的AST上进行,由于OpenMP程序运行时最大的开销是并行域上线程启动、停止的一些开销,线程和任务是动态的关系,并行域的合并可以在一定程度上提高程序的性能,故优化步骤常从此点出发:通过合并相邻的并行域,减少线程组的产生和撤销。并行域合并优化需要考虑串行代码的保护和同步行为的正确等问题。
通常这种将OpenMP编译器和后端编译器分割开的方案,限制了线程级并行和指令级并行的交互,可优化的地方会受到很大限制。
以上只是对OpenMp代码转换过程做了简单的描述,完整的OpenMP环境还有很多的处理过程和细节,诸如for任务的划分、多线程的调度、并行域的管理、线程变量作用域处理、多线程环境的初始化、线程支撑函数、环境变量的处理等等,而且其中大部分是平台相关的,和编译器的具体实现有关。