【问题标题】:Efficiency of load-value instructions versus load-address instructions for fields of structs结构字段的加载值指令与加载地址指令的效率
【发布时间】:2020-06-13 16:29:22
【问题描述】:

考虑以下 C# 结构定义:

public struct A
{
    public B B;
}

public struct B
{
    public int C;
}

还要考虑下面的静态方法:

public static int Method(A a) => a.B.C;

调用此方法将生成结构类型A 的副本。例如,在以下代码中:

A a = default;
Method(a);

Method 的调用将编译为如下所示的IL:

IL_0008: ldloc.0      // V_0
IL_0009: call         int32 Class::Method(valuetype A)

ldloc 会将局部变量a (V_0) 的值复制到评估堆栈上,该值将用于Method。如果A(或B)是一个大型结构,那么这个副本可能会很昂贵。 Method 的 IL 也会导致加载值指令:

IL_0000: ldarg.0      // a
IL_0001: ldfld        valuetype B A::B
IL_0006: ldfld        int32 B::C
IL_000b: ret

最新版本的 C# 包含有助于更高效地使用结构的功能。 C# 7.2 在参数上引入了in 修饰符,当编译器可以验证参数不会被调用的方法修改时,它可以通过引用传递值类型。例如,将in 修饰符应用于参数a

public static int Method(in A a) => a.B.C;

将在调用站点生成以下编译的 IL:

IL_0008: ldloca.s     a
IL_000a: call         int32 Class::Method(valuetype A&)

并在执行Method:

IL_0000: ldarg.0      // a
IL_0001: ldflda       valuetype B A::B
IL_0006: ldfld        int32 B::C
IL_000b: ret

注意加载地址指令。我的假设(如果我错了,请纠正我)是对于深场读取(例如读取 C 内部 B 内部 A ),加载地址指令比加载值指令更有效。

考虑到这一点,考虑更改示例代码:

A a = default;
var c = a.B.C;

然后第二行编译为:

IL_0008: ldloc.1      // V_1
IL_0009: ldfld        valuetype B A::B
IL_000e: ldfld        int32 B::C
IL_0013: stloc.0      // c

为什么编译器在这种情况下也不喜欢使用加载地址指令呢?是否仅仅因为a 是局部变量而不是方法参数而存在效率差异,还是我在这里遗漏了其他东西?

【问题讨论】:

    标签: c# cil intermediate-language


    【解决方案1】:

    这绝对与a 是局部变量还是方法参数无关。至少从效率的角度来看不是。

    首先要理解的是,C# 中的结构直接位于(在内存中)它们被声明的位置 - 对于局部变量,直接位于堆栈上。更重要的是 - 嵌套结构的行为相同。 JIT 有可能在运行时的任何时候(并非总是在编译期间,阅读有关StructLayoutAttribute 的更多信息)准确知道BA 内部的位置,其中CB 内部,而B.C 位于a 内部。

    在 JIT 编译方法后查看汇编代码时(在 Release 中编译很重要 - 调试构建不会以相同的方式优化。确保编译器也不会优化变量),你'会看到,无论您在哪里输入 a.B.C,它始终是来自内存的直接分配(相对于 A 在内存中的位置)。

    就我而言,我在 A 中添加了另一个变量 int a1 以稍微移动内存 - 这是生成的代码:

    A a = 默认值;

    xor         ecx,ecx  
    mov         qword ptr [rbp-30h],rcx
    

    var c = a.B.C;

    mov         esi,dword ptr [rbp-2Ch]
    

    其中 esi 是 var c 的临时寄存器,[rbp-30h]a 在堆栈中的位置。 B 有一个整数位于偏移 0,A 有一个整数位于偏移 0,B 位于偏移 4,所以 a.B.C 的最终地址始终是 a+4 ([rbp-2Ch])。

    【讨论】:

    • 非常感谢您为我调查此事。我有几个后续问题。您提到结构直接位于局部变量的堆栈上。值传递的方法参数也是这样吗?其次,在更高的层次上,如果 JIT 无论如何都要加载字段的地址,为什么编译器不只使用加载地址指令呢?换句话说,为什么编译器首先要为 JIT 实现留下“解释空间”来做一些低效的事情?
    • @WizardBrony 如果您通过值将结构传递给方法,它会将您的结构复制到被调用方法的堆栈上。如果要防止结构复制,可以使用outref 关键字,它告诉编译器使用by address 指令(如ldloca)并仅在堆栈地址上复制。对于上一条评论的第二部分,我没有答案。
    • @WizardBrony 如果此答案或任何答案解决了您的问题,请单击复选标记考虑accepting it。这向更广泛的社区表明您已经找到了解决方案,并为回答者和您自己提供了一些声誉。没有义务这样做。
    • 关于第二个问题:JIT 并不总是“本质上加载字段的地址”。即使在这种特殊情况下 - 它也不会加载该字段的地址。相反,它确切地知道它所在的位置,并使用它来了解其余信息的位置。例如,在嵌套结构中,我们根本不“加载第二个结构的地址”。了解您到底在问什么很重要。是“为什么 JIT 甚至复制结构而不使用原始地址?”是“为什么在方法内访问 A.B.C 使用按值加载而不是按地址加载”?
    • 将 .Net 编译器 (Roslyn) 视为说“什么”他期望发生的人,而 JIT 决定“如何”做到这一点。 JIT 有更多的信息,可以做出更好的决策——它可以在寄存器中缓存局部变量或参数,它可以内联方法和运行时的许多更复杂的决策。
    猜你喜欢
    • 1970-01-01
    • 2015-03-14
    • 2015-10-12
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2013-04-18
    相关资源
    最近更新 更多