激活结构体而不将其存储为本地变量,预计比不将其存储为本地变量慢吗?

7

我在尝试理解.NET Core 2.1中的性能问题。此处可以找到相关代码:

https://github.com/mike-eee/StructureActivation

这是相关的基准测试代码,使用BenchmarkDotNet进行测试:
public class Program
{
    static void Main()
    {
        BenchmarkRunner.Run<Program>();
    }

    [Benchmark(Baseline = true)]
    public uint? Activated() => new Structure(100).SomeValue;

    [Benchmark]
    public uint? ActivatedAssignment()
    {
        var selection = new Structure(100);
        return selection.SomeValue;
    }
}

public readonly struct Structure
{
    public Structure(uint? someValue) => SomeValue = someValue;

    public uint? SomeValue { get; }
}

从一开始,我预计Activated会更快,因为它不存储本地变量,而我一直认为在当前堆栈上下文中定位和保留空间会导致性能损失。

然而,在运行测试时,我得到了以下结果:

// * Summary *

BenchmarkDotNet=v0.11.1, OS=Windows 10.0.17134.285 (1803/April2018Update/Redstone4)
Intel Core i7-4820K CPU 3.70GHz (Haswell), 1 CPU, 8 logical and 4 physical cores
.NET Core SDK=2.1.402
  [Host]     : .NET Core 2.1.4 (CoreCLR 4.6.26814.03, CoreFX 4.6.26814.02), 64bit RyuJIT
  DefaultJob : .NET Core 2.1.4 (CoreCLR 4.6.26814.03, CoreFX 4.6.26814.02), 64bit RyuJIT


              Method |     Mean |     Error |    StdDev | Scaled |
-------------------- |---------:|----------:|----------:|-------:|
           Activated | 4.700 ns | 0.0128 ns | 0.0107 ns |   1.00 |
 ActivatedAssignment | 3.331 ns | 0.0278 ns | 0.0260 ns |   0.71 |

激活结构(不存储本地变量)大约比原来慢30%。

参考ReSharper的IL Viewer中的IL:

.method /*06000002*/ public hidebysig instance valuetype [System.Runtime/*23000001*/]System.Nullable`1/*0100000E*/<unsigned int32> 
  Activated() cil managed 
{
  .custom /*0C00000C*/ instance void [BenchmarkDotNet/*23000002*/]BenchmarkDotNet.Attributes.BenchmarkAttribute/*0100000D*/::.ctor() 
    = (01 00 01 00 54 02 08 42 61 73 65 6c 69 6e 65 01 ) // ....T..Baseline.
    // property bool 'Baseline' = bool(true)
  .maxstack 1
  .locals /*11000001*/ init (
    [0] valuetype StructureActivation.Structure/*02000003*/ V_0
  )

  // [14 31 - 14 59]
  IL_0000: ldc.i4.s     100 // 0x64
  IL_0002: newobj       instance void valuetype [System.Runtime/*23000001*/]System.Nullable`1/*0100000E*/<unsigned int32>/*1B000001*/::.ctor(!0/*unsigned int32*/)/*0A00000F*/
  IL_0007: newobj       instance void StructureActivation.Structure/*02000003*/::.ctor(valuetype [System.Runtime/*23000001*/]System.Nullable`1/*0100000E*/<unsigned int32>)/*06000005*/
  IL_000c: stloc.0      // V_0
  IL_000d: ldloca.s     V_0
  IL_000f: call         instance valuetype [System.Runtime/*23000001*/]System.Nullable`1/*0100000E*/<unsigned int32> StructureActivation.Structure/*02000003*/::get_SomeValue()/*06000006*/
  IL_0014: ret          

} // end of method Program::Activated

