为什么我在调试模式 nVIDIA SASS 代码中看到 MOV Rn, Rn 指令?
Why do I see MOV Rn, Rn instructions in debugging-mode nVIDIA SASS code?
这是我正在处理的内核的一些 SASS 代码片段(对于 sm52 目标,compiled in debugging mode):
/*0028*/ ISETP.GE.U32.AND P0, PT, R1, R0, PT; /* 0x5b6c038000070107 */
/*0030*/ @P0 BRA 0x40; /* 0xe24000000080000f */
/*0038*/ BPT.TRAP 0x1; /* 0xe3a00000001000c0 */
/* 0x007fbc0321e01fef */
/*0048*/ IADD R2, R1, RZ; /* 0x5c1000000ff70102 */
/*0050*/ I2I.U32.U32 R2, R2; /* 0x5ce0000000270a02 */
/*0058*/ MOV R2, R2; /* 0x5c98078000270002 */
/* 0x007fbc03fde01fef */
/*0068*/ MOV R3, RZ; /* 0x5c9807800ff70003 */
/*0070*/ MOV R2, R2; /* 0x5c98078000270002 */
/*0078*/ MOV R3, R3; /* 0x5c98078000370003 */
/* 0x007fbc03fde01fef */
/*0088*/ MOV R4, R2; /* 0x5c98078000270004 */
/*0090*/ MOV R5, R3; /* 0x5c98078000370005 */
/*0098*/ MOV R2, c[0x0][0x4]; /* 0x4c98078000170002 */
/* 0x007fbc03fde01fef */
/*00a8*/ MOV R3, RZ; /* 0x5c9807800ff70003 */
/*00b0*/ LOP.OR R2, R4, R2; /* 0x5c47020000270402 */
/*00b8*/ LOP.OR R3, R5, R3; /* 0x5c47020000370503 */
我注意到不止几个 "Move the contents of register Rn to register Rn" 形式的说明 - 看起来没有意义。我知道在未启用调试信息和优化的情况下进行编译时,我不会得到这些说明。但是,即使在调试模式下——它们为什么在那里?他们的目的是什么?据我所知,在编译 CPU 调试代码时,您不会得到这些指令。
基于一般的编译器知识,我对 CUDA 一无所知。
大多数编程语言大多有 context/state-less 命令。每个这样的命令都可以单独编译到目标机器 code/opcode 输出中(使这个编译步骤实现起来很简单,只处理单个实际解析的命令)。一些例外是各种 prefix/suffix/with 修饰符,或 continue
/ break
控制循环。
例如variable = variable + 2;
可以独立于源码中的上一条和下一条命令编译成"add two to variable"(简单快速),即:"load variable from memory into register, add two to register, store value from register back to variable memory".
很难决定使用哪个寄存器。如果您仔细考虑一下,就会发现随机寄存器分配与任何其他朴素分配规则一样好。这通常是在编译的早期阶段分配寄存器的方式(使用任何被破坏惩罚最小的寄存器)。
但是你需要一些 "bridge" 代码来连接它们之间的命令,或者使用内存中的严格变量(然后根本没有桥接代码),或者 reusing/sharing 命令之间的一些值,只是将它们移动到适当的寄存器中(您的 "non sense" mov rN,rN
指令,从内存中保存一些获取指令)。
编译阶段优化寄存器分配(尝试增加 sharing/reusing 寄存器,为某些命令重新分配寄存器并再次编译它们,有时甚至重新排序命令块以使寄存器共享更优化)是不平凡的任务和耗时的编译步骤,代码工作不需要这些。调试编译跳过此步骤以更快地生成二进制文件。
同样在调试版本中,希望在每个源命令之后将变量值存储到它的内存中,以使结果在调试器中可见,尽管在优化的发布版本中编译器可能会识别某些结果的 "intermediary" 性质,并仅将它们暂时保存在寄存器中。
您得到的简单答案会出现奇怪的代码,因为您打开了调试,关闭了优化。由于现代优化编译器的工作方式,这很正常。他们将操作分解为一个原语static single-assignment (SSA) form,这使得优化更容易,但当不优化时会生成比更简单的非优化编译器更糟糕的代码。
还有一种可能性,尽管我不认为这里是这种情况,指令是故意插入 NOP 以延迟执行。 GPU 的指令集与您可能熟悉的通用 CPU 有很大不同。例如,大多数 CPU 的工作方式就好像指令一次执行一个,并且严格按照给出的顺序执行。尽管现代 CPUs 会尝试并行甚至乱序执行指令以提高性能,但这是事实。 GPU 通常不会以这种方式工作。如果您尝试使用前一条指令在该指令完成之前存储在某个寄存器中的结果,您将获得该寄存器的旧值。与 CPU 不同,GPU 不会在执行依赖它的下一条指令之前自动等待指令完成。
如果您查看反汇编代码,您会注意到指令被分为三个指令包。您可能还会看到捆绑包之间有隐藏的说明。该指令的机器代码显示在右侧(例如 /* 0x007fbc0321e01fef */
),但它未在左侧反汇编,并且尽管像任何其他指令一样占用 8 字节槽,但它的地址未显示。这实际上是一个scheduling block control code。它不是真正的指令,而是指示 GPU 如何安排它之前的指令包中的指令。它告诉 GPU 一些事情,比如哪些指令需要等待前面的指令完成,以及它们应该等待多长时间。
最后还有一种可能性,尽管可能性极小,即冗余 MOV 实际上根本不是 NOP。它们可能正在作用于尚未覆盖的寄存器值,并以某种奇怪的方式与其他指令并行,这给它们带来了延迟以外的有用效果。然而,这将是一种非常先进的优化技术,我只希望在手动调整的汇编代码中使用,而不是在甚至不生成优化代码的编译器中使用。
这是我正在处理的内核的一些 SASS 代码片段(对于 sm52 目标,compiled in debugging mode):
/*0028*/ ISETP.GE.U32.AND P0, PT, R1, R0, PT; /* 0x5b6c038000070107 */
/*0030*/ @P0 BRA 0x40; /* 0xe24000000080000f */
/*0038*/ BPT.TRAP 0x1; /* 0xe3a00000001000c0 */
/* 0x007fbc0321e01fef */
/*0048*/ IADD R2, R1, RZ; /* 0x5c1000000ff70102 */
/*0050*/ I2I.U32.U32 R2, R2; /* 0x5ce0000000270a02 */
/*0058*/ MOV R2, R2; /* 0x5c98078000270002 */
/* 0x007fbc03fde01fef */
/*0068*/ MOV R3, RZ; /* 0x5c9807800ff70003 */
/*0070*/ MOV R2, R2; /* 0x5c98078000270002 */
/*0078*/ MOV R3, R3; /* 0x5c98078000370003 */
/* 0x007fbc03fde01fef */
/*0088*/ MOV R4, R2; /* 0x5c98078000270004 */
/*0090*/ MOV R5, R3; /* 0x5c98078000370005 */
/*0098*/ MOV R2, c[0x0][0x4]; /* 0x4c98078000170002 */
/* 0x007fbc03fde01fef */
/*00a8*/ MOV R3, RZ; /* 0x5c9807800ff70003 */
/*00b0*/ LOP.OR R2, R4, R2; /* 0x5c47020000270402 */
/*00b8*/ LOP.OR R3, R5, R3; /* 0x5c47020000370503 */
我注意到不止几个 "Move the contents of register Rn to register Rn" 形式的说明 - 看起来没有意义。我知道在未启用调试信息和优化的情况下进行编译时,我不会得到这些说明。但是,即使在调试模式下——它们为什么在那里?他们的目的是什么?据我所知,在编译 CPU 调试代码时,您不会得到这些指令。
基于一般的编译器知识,我对 CUDA 一无所知。
大多数编程语言大多有 context/state-less 命令。每个这样的命令都可以单独编译到目标机器 code/opcode 输出中(使这个编译步骤实现起来很简单,只处理单个实际解析的命令)。一些例外是各种 prefix/suffix/with 修饰符,或 continue
/ break
控制循环。
例如variable = variable + 2;
可以独立于源码中的上一条和下一条命令编译成"add two to variable"(简单快速),即:"load variable from memory into register, add two to register, store value from register back to variable memory".
很难决定使用哪个寄存器。如果您仔细考虑一下,就会发现随机寄存器分配与任何其他朴素分配规则一样好。这通常是在编译的早期阶段分配寄存器的方式(使用任何被破坏惩罚最小的寄存器)。
但是你需要一些 "bridge" 代码来连接它们之间的命令,或者使用内存中的严格变量(然后根本没有桥接代码),或者 reusing/sharing 命令之间的一些值,只是将它们移动到适当的寄存器中(您的 "non sense" mov rN,rN
指令,从内存中保存一些获取指令)。
编译阶段优化寄存器分配(尝试增加 sharing/reusing 寄存器,为某些命令重新分配寄存器并再次编译它们,有时甚至重新排序命令块以使寄存器共享更优化)是不平凡的任务和耗时的编译步骤,代码工作不需要这些。调试编译跳过此步骤以更快地生成二进制文件。
同样在调试版本中,希望在每个源命令之后将变量值存储到它的内存中,以使结果在调试器中可见,尽管在优化的发布版本中编译器可能会识别某些结果的 "intermediary" 性质,并仅将它们暂时保存在寄存器中。
您得到的简单答案会出现奇怪的代码,因为您打开了调试,关闭了优化。由于现代优化编译器的工作方式,这很正常。他们将操作分解为一个原语static single-assignment (SSA) form,这使得优化更容易,但当不优化时会生成比更简单的非优化编译器更糟糕的代码。
还有一种可能性,尽管我不认为这里是这种情况,指令是故意插入 NOP 以延迟执行。 GPU 的指令集与您可能熟悉的通用 CPU 有很大不同。例如,大多数 CPU 的工作方式就好像指令一次执行一个,并且严格按照给出的顺序执行。尽管现代 CPUs 会尝试并行甚至乱序执行指令以提高性能,但这是事实。 GPU 通常不会以这种方式工作。如果您尝试使用前一条指令在该指令完成之前存储在某个寄存器中的结果,您将获得该寄存器的旧值。与 CPU 不同,GPU 不会在执行依赖它的下一条指令之前自动等待指令完成。
如果您查看反汇编代码,您会注意到指令被分为三个指令包。您可能还会看到捆绑包之间有隐藏的说明。该指令的机器代码显示在右侧(例如 /* 0x007fbc0321e01fef */
),但它未在左侧反汇编,并且尽管像任何其他指令一样占用 8 字节槽,但它的地址未显示。这实际上是一个scheduling block control code。它不是真正的指令,而是指示 GPU 如何安排它之前的指令包中的指令。它告诉 GPU 一些事情,比如哪些指令需要等待前面的指令完成,以及它们应该等待多长时间。
最后还有一种可能性,尽管可能性极小,即冗余 MOV 实际上根本不是 NOP。它们可能正在作用于尚未覆盖的寄存器值,并以某种奇怪的方式与其他指令并行,这给它们带来了延迟以外的有用效果。然而,这将是一种非常先进的优化技术,我只希望在手动调整的汇编代码中使用,而不是在甚至不生成优化代码的编译器中使用。