当前位置: 首页 > 工具软件 > SALVIA > 使用案例 >

SALVIA Shading Language中的表达式值的表示与Graphics Pipeline语义的表达

法弘亮
2023-12-01

表达式值的存储

LLVM中基本数据类型及存储类型

值是编译器所需要处理的基本数据。它出现在各个角落,条件分支、表达式、返回语句。甚至是函数地址也可以被视作是值类型。

对 于编译器而言,最基本的值是整数和浮点。其他的值都可以用这两者来表达,例如布尔和指针。如果你是从一个最基本的指令集开始写起,那么整数和浮点的数值运 算、转换、基于整数寄存器的跳转和地址取值是一个寄存器机的最基本操作。所有更加高级的操作,例如数组、结构体、指针、函数、对象等,都可以建立在这一基 础上。

如果一个指令系统在整数和浮点数之外,额外提供了布尔、分支、函数调用和结构体的支持,那么它与高级语言将会贴近更多,生成代码的方式也更加简单。

在高级语义的数据结构上,LLVM提供了相当良好的支持。它支持的原生类型(First class)包括: 各种精度的整型和浮点数,指针、向量,结构体和数组。这些类型的数据存取和运算都是有指令直接支撑,而不需要自行计算并生成更加原始的指令。

在 存储类型上,LLVM提供了Value, Argument, Alloca, GlobalVariable, Pointer五种存储类型。Value是右值,它不可取引用,不可更改。Argument表示了函数实参,它是Value的一个派生类。所以对参数的任 何更改行为实际上都是不被允许的。Alloca保存了栈地址,GlobalVariable保存了全局变量的地址,Pointer则是一般意义上的指针。

除了存储指令,LLVM所有的指令都是针对Value的操作,并返回一个Value。所以

Code:
  1. Var a = Alloca int  
  2. Var b = Alloca int  
  3. Var c = Alloca int  
  4. c = ADD a, b  

这样的操作,在LLVM中实际上是将a和b的地址相加,并把C从变量替换成一个左值(注意,是替换,变量的值没有任何变化)。

在LLVM中,正确的做法应当类似于下面这样:

Code:
  1. a = Alloca int  
  2. b = Alloca int  
  3. c = Alloca int  
  4. a_v = load a  
  5. b_v = load b  
  6. c_v = ADD a, b  
  7. store c, c_v  

要先将值从变量中读出,进行操作,再保存到另外一个变量中。

表达式值的数据结构

一个的表达式参数或结果可能是左值或右值。例如++x输入一个左值返回一个左值,而x++就返回一个右值。A+B则是需要两个右值并返回一个右值。

一个左值可以很方便的转化为右值,但是右值转化成左值通常是很困难的。地址信息被丢弃了,或者它根本就是一个字面常量,都会导致一个右值将 永远是右值。将右值构造成左值的唯一办法,就是构造临时对象并将右值赋予左值。当这个左值被读取时,如果临时对象除了初始化之外从未被写过,并且它关联的 右值依然有效,那么这个操作会被优化成直接返回那个原始的右值,从而避免临时左值的读写操作。

在Clang(一个C++编译器的前端)中对左值和右值进行了严格的区分。这是由于C++需要额外的处理临时对象。临时对象意味着尽管它有右值的语义,但是实际上是左值的存储。这是需要将真正的左值和临时的左值区分开,并提供特定语境下的转化。

SASL没有处理复杂的临时对象问题,因此它使用了一个相对简单的办法来解决左右值的判定和存储。

我们设计了一个数据结构,用于保存任何可能的值。

Code:
  1. struct Data{  
  2.     bool isRef;  
  3.     Value* rval;  
  4.     Alloca* local;  
  5.     GlobalVariable* global;  
  6.     struct Aggregated{  
  7.         Data* parent;  
  8.         int index;  
  9.     } agg;  
  10. };  

 

rval用于处理Argument和右值时的情况。Local意味着它是一个局部变量,global说明它是一个全局变量,agg则用于处理structure member。Parent指向包含当前变量的聚合变量,index则指明了当前变量在聚合变量中的位次。

SASL提供了load, load_ptr 和 store 来数据的存取,而不要关心它的具体存储类型。

左值/右值语义

在Data这个结构中,rval, local, global和agg四个值是互斥的。当然这里的我们也可以选择union+enum的方式来表达。

