如何在代码中高效地旋转位图

19

有没有比使用倒置坐标的嵌套循环更快的方法来将大位图旋转90度或270度?

这些位图是8bpp的,通常为2048x2400x8bpp。

目前我只是通过复制参数的倒置来实现,大致如下(伪代码:

for x = 0 to 2048-1
  for y = 0 to 2048-1
    dest[x][y]=src[y][x];

(实际上我使用指针来完成,以获得更快的速度,但数量级大致相同)

GDI在处理大型图像时非常慢,而纹理的GPU加载/存储时间(GF7显卡)与当前CPU时间的数量级相同。

有什么提示或建议吗?一个原地算法甚至更好,但速度比原地更重要。

目标是Delphi,但这更是一个算法问题。 SSE(2)向量化没有问题,对我来说,这是足够大的问题,可以用汇编语言编写它。


回复 Nils 的答案

  • 图像 2048x2700 -> 2700x2048
  • 编译器 Turbo Explorer 2006 启用优化。
  • Windows:电源计划设置为“始终保持开启”。(重要!!!)
  • 机器:Core2 6600 (2.4 GHz)

time with old routine: 32ms (step 1)

time with stepsize 8 : 12ms

time with stepsize 16 : 10ms

time with stepsize 32+ : 9ms

Meanwhile I also tested on a Athlon 64 X2 (5200+ iirc), and the speed up there was slightly more than a factor four (80 to 19 ms).

The speed up is well worth it, thanks. Maybe that during the summer months I'll torture myself with a SSE(2) version. However I already thought about how to tackle that, and I think I'll run out of SSE2 registers for an straight implementation:

for n:=0 to 7 do
  begin
    load r0, <source+n*rowsize> 
    shift byte from r0 into r1
    shift byte from r0 into r2
    ..
    shift byte from r0 into r8
  end; 
store r1, <target>   
store r2, <target+1*<rowsize>
..
store r8, <target+7*<rowsize>   

So 8x8 needs 9 registers, but 32-bits SSE only has 8. Anyway that is something for the summer months :-)

Note that the pointer thing is something that I do out of instinct, but it could be there is actually something to it, if your dimensions are not hardcoded, the compiler can't turn the mul into a shift. While muls an sich are cheap nowadays, they also generate more register pressure afaik.

The code (validated by subtracting result from the "naieve" rotate1 implementation):

const stepsize = 32;
procedure rotatealign(Source: tbw8image; Target:tbw8image);

var stepsx,stepsy,restx,resty : Integer;
   RowPitchSource, RowPitchTarget : Integer;
   pSource, pTarget,ps1,ps2 : pchar;
   x,y,i,j: integer;
   rpstep : integer;