.method /*06000003*/ public hidebysig instance valuetype [System.Runtime/*23000001*/]System.Nullable`1/*0100000E*/<unsigned int32> 
  ActivatedAssignment() cil managed 
{
  .custom /*0C00000D*/ instance void [BenchmarkDotNet/*23000002*/]BenchmarkDotNet.Attributes.BenchmarkAttribute/*0100000D*/::.ctor() 
    = (01 00 00 00 )
  .maxstack 2
  .locals /*11000001*/ init (
    [0] valuetype StructureActivation.Structure/*02000003*/ selection
  )

  // [19 4 - 19 39]
  IL_0000: ldloca.s     selection
  IL_0002: ldc.i4.s     100 // 0x64
  IL_0004: newobj       instance void valuetype [System.Runtime/*23000001*/]System.Nullable`1/*0100000E*/<unsigned int32>/*1B000001*/::.ctor(!0/*unsigned int32*/)/*0A00000F*/
  IL_0009: call         instance void StructureActivation.Structure/*02000003*/::.ctor(valuetype [System.Runtime/*23000001*/]System.Nullable`1/*0100000E*/<unsigned int32>)/*06000005*/

  // [20 4 - 20 31]
  IL_000e: ldloca.s     selection
  IL_0010: call         instance valuetype [System.Runtime/*23000001*/]System.Nullable`1/*0100000E*/<unsigned int32> StructureActivation.Structure/*02000003*/::get_SomeValue()/*06000006*/
  IL_0015: ret          

} // end of method Program::ActivatedAssignment

经过检查,Activated有两个newobj,而ActivatedAssignment只有一个,这可能是导致两个基准测试之间差异的原因。

我的问题是:这是否符合预期?我试图理解为什么代码较少的基准测试实际上比代码更多的基准测试慢。如有任何指导/建议以确保我遵循最佳实践,将不胜感激。


FWIW,“局部变量”可以完全通过JIT消除。 MSIL不能直接翻译为“性能效率”。 - user2864740
啊,@user2864740您是在说这可能是与JIT有关的问题吗?虽然我已经将这个问题标记为.NET Core,(并且结果明显显示了使用的运行时),但我已经更新了问题以反映这确实发生在.NET Core 2.1中。由于.NET Core 2.1非常注重性能,这只会增加我对此问题的怀疑(和困惑)。 - Mike-E
3
像这样的代码很难培养直觉。你看不到的是,无论哪种情况下,它都非常慢,因为你使用了可变结构类型,抖动优化器放弃了标准优化。uint?(又称 Nullable<uint>)很难看,因为它的 HasValue 字段需要被赋值,优化器因为无法推理出可能的副作用而束手无策。非常重要的是,您还应该使用普通的 uint 进行比较,这会让您对在性能关键代码中使用可空类型三思而后行。允许将该方法内联,将其置于 for 循环中。 - Hans Passant
有趣的是,@HansPassant,我注意到如果我在调用的方法中使用不可空的结构体(而不是可空的),结果会显著加快。似乎在我的情况下,只要涉及可空的结构体,结果就会受到10纳秒的影响。如果有工具/分析能够指出这些类型的问题,那将非常方便,而不是花费数天时间进行试验/错误,并最终不得不勇敢地前往StackOverflow以查看是否有任何指针可以在这里提供。感谢您提供的/信息/见解。 - Mike-E
1个回答

5

如果你查看你的方法中JIT生成的汇编代码,就可以更清楚地了解发生了什么:

Program.Activated()
L0000: sub rsp, 0x18
L0004: xor eax, eax              // Initialize Structure to {0}
L0006: mov [rsp+0x10], rax       // Store to stack
L000b: mov eax, 0x64             // Load literal 100
L0010: mov edx, 0x1              // Load literal 1
L0015: xor ecx, ecx              // Initialize SomeValue to {0}
L0017: mov [rsp+0x8], rcx        // Store to stack
L001c: lea rcx, [rsp+0x8]        // Load pointer to SomeValue from stack
L0021: mov [rcx], dl             // Set SomeValue.HasValue to 1
L0023: mov [rcx+0x4], eax        // Set SomeValue.Value to 100
L0026: mov rax, [rsp+0x8]        // Load SomeValue's value from stack
L002b: mov [rsp+0x10], rax       // Store it to a different location on stack
L0030: mov rax, [rsp+0x10]       // Return it from that location
L0035: add rsp, 0x18
L0039: ret