首先来看,这个结构如何表达左值/右值语义。

来看isRef,这是一个标记位。它表示了data存储的值究竟是值本身还是地址。如果是isref为真,那么data便可以被认为是一个 左值。Isref为假,那么当它是rval的时候,它就是一个真正的右值了。如果是Alloca或者GlobalVariable,因为它们本身就代表了 地址,那么它仍然是一个右值。如果是agg,那么要取决于它的聚合量是左值还是右值。

如果参数需要左值,那么可以直接从data拷贝,或者使用load_ptr + isRef创建一个新的右值Data。如果参数需要右值,那么可以通过load的方式获取一个右值。

数据存取的实现

Code:
  1. llvm::Value* load( cgllvm_sctxt* data ){  
  2.   assert(data);  
  3.   Value* val = data->val;  
  4.   do{  
  5.     if( val ){ break; }  
  6.     if( data->local ){ val = builder()->CreateLoad( data->local );  
  7.       break;  
  8.     }  
  9.     if( data->global ){  
  10.       val = builder()->CreateLoad( data->global );  
  11.       break;  
  12.     }  
  13.     if( data.agg.parent ){  
  14.       val = load( data->agg.parent );  
  15.       val = builder()->CreateExtractValue( val, data->agg.index );  
  16.       break;  
  17.     }  
  18.   } while(0);  
  19.   
  20.   if( data->is_ref ){val = builder()->CreateLoad( val );}  
  21.   return val;  
  22. }  
  23.   
  24.   
  25. llvm::Value* load_ptr( cgllvm_sctxt* data ){  
  26.   
  27.   Value* addr = NULL;  
  28.   if( data->val ){ addr = NULL; }  
  29.   if( data->local ){  
  30.     addr = data->local;  
  31.   }  
  32.   if( data->global ){  
  33.     addr = data->global;  
  34.   }  
  35.   if( data->agg.parent ){  
  36.     addr = builder()->CreateGEP( load_ptr(data->agg.parent), 0, data->arg.index );  
  37.   }  
  38.   
  39.   if( data->is_ref ){  
  40.     if( !addr ){  
  41.       addr = data->val;  
  42.     } else {  
  43.       addr = builder()->CreateLoad( addr );  
  44.     }  
  45.   }  
  46.   
  47.   return addr;  
  48. }  
  49.   
  50. void store( llvm::Value* v, cgllvm_sctxt* data ){  
  51.   Value* addr = load_ptr( data );  
  52.   builder()->CreateStore( v, addr );  
  53. }  

图形管线与Shader的交互

入口函数与非入口函数

入口函数是Shader的主函数。来看这样一段程序

Code:
  1. float4x4 wvpMat;  
  2.   
  3. struct VS_INPUT{  
  4.     float4 pos: SV_Position;  
  5.     float4 tex: SV_Texcoord0;  
  6. };  
  7.   
  8. struct VS_OUTPUT{  
  9.     float4 pos: SV_Position;  
  10.     float4 tex: SV_Texcoord0;  
  11. };  
  12.   
  13. float4 world_pos( float4 p ){  
  14.     return mul(p, wvpMat);  
  15. }  
  16.   
  17. VS_OUTPUT vs_main(VS_INPUT in){  
  18.     VS_OUTPUT o;  
  19.     o.pos = world_pos(in.pos);  
  20.     o.tex = in.tex;  
  21.     return o;  
  22. }  

 

很显然,vs_main是一个合法的VS程序的主函数,那么我们称vs_main为入口函数,称world_pos为非入口函数。Shading language的入口函数,其实和C语言的主在概念上没有什么区别。但是在SASL中,我们要求一个入口函数它所有的输入和输出都要正确的关联到语义 上。SM4中这一条件被放宽了,入口函数也可以提供无语义的uniform参数。

语义分类

对于Shading Language而言,最重要的两个操作是从图形管线中获取数据并将数据写回到管线中。流水线中的数据是附带了语义信息的,用于表达这个数据的用途。例如 SV_Position就指明了这样一个数据是表示位置的。用户输入的数据、SL输出的数据,都是依靠语义信息来确保读取和写入的正确性。例如 SV_Position只能从某个顶点流的特定偏移量获取,SV_Color的数据才能被写到color buffer中。

