并行循环跟踪结果和性能

时间:2017-05-02 21:18:00

标签: c# asp.net-mvc concurrency parallel-processing

我有一个.Net Core MVC应用程序,可以进行一些中等重度的可计算性计算。我在~250万个条目的列表上运行并行循环。由于它是一个并行循环,我使用并发包来保存结果对象。对于每次迭代,我然后在我的并发包中找到该条目并迭代该值,基本上计算结果发生的次数。以下是正在发生的事情的简要示例:

// results class
public class RandResult
{
    public int id { get; set; }
    public int val { get; set; }        
}

// list of ints I iterate over
var intList = new List<int>();            
for(var i = 0; i < 2500000; i++)
{
    intList.Add(i);   
}

var bagResult = new ConcurrentBag<RandResult>()
{
    new RandResult() { id = 0, val = 0 },
    new RandResult() { id = 1, val = 0 },
    new RandResult() { id = 2, val = 0 },
    new RandResult() { id = 3, val = 0 },
    new RandResult() { id = 4, val = 0 }
};

watch.Restart();
Parallel.ForEach(intList, i =>
{
    bagResult.First(b => b.id == i % 5).val++;
});
timers.Add(watch.ElapsedMilliseconds / 1000.0);  // ~1.3 seconds

您可以看到我在代码中放置的计时器有助于评估速度。即使这里使用这个简单的计算,该循环也需要大约1.3秒,几乎完全是由于并发包的开销。鉴于这种相对低效率,我正在寻找替代方案。这是我到目前为止所尝试的:

使用常规List<RandResult>和锁定:

// takes ~0.6sec
var _lock = new object();
Parallel.ForEach(intList, i =>
{
    lock (_lock)
    {
        listResult.First(b => b.id == i % 5).val++;
    }
 });

使用Interlock有点复杂

// takes ~0.2sec
var dict = new Dictionary<int, int>()
{
    { 0, 0 },{ 1, 1 },{ 2, 2 },{ 3, 3 },{ 4, 4 }
};
int[] indexes = new int[5] { 0, 1, 2, 3, 4 };
int[] vals= new int[5] { 0, 0, 0, 0, 0 };
Parallel.ForEach(intList, i =>
{
    dict.TryGetValue(i % 5, out int k);    
    Interlocked.Increment(ref vals[k]);
});

这个更复杂,因为Id值不是连续的int,因此Dictionary用作反向查找。

问题是,还有其他选择吗?

注意:
正在进行的实际计算肯定比i%5更复杂,但这里真正的问题是关于记录结果以便用于示例。此外,即使在完整的应用程序中,RandResult的列表/包中也不会有超过10个条目。

奖金问题:我对ConcurrentBag选项比其他所有选项都慢得多感到有点震惊。我知道并行性和并发性总体上涉及相当多的开销,但这似乎过多。有谁知道它为什么这么慢?

2 个答案:

答案 0 :(得分:1)

通过切换到ConcurrentDictionary,您可以使用它的AddOrUpdate函数来获得有效的查找和线程安全增量。

var dict = new ConcurrentDictionary<int, int>();

Parallel.ForEach(intList, i =>
{
    dict.AddOrUpdate(GiveSomeInt(i), 1, (key, value) => value++);
});

第一次尝试访问索引时,它会添加一个新值1,以后对索引的任何调用都将返回old value + 1。如果两个线程尝试同时更新该值,则当尝试保存其值的两个更新中较慢的一个然后将1添加到新的更新值时,将重新运行值工厂函数。

如果您想预先初始化字典,也可以

var dict = new ConcurrentDictionary<int, int>()
{
    { 0, 0 },{ 1, 0 },{ 2, 0 },{ 3, 0 },{ 4, 0 }
};

Parallel.ForEach(intList, i =>
{
    dict.AddOrUpdate(GiveSomeInt(i), 1, (key, value) => value++);
});

要回答您的红利问题,ConcurrentBag根本没有经过优化而经常被枚举,每当您拨打bagResult.GetEnumerator().First(在幕后做)时,它必须克隆包并生成冻结时间快照。它经过优化,可以将内容推送到一个项目池中。使用.First(会影响您的表现。

答案 1 :(得分:0)

不确定为什么要使用并发包。这不像是你要添加或删除项目。而且我认为它不会为你解决任何并行问题 - 包给你的唯一东西是线程安全访问包,而不是线程安全访问包内的RandResult项。

如果是我,我会使用一个简单的字典,密钥为id。或者,如果id始终是顺序整数,请使用数组。那会更快。

至于并发问题 - 您需要做的只是使用Interlocked.Increment而不是val++。这将为您提供足够的线程安全性来解决此特定问题。您根本不需要同步对bag / list / dictionary / array的访问,因为所有线程仅使用与该对象相关的只读访问权限。根据您的平台,Interlocked.Increment根本不会产生任何开销,因为在许多情况下增量是自动原子的 - 它们在使用当前CLR的Windows系统上可能是99%自动原子。

var results = new int[5];

var intList = new List<int>();            
for(var i = 0; i < 2500000; i++)
{
    intList.Add(i);   
}

watch.Restart();
Parallel.ForEach(intList, i =>
{
    Interlocked.Increment(ref results[i % 5]);
});
timers.Add(watch.ElapsedMilliseconds / 1000.0);  // ~1.3 seconds

其他性能说明:由于结果列表中的元素在内存中非常接近,因此可能会导致CPU缓存争用。通常,您的CPU将使用缓存突发将小块内存移动到L1或L2缓存(每个核心单独);在缓存时,将锁定对主内存板上的那些内存位置的访问。因此,如果他们正在处理彼此相距一定距离(“缓存线”)的内存部分,那么基本上所有内核都会相互锁定。这可能导致性能太差,甚至比串行运行算法更慢。此问题称为“虚假共享”。

为避免此问题,您可能希望填充结果列表中的项目,使其足够大,以超过缓存突发大小(取决于CPU)。由于数组只包含10个项目,因此您可以使用128字节的虚拟块来填充它们,而不会产生很多开销。

有关此问题的详情,请参阅this article