Program.ActivatedAssignment()
L0000: push rax
L0001: xor eax, eax              // Initialize SomeValue to {0}
L0003: mov [rsp], rax            // Store to stack
L0007: mov eax, 0x64             // Load literal 100
L000c: mov edx, 0x1              // Load literal 1
L0011: lea rcx, [rsp]            // Load pointer to SomeValue from stack
L0015: mov [rcx], dl             // Set SomeValue.HasValue to 1
L0017: mov [rcx+0x4], eax        // Set SomeValue.Value to 100
L001a: mov rax, [rsp]            // Return SomeValue
L001e: add rsp, 0x8
L0022: ret

显然,Activated() 做了更多的工作,这就是它变慢的原因。归根结底,它涉及到许多堆栈操作(所有对 rsp 的引用)。我已经尽可能地对它们进行了注释,但由于冗余的 movActivated() 方法有些复杂。 ActivatedAssigment() 要简单得多。
最终,省略局部变量实际上并没有节省堆栈空间。无论您是否为其命名,该变量必须在某个时刻存在。您粘贴的 IL 代码显示了一个本地变量(称为 V_0),这是 C# 编译器创建的临时变量,因为您没有显式创建它。
两者之间的区别在于,具有临时变量的版本仅保留一个堆栈插槽(.maxstack 1),并将其用于 Nullable<T>Structure,因此需要进行堆栈操作。而具有命名变量的版本则保留了2个插槽(.maxstack 2)。
讽刺的是,在预留了 selection 的本地变量的版本中,JIT 能够消除外层的结构,只处理其嵌入的 Nullable<T>,从而产生更干净/更快的代码。
我不确定您是否可以从这个示例中推导出任何最佳实践,但我想很容易看出 C# 编译器是性能差异的来源。 JIT 足够聪明,可以正确地处理您的结构,但前提是它以某种特定的方式出现。

哇,谢谢你提供详尽且有用的回答!我现在连反汇编都还在摸索阶段,更不用说JIT了,所以非常感谢你的评论。由于似乎编译器(Roslyn)是导致这种情况的原因,我的剩下想法是:你觉得这个问题应该在Roslyn的存储库中提出吗?因此,当我说“最佳实践”时,我也是想寻求有关工具和环境方面的指导。例如,如果你知道任何Roslyn分析器可以指出这些问题,那么指向它们的指针将会是非常感激的。再次感谢! - Mike-E
1
这是个好问题。让Roslyn团队知道他们可以改进IL来帮助JIT绝对不会有任何坏处。最坏的情况也只是他们说不而已 ;). 我想不出有哪些工具可以预先警告你这里的问题,但如果你还没有尝试过,我推荐使用sharplab.io来探索C#、IL和Asm之间的关系。 - saucecontrol
2
这是我用于您的示例的设置链接:https://sharplab.io/#v2:EYLgHgbALANALiAhgZwLYB8ACAGABJgRgG4BYAKEwGZ8AmfAgdnIG9zd36J8pcBZRAJYA7ABQBKNh1ZkOs3ADdEAJ1wAHXAF5cQgKYB3eg3GkZc9qoB0AQQDGcAYrg6AJsclnLt+45dXkyAQBzIVQdITg3U3YAX3J3fGoAV2E4AH5cLwdEJ1cxTQA+XBFdAwBlOCVEu0SlHRECbGwxMQtSgHtQgDVEABtEnRN4qlxk8PTMn2c/AODQ8PF46TN2RRVkHR6dOwE2oU1tfVxyyura+saxE2X8Blx1ze3d1o6dbr6B+NiyL/Jh2sRnLsegBPO4VKpwI7g046FhDajHCE1OqjNJ3F5vfp5DSFdpdXr9fbIDEEj5keEjFLpPGvUm4Zi4QI6OBEXBfaJAA= - saucecontrol
1
太棒了!我完全不知道你可以从网页中派生JIT。:) 这正是我对我的资源添加的非常感兴趣的内容。在冒险使用StackOverflow之前,我还会考虑另一个因素。再次感谢。 - Mike-E
1
顺便提一下,我已经在Roslyn的仓库中发布了一个问题,链接在这里:https://github.com/dotnet/roslyn/issues/30284 - Mike-E
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接