SASL支持的语义集合是HLSL Shader Model 4.0的子集。目前参考的HLSL版本为4.0。

在Shader Model 4.0的所有输入语义中,一些语义的值直接来自于外部存储,例如SV_Position的数据来自顶点流,一些语义的值则是来自于管线执行中间计算的结果。输出语义也是如此。

Shader从设计之初便需要应对每秒百万到数亿的调用,因此一些平常不可见的开销问题在这里也变得尤为显著,例如函数参数压栈的开销。所 以将所有输入数据均按值或者按地址传递到入口函数中是不妥的。为了尽可能的减少内存读写的次数,从外部存储读入(例如Vertex Buffer)或者写入的外部存储(例如Stream Output或者Frame Buffer)的数据,我们一律以指针+偏移的形式将数据传递到Shader中,称之为Stream类型,而临时的语义变量,如 SV_IsFrontFace,我们则暂存到一个临时的buffer中,称之为buffer类型。

在SASL中我们将shader的全部语义分为四类,Stream_in,stream_out,buffer_in,buffer_out。

Shader还有一种特有的存储类型,uniform。这一类型在编译期的时候是一个变量,在代码生成期/优化期是一个常量。如果将这一类 型的量按照编译期常量来处理,那么便能获得更高的运行时性能,比方说一些条件展开可以通过优化而被消除。但是,这也意味着一旦uniform量发生变化 后,shader便最少需要重新执行代码生成乃至于重新编译。这将会带来巨大的性能开销。由于SASL主要执行在CPU上,CPU对于动态代码的执行优化 要远远优于GPU,例如间接地址读取指令和分支预测。因此我们将uniform作为一个普通的变量经由buffer_in来执行输入,以平衡代码调用和编 译之间的开销。

数据结构与入口签名

SASL最终将生成如下的签名:

Code:
  1. struct stream_in{  
  2.     float4* pos;  
  3.     float4* tex;  
  4. };  
  5.   
  6. struct buffer_in{ float4x4 wvpMat; };  
  7. struct stream_out{}; // empty.  
  8. struct buffer_out{  
  9.     float4 pos;  
  10.     float4 tex;  
  11. };  
  12.   
  13. float4 world_pos( float4 pos, buffer_in* bi );  
  14. void vs_main( stream_in* si, buffer_in* bi, stream_out* so, buffer_out* bo );  

 

通过对语义和常量进行重整,SASL减少了不必要的拷贝开销。

结构体的语义布局与常规布局

我们注意到,VS_OUTPUT对于返回值和堆栈变量的类型时的意义是不同的。在返回值时,它匹配了语义输出,而在堆栈变量时,它只是一个普通结构体的内存布局。这就要求,VS_OUTPUT在分析时必须同时产生并保存两套内存布局信息。

但是实际上由于布局差异仅仅在入口函数才存在,并且只有当结构体作为入口函数参数或返回值的时候才会使用语义布局,其他函数内无论是参数还 是变量都是使用普通布局,因此我们运用一个临时对象,将语义布局的值拷贝成一个普通布局的对象。也就是说,入口函数内的代码中所有对这个参数值的读取实际 上都是对临时对象的读取。其代码类似于下段:

Code:
  1. void vs_main( stream_in* si, buffer_in* bi, stream_out* so, buffer_out* bo ){  
  2.     // initialization  
  3.     VS_INPUT __tmp_in = {*si->pos, *si->tex};  
  4.     VS_OUTPUT __tmp_out;  
  5.     // end initialization  
  6.   
  7.     VS_OUTPUT o;  
  8.     o.pos = world_pos( __tmp_in.pos, bi );  
  9.     o.tex = __tmp_in.tex;  
  10.   
  11.     __tmp_out = o;  
  12.   
  13.     // return  
  14.     bo->pos = __tmp_out.pos;  
  15.     bo->tex = __tmp_out.tex;  
  16.     return;  
  17.     // end return  
  18. }  

 

那么通过临时对象的构造,便可以将其余部分的代码通过常规布局生成,避免了在普通布局和语义布局之间复杂的判断和逻辑。尽管临时变量的使用导致了代码在外观上看起来很低效,但是实际上这种极为简单的冗余代码,是非常适合LLVM这种基于SSA的优化方案的。

 

 类似资料: