字段 vs 属性。性能优化

96
请注意,这个问题只涉及性能。让我们跳过设计指南、哲学、兼容性、可移植性以及任何与纯性能无关的内容。谢谢。
现在来看问题。我一直认为,因为C#的getter/setter实际上是伪装成方法的,所以读取公共字段必须比调用getter更快。
因此,为了确保我做了一个测试(下面的代码)。然而,这个测试只有在你从Visual Studio内部运行它时才会产生预期的结果(即字段比getter快34%如果你从命令行运行它,它将显示几乎相同的时间……
唯一的解释可能是CLR进行了额外的优化(如果我在这里错了,请纠正我)。
我不相信在真正的应用程序中,这些属性被用于更复杂的方式时,它们会以同样的方式进行优化。
请帮助我证明或否定这个想法,在实际应用程序中,属性是否比字段更慢。
问题是 - 我应该如何修改测试类,使CLR改变行为,使公共字段优于getter。或者展示给我,没有内部逻辑的任何属性在getter方面的表现都和字段相同(至少如此)。
编辑:我只谈论Release x64版本。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Diagnostics;
using System.Runtime.InteropServices;

namespace PropertyVsField
{
    class Program
    {
        static int LEN = 20000000;
        static void Main(string[] args)
        {
            List<A> a = new List<A>(LEN);
            List<B> b = new List<B>(LEN);

            Random r = new Random(DateTime.Now.Millisecond);

            for (int i = 0; i < LEN; i++)
            {
                double p = r.NextDouble();
                a.Add(new A() { P = p });
                b.Add(new B() { P = p });
            }

            Stopwatch sw = new Stopwatch();

            double d = 0.0;

            sw.Restart();
            for (int i = 0; i < LEN; i++)
            {
                d += a[i].P;
            }

            sw.Stop();

            Console.WriteLine("auto getter. {0}. {1}.", sw.ElapsedTicks, d);

            sw.Restart();
            for (int i = 0; i < LEN; i++)
            {
                d += b[i].P;
            }

            sw.Stop();

            Console.WriteLine("      field. {0}. {1}.", sw.ElapsedTicks, d);

            Console.ReadLine();
        }
    }

    class A
    {
        public double P { get; set; }
    }
    class B
    {
        public double P;
    }
}

20
Bobb,微软非常清楚无论他们如何吹嘘设计哲学/玩意,一旦人们意识到使用公共字段比属性快34%(尽管在99.9%的情况下这只是微小的优化),每个人都会使用公共字段。因此,像Eric Lipperts这样的世界级开发者已经构建了一个非常好的编译器和优化器,它可以确定你的自动属性实际上是伪装成公共字段的,从而进行相应的优化。 - Sergey Kalinichenko
7
如果你只是使用一个没有方法的容器,那么可以尝试将B定义为结构体而不是类。同时需要考虑在Visual Studio中启动时是使用“开始调试”还是“无调试启动”。使用“开始调试”会钩住很多东西,例如允许你进行步进、观察值等操作,因此会对性能产生重大影响。 - JamieSee
1
@dasblinkenlight - 很好的观点...但好像好处来自CLR而不是C#编译器...如果是编译器,那么在VS中也会很快...我错过了什么吗? - Boppity Bop
1
如果您在紧密循环中频繁使用某个属性,则将其存储在本地变量中可能会更快。请参见http://jacksondunstan.com/articles/2968 - CAD bloke
1
您的请求中写着“或者”,但是这两个选项不是互相排斥的。您已经标记了一个答案,它费尽心思通过跳跃来降低性能以满足您的第一个任务,但读者应该被警告,这并不有用(至少在您强调的纯性能方面),内联自动属性具有良好的性能表现。 - Paul Childs
显示剩余3条评论
6个回答

71

正如其他人已经提到的,这些getter方法被内联了。

如果你想避免内联,你需要:

  • 用手动属性替换自动属性:

  • class A 
    {
        private double p;
        public double P
        {
            get { return p; }
            set { p = value; }
        }
    } 
    
  • 并告诉编译器不要内联 getter(或者两者都不内联,如果你觉得这样更好):

  •         [MethodImpl(MethodImplOptions.NoInlining)]
            get { return p; }
    
    请提供需要翻译的完整上下文,以便我能够更好地理解并准确翻译。
    auto getter. 519005. 10000971,0237547.
          field. 514235. 20001942,0475098.
    

    不会对getter进行内联:

    auto getter. 785997. 10000476,0385552.
          field. 531552. 20000952,077111.
    

4
请问,您是否需要使用手动属性?或者直接将属性应用于自动属性的getter方法是否仍然有效? - Lukazoid
1
有没有什么理由可以防止内联呢? - Jacob Stamm
1
虽然这是老问题,但我仍然对@JacobStamm提出的问题很感兴趣,如果有人看到这个问题,请告诉我。我不确定它是否具有足够小的范围来证明它自己的问题。 - Sinjai
4
这些数字是什么意思? - person27
1
@person27:运行OP代码的输出结果。第一列数字是相关的,包含经过的时钟数。数字越小越好(即速度越快)。 - Heinzi
@JacobStamm 其中一个原因可能是您的对象大小。我预期具有许多小型内联方法的对象比没有内联的对象要大。换句话说,您可能需要在性能与 RAM 之间做出取舍。 - mlemanczyk

31
请查看微软VB团队成员在MSDN上发布的博客文章“属性与字段 - 为什么重要?”(Jonathan Aneja)。他阐述了属性与字段的争论,并如下解释了琐碎属性:

有人主张使用字段,而非属性的理由是“字段更快”,但对于琐碎的属性来说,实际上并非如此。因为CLR的即时(JIT)编译器会内联属性访问,生成与直接访问字段一样高效的代码。


嗨@Bobb,我不怀疑你的阅读能力。实际上,我也最喜欢Heinzi的答案。我只是觉得这是一个有用的小参考,并且会对主题做出良好的贡献,因为解释来自主要来源。 - JamieSee
谢谢伙计..这只是周五的玩笑,别担心。我标记了Heinzi,因为我比你早几分钟看到了他的答案。但我也喜欢你的,干杯! :-) - Boppity Bop

