我在多线程应用程序中看到了一些奇怪的行为,我写的这个行为在多个内核中都没有很好的扩展性。
以下代码说明了我看到的行为。堆密集型操作似乎不会跨多个核心扩展,而是看起来速度变慢。即使用单个线程会更快。
class Program
{
public static Data _threadOneData = new Data();
public static Data _threadTwoData = new Data();
public static Data _threadThreeData = new Data();
public static Data _threadFourData = new Data();
static void Main(string[] args)
{
// Do heap intensive tests
var start = DateTime.Now;
RunOneThread(WorkerUsingHeap);
var finish = DateTime.Now;
var timeLapse = finish - start;
Console.WriteLine("One thread using heap: " + timeLapse);
start = DateTime.Now;
RunFourThreads(WorkerUsingHeap);
finish = DateTime.Now;
timeLapse = finish - start;
Console.WriteLine("Four threads using heap: " + timeLapse);
// Do stack intensive tests
start = DateTime.Now;
RunOneThread(WorkerUsingStack);
finish = DateTime.Now;
timeLapse = finish - start;
Console.WriteLine("One thread using stack: " + timeLapse);
start = DateTime.Now;
RunFourThreads(WorkerUsingStack);
finish = DateTime.Now;
timeLapse = finish - start;
Console.WriteLine("Four threads using stack: " + timeLapse);
Console.ReadLine();
}
public static void RunOneThread(ParameterizedThreadStart worker)
{
var threadOne = new Thread(worker);
threadOne.Start(_threadOneData);
threadOne.Join();
}
public static void RunFourThreads(ParameterizedThreadStart worker)
{
var threadOne = new Thread(worker);
threadOne.Start(_threadOneData);
var threadTwo = new Thread(worker);
threadTwo.Start(_threadTwoData);
var threadThree = new Thread(worker);
threadThree.Start(_threadThreeData);
var threadFour = new Thread(worker);
threadFour.Start(_threadFourData);
threadOne.Join();
threadTwo.Join();
threadThree.Join();
threadFour.Join();
}
static void WorkerUsingHeap(object state)
{
var data = state as Data;
for (int count = 0; count < 100000000; count++)
{
var property = data.Property;
data.Property = property + 1;
}
}
static void WorkerUsingStack(object state)
{
var data = state as Data;
double dataOnStack = data.Property;
for (int count = 0; count < 100000000; count++)
{
dataOnStack++;
}
data.Property = dataOnStack;
}
public class Data
{
public double Property
{
get;
set;
}
}
}
此代码在Core 2 Quad(4核心系统)上运行,结果如下:
使用堆的一个线程:00:00:01.8125000
使用堆的四个线程:00:00:17.7500000
使用堆栈的一个线程:00:00:00.3437500
使用堆栈的四个线程:00:00:00.3750000
因此,使用具有四个线程的堆执行了4倍的工作但是花费了近10倍的时间。这意味着在这种情况下,只使用一个线程的速度会快两倍??????
使用堆栈远远超出预期。
我想知道这里发生了什么。堆只能一次从一个线程写入吗?
答案 0 :(得分:13)
答案很简单 - 在Visual Studio之外运行......
我刚刚复制了整个程序,并在我的四核系统上运行它。
内部VS(发布版本):
One thread using heap: 00:00:03.2206779
Four threads using heap: 00:00:23.1476850
One thread using stack: 00:00:00.3779622
Four threads using stack: 00:00:00.5219478
外部VS(发布版本):
One thread using heap: 00:00:00.3899610
Four threads using heap: 00:00:00.4689531
One thread using stack: 00:00:00.1359864
Four threads using stack: 00:00:00.1409859
注意区别。 VS外部构建的额外时间几乎都是由于启动线程的开销。在这种情况下你的工作太小而无法真正测试,而且你没有使用高性能计数器,因此它不是一个完美的测试。
主要经验法则 - 总是做到性能。在VS外测试,即:使用Ctrl + F5而不是F5来运行。
答案 1 :(得分:3)
除了调试与释放效果之外,还有一些你应该注意的事情。
您无法在0.3秒内有效评估多线程代码的性能。
线程的重点是双重的:有效地模拟代码中的并行工作,并有效地利用并行资源(cpus,cores)。
你正试图评估后者。鉴于线程启动开销与您计时的时间间隔相比并不是很小,您的测量结果立即被怀疑。在大多数性能测试试验中,显着的预热间隔是合适的。这对你来说可能听起来很愚蠢 - 这是一个计算机程序,而不是割草机。但是,如果您真的要评估多线程性能,那么热身是绝对必要的。缓存填满,管道填满,池填满,GC代填满。您希望评估稳态持续性能。出于本练习的目的,该程序的行为类似于割草机。
你可以说 - 嗯,不,我不想评估稳态性能。如果是这样,那么我会说你的场景非常专业。大多数应用场景,无论他们的设计者是否明确意识到,都需要持续,稳定的性能。
如果你真的只需要在0.3秒的时间间隔内获得好的表现,你就找到了自己的答案。但要小心不要概括结果。
如果您想要一般结果,则需要有相当长的预热间隔和更长的采集间隔。对于这些阶段,您可能从20秒/ 60秒开始,但这是关键的事情:您需要改变这些间隔,直到您发现结果收敛为止。因人而异。显然,有效时间取决于应用程序工作负载和专用资源。你可能会发现收敛需要120s的测量间隔,或者你可能会发现40s就好了。但是(a)在你测量它之前你不会知道,并且(b)你可以下注0.3s不够长。
答案 2 :(得分:2)
[edit]事实证明,这是一个发布与调试构建问题 - 不确定它为什么,但确实如此。见评论和其他答案。[/ edit]
这非常有趣 - 我不会猜到会有那么大的差异。 (类似的测试机器 - Core 2 Quad Q9300)
这是一个有趣的比较 - 在'数据'类中添加一个体面大小的附加元素 - 我将其更改为:
public class Data
{
public double Property { get; set; }
public byte[] Spacer = new byte[8096];
}
它仍然不是完全相同的时间,但它非常接近(运行10倍,结果为13.1秒,而我的机器上则为17.6秒)。
如果我不得不猜测,我推测它与跨核心缓存一致性有关,至少如果我记得CPU缓存是如何工作的话。对于'Data'的小版本,如果单个缓存行包含多个Data实例,则核心必须不断地使彼此的缓存无效(最坏的情况是它们都在同一缓存行上)。添加'spacer'后,它们的内存地址足够远,以至于一个CPU写入给定地址不会使其他CPU的高速缓存无效。
另外需要注意的是 - 4个线程几乎同时启动,但它们没有同时完成 - 另一个迹象表明存在交叉核心问题。另外,我猜想在不同架构的多CPU机器上运行会带来更多有趣的问题。
我想从这里得到的教训是,在一个高度并发的场景中,如果你正在做一些使用一些小数据结构的工作,你应该尽量确保它们不是全部打包在每个其他在记忆中。当然,实际上没有办法确保这一点,但我猜测有些技术(比如添加垫片)可以用来试图让它成为现实。
[编辑] 这太有趣了 - 我无法爱不释手。为了进一步测试这个,我想我会尝试不同大小的垫片,并使用整数而不是双精度来保持对象,而不增加任何间隔的间隔。
class Program
{
static void Main(string[] args)
{
Console.WriteLine("name\t1 thread\t4 threads");
RunTest("no spacer", WorkerUsingHeap, () => new Data());
var values = new int[] { -1, 0, 4, 8, 12, 16, 20 };
foreach (var sv in values)
{
var v = sv;
RunTest(string.Format(v == -1 ? "null spacer" : "{0}B spacer", v), WorkerUsingHeap, () => new DataWithSpacer(v));
}
Console.ReadLine();
}
public static void RunTest(string name, ParameterizedThreadStart worker, Func<object> fo)
{
var start = DateTime.UtcNow;
RunOneThread(worker, fo);
var middle = DateTime.UtcNow;
RunFourThreads(worker, fo);
var end = DateTime.UtcNow;
Console.WriteLine("{0}\t{1}\t{2}", name, middle-start, end-middle);
}
public static void RunOneThread(ParameterizedThreadStart worker, Func<object> fo)
{
var data = fo();
var threadOne = new Thread(worker);
threadOne.Start(data);
threadOne.Join();
}
public static void RunFourThreads(ParameterizedThreadStart worker, Func<object> fo)
{
var data1 = fo();
var data2 = fo();
var data3 = fo();
var data4 = fo();
var threadOne = new Thread(worker);
threadOne.Start(data1);
var threadTwo = new Thread(worker);
threadTwo.Start(data2);
var threadThree = new Thread(worker);
threadThree.Start(data3);
var threadFour = new Thread(worker);
threadFour.Start(data4);
threadOne.Join();
threadTwo.Join();
threadThree.Join();
threadFour.Join();
}
static void WorkerUsingHeap(object state)
{
var data = state as Data;
for (int count = 0; count < 500000000; count++)
{
var property = data.Property;
data.Property = property + 1;
}
}
public class Data
{
public int Property { get; set; }
}
public class DataWithSpacer : Data
{
public DataWithSpacer(int size) { Spacer = size == 0 ? null : new byte[size]; }
public byte[] Spacer;
}
}
结果:
1个线程与4个线程
无间隔=速度的1/6,零间隔=速度的1/5,0B间隔=速度的1/3,4B间隔=全速。
我不知道CLR如何分配或对齐对象的完整细节,所以我不能谈论这些分配模式在实际内存中的样子,但这些肯定是一些有趣的结果。