创建堆栈大小为默认值的50倍的线程有什么危险?


228

我目前正在开发一个性能非常关键的程序,因此决定探索一条可能有助于减少资源消耗的方法,这是增加工作线程的堆栈大小,以便我可以将要访问的大部分数据float[]移至堆栈(使用stackalloc)。

我已经读到,线程的默认堆栈大小是1 MB,因此,要移动我float[]的所有内存,我必须将堆栈扩展大约50倍(到50 MB〜)。

我了解这通常被认为是“不安全的”并且不建议这样做,但是在将当前代码与此方法进行基准比较之后,我发现处理速度提高了530%!因此,我不能在没有进一步调查的情况下简单地通过该选项,这使我提出了问题。将堆栈增加到如此大的尺寸有什么危险(可能会出问题),我应采取什么预防措施以最小化此类危险?

我的测试代码

public static unsafe void TestMethod1()
{
    float* samples = stackalloc float[12500000];

    for (var ii = 0; ii < 12500000; ii++)
    {
        samples[ii] = 32768;
    }
}

public static void TestMethod2()
{
    var samples = new float[12500000];

    for (var i = 0; i < 12500000; i++)
    {
        samples[i] = 32768;
    }
}

98
+1。说真的 您问什么看似不合常规的愚蠢问题,然后提出一个非常好的案例,在您的特定情况下,考虑这是明智的选择,因为您做了作业并衡量了结果。这很好-我想念很多问题。非常好-不错,您考虑过这种事情,可惜许多C#程序员都不知道这些优化机会。是的,通常不需要-但是有时这很关键,并带来很大的改变。
TomTom

5
我很感兴趣地看到这两个代码在处理速度上有530%的差异,这完全是因为将数组移动到堆栈中。只是感觉不对。
Dialecticus

13
在走这条路之前:您是否尝试过使用Marshal.AllocHGlobal(别忘FreeHGlobal了)托管内存之外分配数据?然后将指针转换为float*,然后应该进行排序。
Marc Gravell

2
如果您进行大量分配,感觉确实不错。Stackalloc绕过了所有GC问题,这些问题也可以在处理器级别上创建/创建非常强大的局部性。这是看起来像微优化的事情之一-除非您编写高性能的数学程序并且正好具有此行为,否则它会有所不同;)
TomTom

6
我的怀疑:这些方法中的一种在每次循环迭代时触发边界检查,而另一种则没有,或者已被优化。
pjc50 2014年

Answers:


45

通过与Sam对比测试代码,我确定我们都是对的!
但是,关于不同的事情:

  • 无论在何处(堆栈,全局或堆),访问内存(读取和写入)的速度一样快
  • 但是,分配它在堆栈上最快,而在堆上最慢。

它是这样的:stack< global< heap。(分配时间)
从技术上讲,堆栈分配实际上不是分配,运行时仅确保堆栈的一部分(帧?)已为数组保留。

不过,我强烈建议您谨慎使用。
我建议以下内容:

  1. 当您需要频繁创建永远不会离开函数的数组(例如,通过传递其引用)时,使用堆栈将是一个巨大的改进。
  2. 如果可以回收一个阵列,请尽可能回收利用!堆是长期对象存储的最佳位置。(污染全局内存不是很好;堆栈帧可能会消失)

:1.仅适用于值类型;引用类型将在堆上分配,并且收益将减少为0)

要回答这个问题本身:任何大型堆栈测试都没有遇到任何问题。
我相信,唯一可能的问题是堆栈溢出,如果您不小心使用函数调用,并且如果系统运行不足,则在创建线程时会耗尽内存。

以下部分是我的初步答案。这是错误的,测试不正确。保留仅供参考。


我的测试表明,在数组中使用堆栈分配的内存和全局内存至少比堆分配的内存慢15%(占用时间的120%)!

这是我的测试代码,这是示例输出:

Stack-allocated array time: 00:00:00.2224429
Globally-allocated array time: 00:00:00.2206767
Heap-allocated array time: 00:00:00.1842670
------------------------------------------
Fastest: Heap.

  |    S    |    G    |    H    |
--+---------+---------+---------+
S |    -    | 100.80 %| 120.72 %|
--+---------+---------+---------+
G |  99.21 %|    -    | 119.76 %|
--+---------+---------+---------+
H |  82.84 %|  83.50 %|    -    |
--+---------+---------+---------+
Rates are calculated by dividing the row's value to the column's.

我在.NET 4.5.1下使用i7 4700 MQ在Windows 8.1 Pro(带有Update 1)上进行了测试
测试,同时对x86和x64进行了测试,结果是相同的。

编辑:我将所有线程的堆栈大小增加了201 MB,样本大小增加到5000万,迭代次数减少到5。
结果与上述相同

Stack-allocated array time: 00:00:00.4504903
Globally-allocated array time: 00:00:00.4020328
Heap-allocated array time: 00:00:00.3439016
------------------------------------------
Fastest: Heap.

  |    S    |    G    |    H    |
--+---------+---------+---------+
S |    -    | 112.05 %| 130.99 %|
--+---------+---------+---------+
G |  89.24 %|    -    | 116.90 %|
--+---------+---------+---------+
H |  76.34 %|  85.54 %|    -    |
--+---------+---------+---------+
Rates are calculated by dividing the row's value to the column's.

不过,看来堆栈实际上正在变慢


根据基准测试的结果(请参阅页面底部的评论以了解结果),我不得不不同意的是,堆栈的速度比全局速度快一点,比堆栈快得多。并确保我的结果准确无误,并进行了20次测试,每种方法在每次测试迭代中均被称为100次。您肯定会正确运行基准测试吗?
2014年

我得到的结果非常不一致。有了完全的信任,x64,发行版配置,没有调试器,它们都同样快(差异小于1%;波动),而使用堆栈确实更快。我需要进一步测试!编辑:您应该抛出堆栈溢出异常。您只需为数组分配足够的空间。O_o
Vercas

是的,我知道,这很近。您需要像我一样重复几次基准测试,也许尝试平均运行5次以上。
2014年

1
@Voo对我来说,第一轮测试花费的时间与第100轮测试一样多。根据我的经验,Java JIT根本不适用于.NET。.NET唯一的“热身”是在首次使用时加载类和程序集。
Vercas 2014年

2
@Voo测试我的基准以及他在对此答案的评论中添加的要点。将代码组合在一起,然后运行数百个测试。然后返回并报告您的结论。我已经非常彻底地完成了测试,并且我很清楚我在说什么。
韦尔卡斯2014年

28

我发现处理速度提高了530%!

到目前为止,这是我要说的最大危险。您的基准测试存在严重问题,无法正常运行的代码通常在某个地方隐藏了一个讨厌的错误。

除了过度递归之外,在.NET程序中占用大量堆栈空间非常非常困难。托管方法的堆栈框架的大小固定不变。只是方法的参数和方法中的局部变量之和。减去那些可以存储在CPU寄存器中的寄存器,您可以忽略它,因为它们很少。

增加堆栈大小不会完成任何操作,您只会保留一堆永远不会使用的地址空间。当然,没有机制可以解释由于不使用内存而导致的性能提升。

这不同于本机程序,尤其是用C编写的本机程序,它还可以为堆栈框架上的数组保留空间。堆栈缓冲区后面的基本恶意软件攻击媒介溢出。同样可能在C#中,您必须使用stackalloc关键字。如果这样做,那么明显的危险就是必须编写容易受到这种攻击以及随机堆栈帧损坏的不安全代码。很难诊断错误。在以后的抖动中有一个针对此的对策,我认为从.NET 4.0开始,抖动会生成代码以在堆栈帧上放置一个“ cookie”,并在方法返回时检查它是否仍然完整。即时崩溃到桌面,如果发生这种情况,则无法拦截或报告事故。这对用户的精神状态很危险。

程序的主线程,即由操作系统启动的主线程,默认情况下将具有1 MB堆栈,而在针对x64的程序进行编译时将具有4 MB堆栈。增长的需要在生成后事件中使用/ STACK选项运行Editbin.exe。通常,在以32位模式运行时,程序在启动之前将遇到问题,通常最多需要500 MB。当然,线程也可以轻松得多,对于32位程序,危险区域通常徘徊在90 MB左右。当程序运行了很长时间并且地址空间与以前的分配不成一体时,将触发该事件。要获得此故障模式,总的地址空间使用率必须已经很高(超过一个演出)。

请仔细检查您的代码,这是非常错误的。除非您显式地编写代码以利用它,否则无法获得具有更大堆栈的x5加速。总是需要不安全的代码。在C#中使用指针总是有创建更快代码的诀窍,它不受数组边界检查。


21
加速报道的5倍是从移动float[]float*。庞大的堆栈就是如何完成的。在某些情况下,x5加速对于该更改是完全合理的。
Marc Gravell

3
好的,当我开始回答问题时,我还没有代码片段。仍然足够接近。
Hans Passant 2014年

22

我在那里有一个保留意见,我根本不知道该如何预测它-权限,GC(需要扫描堆栈)等等,所有这些都会受到影响。我很想使用非托管内存来代替:

var ptr = Marshal.AllocHGlobal(sizeBytes);
try
{
    float* x = (float*)ptr;
    DoWork(x);
}
finally
{
    Marshal.FreeHGlobal(ptr);
}

1
附带问题:为什么GC需要扫描堆栈?分配的内存stackalloc不受垃圾回收的限制。
dcastro 2014年

6
@dcastro,它需要扫描堆栈以检查仅存在于堆栈中的引用。我只是不知道当它达到这么大的时候会做什么stackalloc-它有点需要跳跃,并且您希望它会毫不费力地做-但是我要说明的是它引入了不必要的并发症/担忧。IMO,stackalloc是很大的作为临时缓冲,但对于一个专用的办公空间中,更期望仅分配一个chunk-O-存储器某处,而不是滥用/混淆堆栈,
马克Gravell

8