13

JIT将内部指标确定为内联更快的任何方法(不仅仅是getter)。鉴于标准属性是 return _Property; 它将在所有情况下被内联。

你看到不同行为的原因是,在附加调试器的Debug模式下,JIT会受到显著的限制,以确保任何堆栈位置都符合代码的预期。

你还忘记了性能的第一法则:测试胜过思考。比如,即使快速排序在渐进意义下比插入排序更快,但对于非常小的输入,插入排序却更快。


3
太好了...说得好,“测试胜过思考”。谢谢。 - Praveen Prajapati
1
“测试胜于思考”并不是性能的第一准则。如果是这样,那么 Stack Overflow 就没有存在的理由了。测试和思考都需要时间和开发者资源。如果理论清晰,可以明确什么更快,并且不需要太多思考就能得出结论,那么思考显然更为优越,而测试只对那些无法进行验证的人有用。 - Paul Childs
2
@PaulChilds:你把一个相对的陈述当作绝对的,我不知道为什么。当谈到微观优化时,你是否能够思考得足够深入以优化CLR代码是值得怀疑的。了解C#编译器、JIT甚至你的CPU可能进行的性能优化是一个非常复杂的话题,对于像“使用属性与字段是否存在性能成本”的简单话题来说并不值得。如果你在谈论算法,那么确实需要做好功课。无论哪种情况,任何未经测量的性能改进都是值得怀疑的。这与我的观点一致。 - Guvante
武断地宣称某事是第一原则,本质上是一个绝对的陈述。 - Paul Childs
2
@PaulChilds:按照任何合理的标准,测试是性能优化的第一原则。你强调思考比性能调优更重要,这是完全错误的。而且,这个问题明显不是关于性能设计的,只有在这个领域中思考才更为重要。 - Guvante

8
唯一的解释可能是CLR进行了额外优化(如果我理解错误请纠正)。
是的,它被称为内联。 它是在编译器中完成的(机器代码级别 - 即JIT)。 由于getter/setter很简单,方法调用被销毁,并且getter/setter写入周围的代码中。
这在调试模式下不会发生,以支持调试(即能够在getter或setter中设置断点)。
在Visual Studio中,在调试器中没有办法做到这一点。编译版本,运行时不附加调试器,您将获得完整的优化。
我不相信在更复杂的应用程序中,这些属性被更复杂地使用时,它们会以同样的方式被优化。
世界充满了错误的幻觉。它们将被优化,因为它们仍然是微不足道的(即简单的代码,因此会被内联)。

