使用List<T>.Sort和IEnumerable加速算法

6
对于我的项目,我首先从文件中加载一张图片,并将每个像素放入一个二维数组pixels[,]中。然后,我想检查每个像素并根据它们的颜色将它们分成“箱子”,然后对每个箱子进行排序。因此,我有一个Bin对象,它封装了一个List<Pixel>,我还有一个包含(相对较少的)若干个箱子的List<Bin>
我的问题是,当我尝试从非常大的图像(例如1920x1200 = 230万像素)中填充这些箱子时,我正在使用的算法比我预期的要慢得多,而且我已经追踪到了一些C#语言特定的功能,它们似乎比我预期的要花费更长的时间。我想请教如何更好地使用C#来消除这些瓶颈。
在加载图像后,我调用一个名为“fillBinsFromSource”的函数,该函数接收一个可枚举的像素列表,找出它们属于哪个箱子并将它们放入相应的箱子中:
public void fillBinsFromSource(IEnumerable<Pixel> source)
    {
        Stopwatch s = new Stopwatch();
        foreach (Pixel p in source)
        {
            s.Start();
            // algorithm removed for brevity
            // involves a binary search of all Bins and a List.Add call
            s.Stop();
        }           
    }

对于大尺寸的图片,预计我的算法会需要一些时间。但当我将一个秒表放在函数调用外面时,结果显示它花费的时间是代码中累计的时间的两倍左右。这意味着使用foreach进行枚举占用了该函数时间的一半(对于1920x1200的图像,此函数总共需要1.6秒,而其中大约有800毫秒是foreach操作占用的时间)。
我需要传递一个可枚举列表是因为有时用户只会添加图片的一小部分,而不是整个图片。这个耗时的调用会从一个图片列表中传递下来多个迭代器,然后从列表中的每个图像中依次传递迭代器,就像这样(简化版):
class ImageList : IEnumerable<Pixel>
{
    private List<Image> imageList;
    public IEnumerator<Pixel> GetEnumerator()
    {
        foreach (Image i in imageList)
            foreach (Pixel p in i)
                yield return p;
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    } 

class Image : IEnumerable<Pixel>
{
    private Pixel[,] pixels; // all pixels in the image        
    private List<Pixel> selectedPixels;// all pixels in the user's selection

    public IEnumerator<Pixel> GetEnumerator()
    {
        if (selectedPixels == null)
            for (int i = 0; i < image.Width; i++)
                for (int j = 0; j < image.Height; j++)
                    yield return pixels[i, j];
        else
            for (int i = 0; i < selectedPixels.Count; i++)
                yield return selectedPixels[i];
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }

然后最后我调用这个。
ImageList list; // pretend it contains only 1 image, and it's large
fillBinsFromSource(list);

问题1)由于需要枚举像素的2D数组和选择的区域,根据用户的选择不同,枚举速度非常慢。如何加快速度?

然后,在填充所有这些箱子与Pixel对象之后,我会对它们进行排序。我调用List<Pixel>.Sort()并依赖于IComparable,如下所示:

ImageList list; // pretend it contains only 1 image, and it's large
fillBinsFromSource(list);
foreach(Bin b in allBins)
    b.Sort(); // calls List<Pixel>.Sort()


class Pixel : IComparable
{
    // store both HSV and RGB
    float h, s, v;
    byte r, g, b;

    // we sort by HSV's value property
    public int CompareTo(object obj)
    {
        // this is much faster than calling CompareTo on a float
        Pixel rhs = obj as Pixel;
        if (v < rhs.v)
            return -1;
        else if (v > rhs.v)
            return 1;
        return 0;
    }

问题2)假设allBins有7个元素; 对其中包含总共230万个Pixel的7个不同列表进行排序需要大约2秒钟。对一个包含230万个随机int的列表进行排序只需要不到200毫秒。我可以理解使用基本数据类型会有一定程度的加速,但使用IComparable真的会慢10倍以上吗?在这里是否有任何加速的方法?
对于问题的长度,我深表歉意,如果有人能给我一些建议,我将不胜感激!

1
我想到的一个方法是定义一个委托或回调方法,然后在fillBinsFromSource中不再迭代像素,而是在Image类中执行基本循环并使用回调。从设计角度来看,这似乎特别(且不必要)混乱,但我将避免使用任何迭代器。如果可能的话,我希望避免破坏我的设计。 - XenoScholar
请注意,当遍历枚举时,如果枚举源自查询表达式(如Linq查询),则查询表达式将为每个迭代中的每个项执行。如果查询表达式很昂贵,它将影响迭代。您可以使用.ToList()避免表达式成本,但这样会使内存使用量翻倍。对于小列表来说,这是有利的,但对于230万个项目,内存使用可能会受到限制。 - dthorpe
同样地,如果像素枚举来自使用GetPixel(x,y)从位图中提取像素的查询,迭代器的性能将受到GetPixel(x,y)可怕缓慢性能的限制。永远不要使用GetPixel。 - dthorpe
如果迭代器本身消耗了大量的执行时间(除了通过分析之外没有其他方法可以知道这一点),请考虑将枚举从像素的枚举更改为扫描线的枚举 - 连续像素的组。这应该会减少枚举中的项目数量和大多数情况下至少一个数量级的迭代次数。您的核心循环将从枚举中获取每个扫描线,并在连续像素上运行紧密的代码循环。 - dthorpe
是的,在这个项目之前我从未使用过Bitmap类,并错误地认为GetPixel已经被很好地实现了。在最近实现涉及LockBits的修复之前,仅仅将图像加载到初始像素数组中就需要大约5秒钟的时间。幸运的是,这个问题已经得到解决,图像的加载与上面的代码是分开的。 - XenoScholar
显示剩余3条评论
3个回答

3

您需要对代码进行剖析,查看哪些地方运行缓慢。

最明显的问题:

  • Pixel 没有实现通用的 IComparable<Pixel> 接口,因此 Compare 函数需要做更多的工作。
  • 尝试 将 Pixel 定义为值类型。很可能会导致性能下降,但也可能不会。
  • 如果发现性能低于可接受水平,请考虑传递 2D 范围(矩形)并直接通过索引访问 Pixel 对象,而不是使用迭代器。迭代器很好,但不是免费的。

作为一个相对新手的C#程序员,我之前并不知道有IComparable<T>这个接口。但是现在查了一下,感觉很有意义。将Pixel类实现IComparable<T>接口后,排序时间从2秒缩短到了1.3秒。虽然仍然比int类型慢7倍(我还是很难相信使用IComparable会有这么大的代价),但这确实是一个进步,所以谢谢你!我会进一步研究你提出的值类型建议。 - XenoScholar
再次强调,我不知道在C#中“struct”和“class”之间存在如此根本的区别。将Pixel更改为结构体(值类型)会对我的一些其他代码造成一些问题(这些问题巧合地将在我的主要帖子上通过委托评论得到解决),但仅仅将Pixel更改为结构体可以进一步提高速度,降至约1秒。与“IComparable<Pixel>”一起使用,我将排序时间减少了一半!是否有进一步的建议来进一步降低使用“IComparable”的成本? - XenoScholar
@XenoScholar,顺便提一下:请确保搜索并阅读有关“c# struct vs class differences”的内容(即来自https://dev59.com/7mkw5IYBdhLWcg3wZJp9的链接)-如果您没有准备好,值类型通常会表现出令人惊讶的行为。避免创建可变值类型可以节省您一些调试时间:)。 - Alexei Levenkov

2
各种间接引用,如访问者模式或虚拟继承,如果您想要原始的金属性能,则是有害的。虚拟调用、分配、不可预测的分支对于那种小而紧密的内部循环算法造成了很大的损害,其中99.99%的时间都花在了这里。
为什么呢?因为CPU喜欢并行执行许多(几十个)指令。只有在能够预先查看指令流的情况下才能做到这一点。上述内容会阻止这种情况的发生。
你真的需要把最内层的循环处理好。不要在那里分配内存,也不要调用虚拟函数(或接口方法或委托)。
可能,您的最内层循环应该使用硬编码内核处理给定图像的矩形。而不是针对每个像素实现处理函数,应该按矩形实现它。
相比之下,您提供图像流的方式无关紧要。在那里使用LINQ想用多少就用多少。这是一个低容量操作,因为每个图像都有数百万个像素。

或者对于非矩形区域,考虑将连续像素的运行分组成扫描线,而不是枚举散布在各处的单个像素。 - dthorpe
我想我要稍微重构一下我的变量。我到处使用IEnumerable的原因是因为我将像素存储为2D数组,而selectedPixels则作为List<T>。当我想要进行大量循环时,我应该考虑编写将它们都转换为1D数组的函数,然后执行for(int i=0; i < image.IterableList.Length; i++)或类似的操作。我还应该重新考虑如何存储这些值...可能可以将像素存储为1D数组,并在非迭代代码中提供重载的2D索引器,以便在需要按2D访问时使用。 - XenoScholar
是的,2D数组速度较慢,因为它们总是执行边界检查。您可能还想切换到不安全代码以用于算法的核心部分。这可能非常值得。 - usr

1

不必使用迭代器,甚至不必一开始就构建像素数组/列表,您可以使用访问者模式。图像、图像列表和其他表示任意选择的对象都可以接受一个带有单个方法VisitPixel的访问者类,并为对象表示的每个像素调用该方法。然后,访问者类将负责在访问时对所有像素进行分组。

这可能消除了将所有像素提取到单独的数组中的需要。它还可能消除了创建迭代器,而是采用简单的for循环。

Alexei Levenkov在关于您的第二个问题方面提出了一些好的观点。使用接受IComparer<T>实例的Sortoverload可能会更快。


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