也谈谈性能:局部性与性能的实验观察
同样的算法,为什么会有数量级的性能差异?问题起源于几个月前与一位网友的探讨。这位网友在写婚纱抠图程序。一般来说,婚纱摄影的图像都较大,甚至大至几千万像素。这位网友使用C#开发,他的问题就在于性能。当时建议他使用XNA开发,但问题又出来了:加载一副图像的时间竟需要好几秒!而我自己写的C#加载几千万像素图像及图像转换操作,都是瞬间完成。为什么会有如此大的差别呢?这就是本文要说的。
问题主要处在程序的局部性和缓存命中上。我们把图像类抽象一下:
Bitmap
{
Width,Height;
Data;
}
一般来说,它在内存中被分为两块存放:
程序会分配一大块内存,存储具体的图像数据,然后再为Bitmap分配小块内存,储存Width、Height及对图像数据的引用。
这样一来:对于图像的操作的两种写法,性能上就会存在差异:
写法A:
for(int y = 0; y<xxx.Height; y++)
{
for(int x = 0; x < xxx.Width; x++)
{
Data[x,y] = ....
}
}
写法B:
int width = xxx.Width;
int height = xxx.Height;
for(int y = 0; y<height ; y++)
{
for(int x = 0; x <width ; x++)
{
Data[x,y] = ....
}
}
写法B中,使用的是在栈中的变量width和height,因此较写法A具有更好的局部性。下面,用实验测试下这两种写法究竟有多大的性能差异。
====
实验1:对托管内存中3000万像素图像的赋值操作
public class Image
{
public int Width { get; set; }
public int Height { get; set; }
}public class Bitmap : Image
{
public int[] Data;public Bitmap(int width, int height)
{
this.Width = width;
this.Height = height;
Data = new int[width*height];
}public void Fill(int value)
{
int height = Height;
int width = Width;for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
Data[y * width + x] = value;
}
}
}public void FillEx(int value)
{
for (int y = 0; y < Height; y++)
{
for (int x = 0; x < Width; x++)
{
Data[y * Width + x] = value;
}
}
}public static void Test()
{
Bitmap img = new Bitmap(5000, 6000);
CodeTimer.Time("Fill", 1, () => { img.Fill(1); });
CodeTimer.Time("FillEx", 1, () => { img.FillEx(2); });
CodeTimer.Time("Fill", 1, () => { img.Fill(1); });
CodeTimer.Time("FillEx", 1, () => { img.FillEx(2); });
CodeTimer.Time("Fill", 1, () => { img.Fill(1); });
CodeTimer.Time("FillEx", 1, () => { img.FillEx(2); });
CodeTimer.Time("Fill", 1, () => { img.Fill(1); });
CodeTimer.Time("FillEx", 1, () => { img.FillEx(2); });
}
}
结果见下表(单位ms)。
1 2 3 4 Fill 126(82) 83 84 85 FillEx 100(141) 99 100 99
“1”列结果中括号数据是将Fill和FillEx执行顺序互换后的测试结果。可以发现,同一个类中,首次执行的方法吃点亏。对于这个测试来说,吃 40 ms 的亏。比较2,3,4可以看出,FillEx要比Fill慢一点。
====
实验2:对非托管内存中3000万像素图像的赋值操作
public class UnmanagedBitmap : Image
{
public IntPtr Data;public UnmanagedBitmap(int width, int height)
{
this.Width = width;
this.Height = height;
Data = Marshal.AllocHGlobal(sizeof(int) * width * height);
}public unsafe void Fill(int value)
{
int height = Height;
int width = Width;
int* p = (int*)Data;for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
*p = value;
p++;
}
}
}public unsafe void FillEx(int value)
{
int* p = (int*)Data;
for (int y = 0; y < Height; y++)
{
for (int x = 0; x < Width; x++)
{
*p = value;
p++;
}
}
}public static void Test()
{
UnmanagedBitmap img = new UnmanagedBitmap(5000, 6000);
CodeTimer.Time("Fill", 1, () => { img.Fill(1); });
CodeTimer.Time("FillEx", 1, () => { img.FillEx(2); });
CodeTimer.Time("Fill", 1, () => { img.Fill(1); });
CodeTimer.Time("FillEx", 1, () => { img.FillEx(2); });
CodeTimer.Time("Fill", 1, () => { img.Fill(1); });
CodeTimer.Time("FillEx", 1, () => { img.FillEx(2); });
CodeTimer.Time("Fill", 1, () => { img.Fill(1); });
CodeTimer.Time("FillEx", 1, () => { img.FillEx(2); });
}
}
测试结果:
1 2 3 4 Fill 128(83) 93 84 84 FillEx 88(123) 90 84 84
可看出,Fill和FillEx几乎没有差别。
====
实验1中,FillEx的循环为:
for (int y = 0; y < Height; y++)
{
for (int x = 0; x < Width; x++)
{
Data[y * Width + x] = value;
}
}
实验2中,FillEx的循环为:
public unsafe void FillEx(int value)
{
int* p = (int*)Data;
for (int y = 0; y < Height; y++)
{
for (int x = 0; x < Width; x++)
{
*p = value;
p++;
}
}
}
对比这两段代码,参照实验结果可以看出,在for循环条件中的Width和Height属性,jit做了特殊的处理,应该是缓存起来了,因此,实验2中的Fill和FillEx才没出现性能差别。而在循环体中所使用的Width属性,没有缓存起来,导致实验1中,Fill和FillEx性能有可见的差异。
尽管如此,实验1所显示的两种写法的性能差别并不大。下面,请看实验3。
====
实验3:两种写法产生数量级性能差异的实验
本实验中所使用的核心类见《发布我的高性能纯C#图像处理基本类,顺便也挑战一下极限。:)》。具体代码可在 http://smartimage.googlecode.com/svn/trunk/ 下载。
测试的两个方法如下,两个方法的不同之处我用红色显著标示出来:
public unsafe void ToBitmap(Bitmap map)
{
if (map == null) throw new ArgumentNullException("map");
if (map.Width != this.Width || map.Height != this.Height)
{
throw new ArgumentException("尺寸不匹配.");
}if (map.PixelFormat != PixelFormat.Format32bppArgb)
{
throw new ArgumentException("只支持 Format32bppArgb 格式。 ");
}Int32 step = SizeOfT();
Byte* t = (Byte*)StartIntPtr;BitmapData data = map.LockBits(new Rectangle(0, 0, map.Width, map.Height), ImageLockMode.ReadWrite, map.PixelFormat);
try
{
int width = map.Width;
int height = map.Height;Byte* line = (Byte*)data.Scan0;
for (int h = 0; h < height; h++)
{
Argb32* c = (Argb32*)line;
for (int w = 0; w < width; w++)
{
m_converter.Copy(t, c);
t += step;
c++;
}
line += data.Stride;
}
}
finally
{
map.UnlockBits(data);
}
}public unsafe void ToBitmapEx(Bitmap map)
{
if (map == null) throw new ArgumentNullException("map");
if (map.Width != this.Width || map.Height != this.Height)
{
throw new ArgumentException("尺寸不匹配.");
}if (map.PixelFormat != PixelFormat.Format32bppArgb)
{
throw new ArgumentException("只支持 Format32bppArgb 格式。 ");
}Int32 step = SizeOfT();
Byte* t = (Byte*)StartIntPtr;BitmapData data = map.LockBits(new Rectangle(0, 0, map.Width, map.Height), ImageLockMode.ReadWrite, map.PixelFormat);
try
{
Byte* line = (Byte*)data.Scan0;for (int h = 0; h < map.Height; h++)
{
Argb32* c = (Argb32*)line;
for (int w = 0; w < map.Width; w++)
{
m_converter.Copy(t, c);
t += step;
c++;
}
line += data.Stride;
}
}
finally
{
map.UnlockBits(data);
}
}
测试代码:
public static void Test()
{
ImageArgb32 src = new ImageArgb32(5000, 6000);
System.Drawing.Bitmap dst = new System.Drawing.Bitmap(5000, 6000, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
CodeTimer.Time("ToBitmap",1,()=>{ src.ToBitmap(dst);});
CodeTimer.Time("ToBitmapEx", 1, () => { src.ToBitmapEx(dst); });
CodeTimer.Time("ToBitmap", 1, () => { src.ToBitmap(dst); });
CodeTimer.Time("ToBitmapEx", 1, () => { src.ToBitmapEx(dst); });
CodeTimer.Time("ToBitmap", 1, () => { src.ToBitmap(dst); });
CodeTimer.Time("ToBitmapEx", 1, () => { src.ToBitmapEx(dst); });
CodeTimer.Time("ToBitmap", 1, () => { src.ToBitmap(dst); });
CodeTimer.Time("ToBitmapEx", 1, () => { src.ToBitmapEx(dst); });
}
测试结果:
1 2 3 4 ToBitmap 354 259 261 260 ToBitmapEx 7451 7441 7440 7445
由于区别实在太显著,我就没有交换执行次序重复实验了。从结果看出,ToBitmap的写法比ToBitmapEx要快近30倍。到这里可以知道,为什么我在前面提到的那个哥们写的程序加载图像耗时数秒?
下面,我们对ToBitmapEx做一个小小的变动,变动内容标红:
public unsafe void ToBitmapEx(Bitmap map)
{
if (map == null) throw new ArgumentNullException("map");
if (map.Width != this.Width || map.Height != this.Height)
{
throw new ArgumentException("尺寸不匹配.");
}if (map.PixelFormat != PixelFormat.Format32bppArgb)
{
throw new ArgumentException("只支持 Format32bppArgb 格式。 ");
}Int32 step = SizeOfT();
Byte* t = (Byte*)StartIntPtr;BitmapData data = map.LockBits(new Rectangle(0, 0, map.Width, map.Height), ImageLockMode.ReadWrite, map.PixelFormat);
try
{
Byte* line = (Byte*)data.Scan0;
int width = map.Width;
for (int h = 0; h < map.Height; h++)
{
Argb32* c = (Argb32*)line;
for (int w = 0; w < width; w++)
{
m_converter.Copy(t, c);
t += step;
c++;
}
line += data.Stride;
}
}
finally
{
map.UnlockBits(data);
}
}
测试结果:
1 2 3 4 ToBitmap 313(263) 261 261 260 ToBitmapEx 268(313) 261 264 261
可以看出两者结果已经几乎一样了。
====
总结:
(1)部分情况(实验2),jit可以对程序的局部性做完全优化。
(2)部分情况(实验1),jit可以对程序的局部性做部分优化。
(3)部分情况(实验3),jit对程序的局部性不做优化。
编译优化的原则比较保守,它首先需要保证正确性。比如下面这段代码:
for(int i=0; i< xxx.Width; i++)
{
xxx.Width = 3;
}
jit就不能简单优化成:
int w = xxx.Width;
for(int i=0; i< w; i++)
{
xxx.Width = 3;
}
而实际情况可能比这种情况更复杂,jit优化会非常谨慎,很难达到最优效果。在写高性能程序时,不应该依托于jit的优化。对于实验3这种情况,直接把需要使用的分散在内存中各处的数据缓存到栈中即可。
====
多说几句。C#程序中出现的性能问题一般来说和语言与底层机制关系不大。UI性能低下的根源应该在于过度封装,如果有第三方轻量级UI库,那性能肯定是棒棒的。其它方面的性能问题主要还是和设计有关。C#程序和C/C++的性能差异最主要的区别是关注点不一样,C/C++的公用的库设计的一个很重要的目标就是性能,而C#目前的主要的库在设计时偏好是其它方面,C#程序员在写程序时偏好也是其它方面。一个良好设计的C#程序的性能应该不低于C/C++程序的50%。对于复杂程序,由于C/C++的设计复杂度较高,在同等时间内,C#程序的设计应该优秀于C/C++程序,因此在性能上应该达到C/C++的70%才对。
《大唐双龙传》中,寇仲、徐子陵的井中月至境,师仙子的剑心通明,石之轩的入微,伏难陀的梵我不二(tmd黄易这个老小子竟然没给我家可爱的婠婠的天魔功十八层取一个类似井中月至境、剑心通明、入微、梵我不二这样响亮的名字!),都是相通的。