可能出错的一件事是您可能没有获得这样做的许可。除非以完全信任模式运行,否则Framework只会忽略对更大堆栈大小的请求(请参见上的MSDN Thread Constructor (ParameterizedThreadStart, Int32)

建议不要重写系统堆栈大小,以使它使用堆上的Iteration和手动堆栈实现来重写代码。


1
好主意,我会遍历。除此之外,我的代码还在完全信任模式下运行,所以还有其他需要注意的事情吗?
山姆

6

高性能数组可能以与普通C#相同的方式访问,但这可能是麻烦的开始:请考虑以下代码:

float[] someArray = new float[100]
someArray[200] = 10.0;

您可能会预期到一个异常,并且这完全是有道理的,因为您正尝试访问元素200,但最大允许值为99。以下内容不会显示任何异常:

Float* pFloat =  stackalloc float[100];
fFloat[200]= 10.0;

在上面,您分配了足够的内存来容纳100个浮点,并且正在设置sizeof(float)内存位置,该位置从此内存的起始位置开始+ 200 * sizeof(float)来保存您的浮点值10。为浮点数分配了内存,没有人知道该地址可以存储什么。如果幸运的话,您可能已经使用了一些当前未使用的内存,但是同时您可能会覆盖一些用于存储其他变量的位置。总结:不可预测的运行时行为。


事实是错误的。运行时和编译器测试仍然存在。
TomTom 2014年

9
@TomTom erm,不;答案是有道理的;问题在谈论stackalloc,在这种情况下,我们在谈论float*etc-没有相同的检查。调用它是unsafe有充分理由的。我个人很高兴unsafe在有充分理由的情况下使用,但苏格拉底提出了一些合理的观点。
Marc Gravell

@Marc对于显示的代码(在JIT运行之后),不再进行边界检查,因为对于编译器来说,所有访问都是入站的是很简单的。总的来说,这当然可以有所作为。
Voo 2014年

6

使用JIT和GC的微基准测试语言(例如Java或C#)可能有点复杂,因此使用现有框架通常是个好主意-Java提供了出色的mhf或Caliper,可惜的是,据我所知,C#没有提供任何接近那些。乔恩·斯基特(Jon Skeet)在这里写下了这篇文章,我会盲目假设他会处理最重要的事情(乔恩知道他在该领域的工作;也是的,我实际上没有担心过)。我稍微调整了时间,因为预热后每次测试30秒对我的耐心来说太长了(应该做5秒)。

因此,首先得出的结果是Windows 7 x64下的.NET 4.5.1-数字表示它可以在5秒钟内运行的迭代,所以越高越好。

x64 JIT:

Standard       10,589.00  (1.00)
UnsafeStandard 10,612.00  (1.00)
Stackalloc     12,088.00  (1.14)
FixedStandard  10,715.00  (1.01)
GlobalAlloc    12,547.00  (1.18)

x86 JIT(是的,这仍然让人很难过):

Standard       14,787.00   (1.02)
UnsafeStandard 14,549.00   (1.00)
Stackalloc     15,830.00   (1.09)
FixedStandard  14,824.00   (1.02)
GlobalAlloc    18,744.00   (1.29)

这样最多可以实现14%的合理加速(并且大部分开销是由于必须运行GC,实际上将其视为最坏的情况)。不过x86的结果很有趣-尚不完全清楚发生了什么。

这是代码:

public static float Standard(int size) {
    float[] samples = new float[size];
    for (var ii = 0; ii < size; ii++) {
        samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
    }
    return samples[size - 1];
}

public static unsafe float UnsafeStandard(int size) {
    float[] samples = new float[size];
    for (var ii = 0; ii < size; ii++) {
        samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
    }
    return samples[size - 1];
}

public static unsafe float Stackalloc(int size) {
    float* samples = stackalloc float[size];
    for (var ii = 0; ii < size; ii++) {
        samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
    }
    return samples[size - 1];
}

public static unsafe float FixedStandard(int size) {
    float[] prev = new float[size];
    fixed (float* samples = &prev[0]) {
        for (var ii = 0; ii < size; ii++) {
            samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
        }
        return samples[size - 1];
    }
}

public static unsafe float GlobalAlloc(int size) {
    var ptr = Marshal.AllocHGlobal(size * sizeof(float));
    try {
        float* samples = (float*)ptr;
        for (var ii = 0; ii < size; ii++) {
            samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
        }
        return samples[size - 1];
    } finally {
        Marshal.FreeHGlobal(ptr);
    }
}

static void Main(string[] args) {
    int inputSize = 100000;
    var results = TestSuite.Create("Tests", inputSize, Standard(inputSize)).
        Add(Standard).
        Add(UnsafeStandard).
        Add(Stackalloc).
        Add(FixedStandard).
        Add(GlobalAlloc).
        RunTests();
    results.Display(ResultColumns.NameAndIterations);
}

一个有趣的发现,我将不得不再次检查基准。尽管这仍然不能真正回答我的问题,“ ...将堆栈增加到如此大的尺寸会带来什么危险... ”。即使我的结果不正确,该问题仍然有效。尽管如此,我还是很感激。
2014年

1
@Sam使用12500000大小时,我实际上得到了stackoverflow异常。但这主要是为了拒绝使用堆栈分配的代码快几个数量级的潜在前提。否则,我们正在做的工作量可能最少,而且差异已经只有10%到15%-实际上,差异甚至会更低..我认为这肯定会改变整个讨论范围。
Voo

5

由于性能差异太大,因此问题与分配几乎无关。这可能是由于数组访问引起的。

我反汇编了函数的循环主体:

TestMethod1:

IL_0011:  ldloc.0 
IL_0012:  ldloc.1 
IL_0013:  ldc.i4.4 
IL_0014:  mul 
IL_0015:  add 
IL_0016:  ldc.r4 32768.
IL_001b:  stind.r4 // <----------- This one
IL_001c:  ldloc.1 
IL_001d:  ldc.i4.1 
IL_001e:  add 
IL_001f:  stloc.1 
IL_0020:  ldloc.1 
IL_0021:  ldc.i4 12500000
IL_0026:  blt IL_0011

TestMethod2:

IL_0012:  ldloc.0 
IL_0013:  ldloc.1 
IL_0014:  ldc.r4 32768.
IL_0019:  stelem.r4 // <----------- This one
IL_001a:  ldloc.1 
IL_001b:  ldc.i4.1 
IL_001c:  add 
IL_001d:  stloc.1 
IL_001e:  ldloc.1 
IL_001f:  ldc.i4 12500000
IL_0024:  blt IL_0012

我们可以检查指令的用法,更重要的是,可以检查它们在ECMA规范中引发的异常:

stind.r4: Store value of type float32 into memory at address

它引发的异常:

System.NullReferenceException

stelem.r4: Replace array element at index with the float32 value on the stack.

它引发的异常:

System.NullReferenceException
System.IndexOutOfRangeException
System.ArrayTypeMismatchException

如您所见,stelem在数组范围检查和类型检查中还有更多工作要做。由于循环体无能为力(仅分配值),因此检查的开销支配了计算时间。这就是为什么性能相差530%的原因。

这也回答了您的问题:危险在于缺少数组范围和类型检查。这是不安全的(如功能声明; D中所述)。


4

编辑:(在代码和度量上的微小变化会在结果中产生很大的变化)

首先,我在调试器(F5)中运行了优化的代码,但这是错误的。它应在没有调试器(Ctrl + F5)的情况下运行。其次,可以对代码进行彻底的优化,因此我们必须使其复杂化,以使优化器不会干扰我们的测量。我使所有方法都返回了数组中的最后一项,并且以不同的方式填充了数组。另外,OP中的额外零TestMethod2总是使它变慢十倍。

除了您提供的两种方法外,我还尝试了其他方法。方法3与方法2的代码相同,但函数已声明unsafe。方法4使用对常规创建的数组的指针访问。如Marc Gravell所述,方法5使用对非托管内存的指针访问。所有这五种方法的运行时间都非常相似。M5是最快的(而M1紧随其后)。最快和最慢之间的差异约为5%,这不是我要关心的。

    public static unsafe float TestMethod3()
    {
        float[] samples = new float[5000000];

        for (var ii = 0; ii < 5000000; ii++)
        {
            samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
        }

        return samples[5000000 - 1];
    }

    public static unsafe float TestMethod4()
    {
        float[] prev = new float[5000000];
        fixed (float* samples = &prev[0])
        {
            for (var ii = 0; ii < 5000000; ii++)
            {
                samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
            }

            return samples[5000000 - 1];
        }
    }

    public static unsafe float TestMethod5()
    {
        var ptr = Marshal.AllocHGlobal(5000000 * sizeof(float));
        try
        {
            float* samples = (float*)ptr;

            for (var ii = 0; ii < 5000000; ii++)
            {
                samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
            }

            return samples[5000000 - 1];
        }
        finally
        {
            Marshal.FreeHGlobal(ptr);
        }
    }

那么M3与仅标记为“不安全”的M2相同吗?有点怀疑这会更快...您确定吗?
Roman Starkov

@romkyns我刚刚运行了一个基准测试(M2与M3),令人惊讶的是M3实际上比M2快2.14%。
2014年

结论是不需要使用堆栈。 ”当我分配我的帖子中给出的大块时,我同意,但是,在刚刚完成更多基准测试M1 vs M2(两种方法都使用PFM的思想)之后,我肯定会不得不不同意,因为M1现在比M2快135%。
山姆

1
@Sam但是您仍在将指针访问与数组访问进行比较!THAT是primarly什么使得它更快。TestMethod4vs TestMethod1是一个更好的比较stackalloc
Roman Starkov

@romkyns啊,是的,我忘了这一点。我重新运行了基准测试,现在只有8%的差异(M1是两者中的更快者)。
2014年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.