HLSL 函数调用

简介

本文档描述了 Clang 中 HLSL 函数调用语义的设计和实现。这包括与参数转换和参数生命周期相关的详细信息。

本文档并非旨在作为 HLSL 调用语义的官方文档,但提供了一个概述以帮助读者。HLSL 语言语义的权威文档是 语言规范草案

参数语义

在 HLSL 中,所有函数参数都按值传递进出函数。HLSL 有 3 个关键字来表示参数语义(inoutinout)。在函数声明中,参数可以使用以下任何方式进行标注

  1. <无参数标注> - 表示输入

  2. in - 表示输入

  3. out - 表示输出

  4. in out - 表示输入和输出

  5. out in - 表示输入和输出

  6. inout - 表示输入和输出

仅作为输入的参数的行为类似于按值传递的 C/C++ 参数。

对于作为输出(或输入和输出)的参数,将在调用者中创建一个临时值。然后按地址传递临时值。对于仅作为输出的参数,在传递时临时值未初始化(如果参数在函数内部未明确初始化,则会将未定义的值存储回参数表达式)。对于既作为输入又作为输出的参数,临时值将通过从左值参数类型到参数类型的隐式或显式转换从左值参数表达式初始化。

函数返回时,任何参数临时值的值将通过反向转换序列写入回参数表达式(如果一个 out 参数在函数中未初始化,则可能将未初始化的值写回)。

具有常量大小数组类型参数的传递也使用值语义。这需要输入参数的数组来构造临时值,并且临时值在初始化参数时会进行数组到指针的衰减。

允许实现避免不必要的临时变量,并且 HLSL 的严格无别名规则可以实现一些简单的优化。

数组临时变量

给出以下示例

void fn(float a[4]) {
  a[0] = a[1] + a[2] + a[3];
}

float4 main() : SV_Target {
  float arr[4] = {1, 1, 1, 1};
  fn(arr);
  return float4(arr[0], arr[1], arr[2], arr[3]);
}

在 C 或 C++ 中,数组参数衰减为指针,因此在调用 fn 之后,arr[0] 的值为 3。在 HLSL 中,数组按值传递,因此 fn 内部进行的修改不会传播出去。

注意

DXC 可能会直接将无大小的数组作为衰减后的指针传递,这是一种不幸的行为差异。

输出参数临时变量

void Init(inout int X, inout int Y) {
  Y = 2;
  X = 1;
}

void main() {
  int V;
  Init(V, V); // MSVC (or clang-cl) V == 2, Clang V == 1
}

在上面的示例中,Init 函数的行为取决于 C++ 的实现。C++ 没有定义参数初始化或销毁的顺序。在 MSVC 和 Clang 的 MSVC 兼容模式中,参数按从右到左发出,并按从左到右销毁。这意味着参数初始化和销毁的顺序为:{Y, X, ~X, ~Y}。这会导致 Y 值的写回最后发生,因此 V 的最终值为 2。在 Itanium C++ ABI 中,参数顺序相反,因此初始化和销毁的顺序为:{X, Y, ~Y, X}。这会导致 X 的值写回最后发生,从而导致 V 的值设置为 1

void Trunc(inout int3 V) { }


void main() {
  float3 F = {1.5, 2.6, 3.3};
  Trunc(F); // F == {1.0, 2.0, 3.0}
}

在上面的示例中,参数表达式 F 经过从浮点数向量到整数向量的逐元素转换,以创建一个临时 int3。在过期时,临时值经过逐元素转换恢复为浮点数向量类型 float3。这导致了向量的隐式逐元素转换,即使该值在函数中未使用(实际上截断了浮点数)。

void UB(out int X) {}

void main() {
  int X = 7;
  UB(X); // X is undefined!
}

在这个示例中,将一个已初始化的值传递给了一个 out 参数。标记为 out 的参数不会由参数表达式或函数隐式初始化。它们必须显式初始化。在本例中,参数在函数中未初始化,因此在复制回参数表达式时临时值仍然未初始化。这在 HLSL 中是未定义的行为,并且在调用之后对参数的任何使用都是对未定义值的用法,这在目标中可能是违法的(具有使用或可能使用 undefpoison 值的 DXIL 程序将无法通过验证)。

Clang 实现

注意

这里描述的实现是一个建议。它尚未完全实现,因此 Clang 源代码的当前状态可能不反映此设计。一个原型实现是在基于 Clang-3.7 的 DXC 上构建的。可以在这里找到原型 here。原型实现中的许多更改都是将以前修改过的 Clang-3.7 代码恢复到其原始状态。

Clang 中的实现添加了一个新的非衰减数组类型,一个新的 AST 节点来表示输出参数,以及对 Clang 现有 Objective-C 写回参数支持的少量扩展。此设计的目标是在 AST 中捕获 HLSL 函数调用的语义细节,并将 IR 生成过程中需要执行的魔法数量降到最低。

数组临时变量

新的 ArrayParameterTypeConstantArrayType 的子类,它继承了父类的所有行为和方法,只是它在重载解析或模板类型推断期间不会衰减为指针。