begin
  RowPitchSource := source.RowPitch;          // bytes to jump to next line. Can be negative (includes alignment)
  RowPitchTarget := target.RowPitch;        rpstep:=RowPitchTarget*stepsize;
  stepsx:=source.ImageWidth div stepsize;
  stepsy:=source.ImageHeight div stepsize;
  // check if mod 16=0 here for both dimensions, if so -> SSE2.
  for y := 0 to stepsy - 1 do
    begin
      psource:=source.GetImagePointer(0,y*stepsize);    // gets pointer to pixel x,y
      ptarget:=Target.GetImagePointer(target.imagewidth-(y+1)*stepsize,0);
      for x := 0 to stepsx - 1 do
        begin
          for i := 0 to stepsize - 1 do
            begin
              ps1:=@psource[rowpitchsource*i];   // ( 0,i)
              ps2:=@ptarget[stepsize-1-i];       //  (maxx-i,0);
              for j := 0 to stepsize - 1 do
               begin
                 ps2[0]:=ps1[j];
                 inc(ps2,RowPitchTarget);
               end;
            end;
          inc(psource,stepsize);
          inc(ptarget,rpstep);
        end;
    end;
  // 3 more areas to do, with dimensions
  // - stepsy*stepsize * restx        // right most column of restx width
  // - stepsx*stepsize * resty        // bottom row with resty height
  // - restx*resty                    // bottom-right rectangle.
  restx:=source.ImageWidth mod stepsize;   // typically zero because width is 
                                          // typically 1024 or 2048
  resty:=source.Imageheight mod stepsize;
  if restx>0 then
    begin
      // one loop less, since we know this fits in one line of  "blocks"
      psource:=source.GetImagePointer(source.ImageWidth-restx,0);    // gets pointer to pixel x,y
      ptarget:=Target.GetImagePointer(Target.imagewidth-stepsize,Target.imageheight-restx);
      for y := 0 to stepsy - 1 do
        begin
          for i := 0 to stepsize - 1 do
            begin
              ps1:=@psource[rowpitchsource*i];   // ( 0,i)
              ps2:=@ptarget[stepsize-1-i];       //  (maxx-i,0);
              for j := 0 to restx - 1 do
               begin
                 ps2[0]:=ps1[j];
                 inc(ps2,RowPitchTarget);
               end;
            end;
         inc(psource,stepsize*RowPitchSource);
         dec(ptarget,stepsize);
       end;
    end;
  if resty>0 then
    begin
      // one loop less, since we know this fits in one line of  "blocks"
      psource:=source.GetImagePointer(0,source.ImageHeight-resty);    // gets pointer to pixel x,y
      ptarget:=Target.GetImagePointer(0,0);
      for x := 0 to stepsx - 1 do
        begin
          for i := 0 to resty- 1 do
            begin
              ps1:=@psource[rowpitchsource*i];   // ( 0,i)
              ps2:=@ptarget[resty-1-i];       //  (maxx-i,0);
              for j := 0 to stepsize - 1 do
               begin
                 ps2[0]:=ps1[j];
                 inc(ps2,RowPitchTarget);
               end;
            end;
         inc(psource,stepsize);
         inc(ptarget,rpstep);
       end;
    end;
 if (resty>0) and (restx>0) then
    begin
      // another loop less, since only one block
      psource:=source.GetImagePointer(source.ImageWidth-restx,source.ImageHeight-resty);    // gets pointer to pixel x,y
      ptarget:=Target.GetImagePointer(0,target.ImageHeight-restx);
      for i := 0 to resty- 1 do
        begin
          ps1:=@psource[rowpitchsource*i];   // ( 0,i)
          ps2:=@ptarget[resty-1-i];       //  (maxx-i,0);
          for j := 0 to restx - 1 do
            begin
              ps2[0]:=ps1[j];
              inc(ps2,RowPitchTarget);
            end;
       end;
    end;
end;

Update 2 Generics

I tried to update this code to a generics version in Delphi XE. I failed because of QC 99703, and forum people have already confirmed it also exists in XE2. Please vote for it :-)

Update 3 Generics Works now in XE10

Update 4

In 2017 i did some work on a assembler version for 8x8 cubes of 8bpp images only and related SO question about shuffle bottlenecks where Peter Cordes generously helped me out. This code still has a missed oportunity and still needs another looptiling level again to aggregate multiple 8x8 block iterations into pseudo larger ones like 64x64. Now it is whole lines again and that is wasteful.

4个回答

22

是的,有更快的方式来完成这个任务。

你的简单循环大部分时间都在缓存未命中中度过。这是因为你在紧密循环中触及了很多位于不同位置的数据。更糟糕的是:你的内存位置恰好相差2的整数次幂。这是缓存表现最差的大小。

如果你想提高内存访问的局部性,可以改进这个旋转算法。

一个简单的方法是使用相同的代码分别旋转每个8x8像素块,并用另一个循环将图像旋转分成8x8像素块的片段。

例如,类似于以下代码(未经检查,抱歉是C代码,我的Delphi技能过时了):

 // this is the outer-loop that breaks your image rotation
 // into chunks of 8x8 pixels each:
 for (int block_x = 0; block_x < 2048; block_x+=8)
 {
    for (int block_y = 0; blocky_y < 2048; block_y+=8)
    { 
       // this is the inner-loop that processes a block
       // of 8x8 pixels.
       for (int x= 0; x<8; x++)
         for (int y=0; y<8; y++)
            dest[x+block_x][y+block_y] = src[y+block_y][x+block_x]
    }
 } 

还有其他方法。您可以使用Hilbert-Order或Morton-Order处理数据。这理论上会更快一些,但代码会复杂得多。

顺便提一下 - 既然你提到SSE对你来说是一个选项。请注意,您可以在SSE寄存器内旋转8x8字节块。这有点棘手,但查看SSE矩阵转置代码应该可以让您开始工作,因为它是同样的东西。


编辑:

刚刚检查过:

使用8x8像素块大小,该代码在我的计算机上运行速度约为5倍。使用16x16的块大小,运行速度为10倍。

看起来尝试不同的块大小是个好主意。

这是我使用的(非常简单的)测试程序:

#include <stdio.h>
#include <windows.h>

char temp1[2048*2048];
char temp2[2048*2048];

void rotate1 (void)
{
  int x,y;
  for (y=0; y<2048; y++)
  for (x=0; x<2048; x++)
    temp2[2048*y+x] = temp1[2048*x+y];
}

void rotate2 (void)
{
  int x,y;
  int bx, by;

  for (by=0; by<2048; by+=8)
  for (bx=0; bx<2048; bx+=8)
  for (y=0; y<8; y++)
  for (x=0; x<8; x++)
    temp2[2048*(y+by)+x+bx] = temp1[2048*(x+bx)+y+by];
}

void rotate3 (void)
{
  int x,y;
  int bx, by;

  for (by=0; by<2048; by+=16)
  for (bx=0; bx<2048; bx+=16)
  for (y=0; y<16; y++)
  for (x=0; x<16; x++)
    temp2[2048*(y+by)+x+bx] = temp1[2048*(x+bx)+y+by];
}


int main (int argc, char **args)
{
  int i, t1;

  t1 = GetTickCount();
  for (i=0; i<20; i++) rotate1();
  printf ("%d\n", GetTickCount()-t1);

  t1 = GetTickCount();
  for (i=0; i<20; i++) rotate2();
  printf ("%d\n", GetTickCount()-t1);

  t1 = GetTickCount();
  for (i=0; i<20; i++) rotate3();
  printf ("%d\n", GetTickCount()-t1);

}

好的,谢谢。听起来非常有前途。现在的10倍速度提升足够了,我不会为了它去折腾SSE。至少现在不会。我对分块处理还有点犹豫,但这个确认了我的想法并给了我一个实现方法。 - Marco van de Voort
7
顺便提一下,这种技术叫做循环分块。还有,别忘了可以并行化缩放操作。只需启动两个线程,让每个线程处理图像的一半即可。 - Nils Pipenbrinck
1
顺便说一句 Marco,一旦你实现了它,让我们知道你加速了多少。我只是好奇它在真实世界的应用中表现如何。 - Nils Pipenbrinck
另一个更新。今年夏天,我终于完成了汇编程序。对于小图像尺寸,它比循环瓷砖快4倍,详见https://dev59.com/lKfja4cB1Zd3GeqPpQjt。 - Marco van de Voort

3
如果您会使用C ++,则可以查看Eigen
它是一个C ++模板库,使用SSE(2及更高版本)和AltiVec指令集,并具有优雅的回退到非矢量化代码。
快速。(请参见基准测试)。表达式模板允许智能地删除临时变量并在适当时启用延迟评估 - Eigen会自动处理这些内容并在大多数情况下处理别名。
对于SSE(2及更高版本)和AltiVec指令集,执行显式矢量化,并优雅地回退到非矢量化代码。表达式模板允许全局执行这些优化。
对于固定大小的对象,避免了动态内存分配,并在有意义时展开循环。
对于大型矩阵,特别注意缓存友好性。

2
这类库的问题在于,将您拥有的格式转换为库使用的格式,通常已经达到了可能获得的收益量级。 - Marco van de Voort

1
你也许可以通过复制缓存对齐块而不是行来提高它的性能,因为目前无论Delphi是行优先还是列优先,src和dest的步幅都会导致未命中。

问题不就是等号两边总有一个对不齐吗?我要么按顺序遍历src,要么遍历dst,但从来不会同时进行。 - Marco van de Voort
不,您可以像Nils演示的那样复制一个块并在缓存中转置它(假设他所在的机器具有256字节的缓存行)。 - Pete Kirkham
好的,抱歉,那我理解错了,是的,尼尔的答案就是我要找的。 - Marco van de Voort

0

如果图像不是正方形的,就不能进行就地操作。即使你在正方形图像中工作,变换也不利于就地操作。

如果您想尝试加快处理速度,可以尝试利用行步幅来实现,但我认为您最好能从源中每次读取一个长整型的4个字节,然后将其写入目标中的四个连续行中。这样可以减少一些开销,但我不会期望超过5%的改进。


正确,但我有几个不同的用例,可以就地进行,例如轻微放大图像,使其成为正方形。虽然这是较小的用例之一。如下所示,加速非常显著(300-400%)。但是就地操作会降低速度,因为您无法简单地交换块,而必须通过4 x 90度旋转,因此我决定不推动它并现在将复制视为理所当然。 - Marco van de Voort

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