谢谢...但请不要在调试时添加注释。我并不那么愚蠢,会尝试比较调试版本的性能..干杯 - Boppity Bop
这些都是非常有效的,因为当你涉及到更复杂的事情时,你会发现很多小东西的行为是不同的。例如,垃圾收集器并不能快速清除很多东西。它会让引用保持更长时间。 - TomTom

6

阅读了您的所有文章后,我决定使用以下代码进行基准测试:

    [TestMethod]
    public void TestFieldVsProperty()
    {
        const int COUNT = 0x7fffffff;
        A a1 = new A();
        A a2 = new A();
        B b1 = new B();
        B b2 = new B();
        C c1 = new C();
        C c2 = new C();
        D d1 = new D();
        D d2 = new D();
        Stopwatch sw = new Stopwatch();

        long t1, t2, t3, t4;

        sw.Restart();
        for (int i = COUNT - 1; i >= 0; i--)
        {
            a1.P = a2.P;
        }

        sw.Stop();

        t1 = sw.ElapsedTicks;

        sw.Restart();
        for (int i = COUNT - 1; i >= 0; i--)
        {
            b1.P = b2.P;
        }

        sw.Stop();


        t2 = sw.ElapsedTicks;

        sw.Restart();
        for (int i = COUNT - 1; i >= 0; i--)
        {
            c1.P = c2.P;
        }

        sw.Stop();

        t3 = sw.ElapsedTicks;

        sw.Restart();
        for (int i = COUNT - 1; i >= 0; i--)
        {
            d1.P = d2.P;
        }

        sw.Stop();


        t4 = sw.ElapsedTicks;
        long max = Math.Max(Math.Max(t1, t2), Math.Max(t3, t4));

        Console.WriteLine($"auto: {t1}, {max * 100d / t1:00.00}%.");
        Console.WriteLine($"field: {t2}, {max * 100d / t2:00.00}%.");
        Console.WriteLine($"manual: {t3}, {max * 100d / t3:00.00}%.");
        Console.WriteLine($"no inlining: {t4}, {max * 100d / t4:00.00}%.");

    }
    class A
    {
        public double P { get; set; }
    }
    class B
    {
        public double P;
    }
    class C
    {
        private double p;
        public double P
        {
            get => p;
            set => p = value;
        }
    }
    class D
    {
        public double P
        {
            [MethodImpl(MethodImplOptions.NoInlining)]
            get;
            [MethodImpl(MethodImplOptions.NoInlining)]
            set;
        }
    }

在调试模式下测试时,我得到了以下结果:
auto: 35142496, 100.78%.
field: 10451823, 338.87%.
manual: 35183121, 100.67%.
no inlining: 35417844, 100.00%.

但是当切换到发布模式时,结果与之前不同。
auto: 2161291, 873.91%.
field: 2886444, 654.36%.
manual: 2252287, 838.60%.
no inlining: 18887768, 100.00%.

似乎自动属性是更好的方法。

5
需要翻译的内容如下:

需要注意的是,在Visual Studio中可以看到“真实”的性能。

  1. 使用启用了优化的Release模式进行编译。
  2. 转到“调试”->“选项和设置”,取消选中“在模块加载时抑制JIT优化(仅托管)”。
  3. 可选择取消选中“启用我的代码”,否则您可能无法进入代码。

现在,即使在调试器附加时,jitted程序集也将保持不变,允许您按需步入优化的反汇编代码。这对于理解CLR如何优化代码至关重要。


好观点。但是太详细了。从资源管理器中启动它会更容易(特别是因为VS有在文件资源管理器中打开文件夹的命令;))。 - Boppity Bop
3
但是,如果您想调试并查看某些内容是否已经被内联,您需要将调试器作为单独的步骤附加。通过取消这两个选项,您只需按F5调试优化后的构建并在生成的汇编代码中单步执行。 - Asik

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