如果底层规范 ConstantArrayType 相同,则 ConstantArrayType 的参数可以隐式转换为等效的非衰减 ArrayParameterType。这发生在重载解析期间,而不是数组到指针的衰减。

void SizedArray(float a[4]);
void UnsizedArray(float a[]);

void main() {
  float arr[4] = {1, 1, 1, 1};
  SizedArray(arr);
  UnsizedArray(arr);
}

在上面的示例中,为 SizedArray 的调用生成的 AST 如下所示

CallExpr 'void'
|-ImplicitCastExpr 'void (*)(float [4])' <FunctionToPointerDecay>
| `-DeclRefExpr 'void (float [4])' lvalue Function 'SizedArray' 'void (float [4])'
`-ImplicitCastExpr 'float [4]' <HLSLArrayRValue>
  `-DeclRefExpr 'float [4]' lvalue Var 'arr' 'float [4]'

在上面的示例中,为 UnsizedArray 的调用生成的 AST 如下所示

CallExpr 'void'
|-ImplicitCastExpr 'void (*)(float [])' <FunctionToPointerDecay>
| `-DeclRefExpr 'void (float [])' lvalue Function 'UnsizedArray' 'void (float [])'
`-ImplicitCastExpr 'float [4]' <HLSLArrayRValue>
  `-DeclRefExpr 'float [4]' lvalue Var 'arr' 'float [4]'

在这两种情况下,参数表达式的数组大小都是已知的,因此我们可以初始化一个大小适当的临时变量。

在 HLSL 中,将无大小的数组转换为大小的数组是非法的

void SizedArray(float a[4]);
void UnsizedArray(float a[]) {
  SizedArray(a); // Cannot convert float[] to float[4]
}

当将大小的数组转换为无大小的数组时,也可以插入一个数组临时变量。给出以下代码

void UnsizedArray(float a[]);
void SizedArray(float a[4]) {
  UnsizedArray(a);
}

预期的 AST 应该类似于

CallExpr 'void'
|-ImplicitCastExpr 'void (*)(float [])' <FunctionToPointerDecay>
| `-DeclRefExpr 'void (float [])' lvalue Function 'UnsizedArray' 'void (float [])'
`-ImplicitCastExpr 'float [4]' <HLSLArrayRValue>
  `-DeclRefExpr 'float [4]' lvalue Var 'arr' 'float [4]'

输出参数临时变量

输出参数在 HLSL 中定义为强制转换过期值(cx-值),这是一个为 HLSL 虚构的术语。cx-值是一个临时值,它可能是强制转换的结果,并在值过期时将其值存储回左值。

为了在 Clang 中表示这个概念,我们引入了一个新的 HLSLOutParamExpr。一个 HLSLOutParamExpr 有两种形式,一种只包含一个子表达式,另一种包含两个子表达式。

当参数表达式和函数参数的类型相同,因此不需要强制转换时,使用单个子表达式形式。例如

void Init(inout int X) {
  X = 1;
}

void main() {
  int V;
  Init(V);
}

此代码的预期 AST 表达式将类似于

CallExpr 'void'
|-ImplicitCastExpr 'void (*)(int &)' <FunctionToPointerDecay>
| `-DeclRefExpr 'void (int &)' lvalue Function  'Init' 'void (int &)'
|-HLSLOutParamExpr 'int' lvalue inout
  `-DeclRefExpr 'int' lvalue Var 'V' 'int'

HLSLOutParamExpr 捕获了该值是 inout 还是 out,以指示临时值是否由子表达式初始化。如果不需要强制转换,则子表达式表示在值过期时 cx-值将被复制到的左值表达式。

当参数类型与参数类型不同时,需要使用 AST 节点的两个子表达式形式。给出这个示例

void Trunc(inout int3 V) { }


void main() {
  float3 F = {1.5, 2.6, 3.3};
  Trunc(F);
}

对于这种情况,HLSLOutParamExpr 将具有子表达式来记录初始化和写回的两个强制转换表达式序列

-CallExpr 'void'
  |-ImplicitCastExpr 'void (*)(int3 &)' <FunctionToPointerDecay>
  | `-DeclRefExpr 'void (int3 &)' lvalue Function 'inc_i32' 'void (int3 &)'
  `-HLSLOutParamExpr 'int3' lvalue inout
    |-ImplicitCastExpr 'float3' <IntegralToFloating>
    | `-ImplicitCastExpr 'int3' <LValueToRValue>
    |   `-OpaqueValueExpr 'int3' lvalue
    `-ImplicitCastExpr 'int3' <FloatingToIntegral>
      `-ImplicitCastExpr 'float3' <LValueToRValue>
        `-DeclRefExpr 'float3' lvalue 'F' 'float3'

在这种形式中,写回强制转换被捕获为第一个子表达式,它们从一个 OpaqueValueExpr 进行强制转换。在 IR 生成中,我们可以使用 OpaqueValueExpr 作为 HLSLOutParamExpr 的临时值在函数返回时的占位符。

在代码生成中,这可以通过对 Objective-C 写回支持进行一些针对性的扩展来实现。具体而言,扩展 CGCall.cpp 中的 EmitWriteback 函数,以支持强制转换表达式和聚合左值的发出。