MATLAB中的索引向量是否效率低下?

时间:2012-11-14 15:46:36

标签: arrays performance matlab loops vectorization

背景

我的问题是出于简单的观察,这有点破坏了经验丰富的MATLAB用户经常持有/做出的信念/假设:

  • MATLAB在内置函数和基本语言特性(如索引向量和矩阵)方面进行了很好的优化。
  • MATLAB中的循环很慢(尽管有JIT),如果算法可以用本机的“矢量化”方式表示,通常应该避免。

底线:核心MATLAB功能很有效,并且尝试使用MATLAB代码超越它是很困难的,如果不是不可能的话。

调查矢量索引的效果

下面显示的示例代码是基本的:我为所有向量条目指定标量值。首先,我分配一个空向量x

tic; x = zeros(1e8,1); toc
Elapsed time is 0.260525 seconds.

拥有x我想将其所有条目设置为相同的值。在实践中,您会采用不同的方式,例如x = value*ones(1e8,1),但这里的重点是调查向量索引的性能。最简单的方法是写:

tic; x(:) = 1; toc
Elapsed time is 0.094316 seconds.

我们称之为方法1(来自分配给x的值)。它看起来非常快(至少比内存分配更快)。因为我在这里做的唯一事情是对内存进行操作,我可以通过计算获得的有效内存带宽并将其与硬件内存带宽进行比较来估计此代码的效率。我的电脑:

eff_bandwidth = numel(x) * 8 bytes per double * 2 / time

在上面,我乘以2因为除非使用SSE流,否则在内存中设置值要求向量既可以读取也可以写入内存。在上面的例子中:

eff_bandwidth(1) = 1e8*8*2/0.094316 = 17 Gb/s
我的计算机的STREAM-benchmarked memory bandwidth大约是17.9 Gb / s,所以确实如此 - 在这种情况下,MATLAB可以提供接近峰值的性能!到目前为止,非常好。

如果要将所有向量元素设置为某个值,则方法1是合适的。但是,如果您想要访问每个step条目的元素,则需要将:替换为1:step:end。以下是与方法1的直接速度比较:

tic; x(1:end) = 2; toc
Elapsed time is 0.496476 seconds.

虽然你不希望它有任何不同,但方法2显然是一大麻烦:因素5无缘无故减速。我怀疑在这种情况下,MATLAB显式地分配索引向量(1:end)。通过使用显式向量大小而不是end来确认这一点:

tic; x(1:1e8) = 3; toc
Elapsed time is 0.482083 seconds.

方法2和3表现同样糟糕。

另一种可能性是显式创建索引向量id并使用它来索引x。这为您提供了最灵活的索引功能。在我们的案例中:

tic;
id = 1:1e8; % colon(1,1e8);
x(id) = 4;
toc
Elapsed time is 1.208419 seconds.

现在这确实是事情 - 与方法1相比减速12倍!我知道它应该比方法1更糟糕,因为id使用了额外的内存,但为什么它比方法2和3差得多?

让我们尝试尝试循环 - 听起来毫无意义。

tic;
for i=1:numel(x)
    x(i) = 5;
end
toc
Elapsed time is 0.788944 seconds.

一个很大的惊喜 - 循环击败vectorized方法4,但仍然比方法1,2和3慢。事实证明,在这种特殊情况下你可以做得更好:

tic;
for i=1:1e8
    x(i) = 6;
end
toc
Elapsed time is 0.321246 seconds.

这可能是本研究中最奇怪的结果 - MATLAB编写的循环明显优于本机矢量索引。当然不应该这样。注意,JIT的循环仍然比通过方法1几乎获得的理论峰值慢3倍。因此仍有很大的改进空间。通常的“矢量化”索引(1:end)甚至更慢,这是令人惊讶的(更强的词会更合适)。

问题

  • 是MATLAB中的简单索引非常效率低(方法2,3和4比方法1慢),还是我错过了什么?
  • 为什么方法4 (那么多)比方法2和3慢?
  • 为什么使用1e8代替numel(x)作为循环绑定会使代码加速2倍?

修改 阅读Jonas的评论后,这是使用逻辑索引的另一种方法:

tic;
id = logical(ones(1, 1e8));
x(id) = 7;
toc
Elapsed time is 0.613363 seconds.

比方法4好多了。

为方便起见:

function test

tic; x = zeros(1,1e8); toc

tic; x(:) = 1; toc
tic; x(1:end) = 2; toc
tic; x(1:1e8) = 3; toc

tic;
id = 1:1e8; % colon(1,1e8);
x(id) = 4;
toc

tic;
for i=1:numel(x)
    x(i) = 5;
end
toc

tic;
for i=1:1e8
    x(i) = 6;
end
toc

end

3 个答案:

答案 0 :(得分:13)

我当然可以推测。但是,当我在启用JIT编译器和禁用的情况下运行测试时,我得到以下结果:

 % with JIT   no JIT
    0.1677    0.0011 %# init
    0.0974    0.0936 %# #1 I added an assigment before this line to avoid issues with deferring
    0.4005    0.4028 %# #2
    0.4047    0.4005 %# #3
    1.1160    1.1180 %# #4
    0.8221   48.3239 %# #5 This is where "don't use loops in Matlab" comes from 
    0.3232   48.2197 %# #6
    0.5464   %# logical indexing

划分向我们显示速度增加的地方:

% withoutJit./withJit
    0.0067 %# w/o JIT, the memory allocation is deferred
    0.9614 %# no JIT
    1.0057 %# no JIT
    0.9897 %# no JIT
    1.0018 %# no JIT
   58.7792 %# numel
  149.2010 %# no numel

初始化的明显加速发生,因为关闭JIT后,似乎MATLAB会延迟内存分配,直到使用它为止,因此x =零(...)实际上没有做任何事情。 (谢谢,@ angainor)。

方法1到4似乎没有从JIT中受益。我想由于subsref中的额外输入测试,#4可能会很慢,以确保输入的格式正确。

numel结果可能与编译器更难处理不确定的迭代次数有关,或者由于检查循环的界限是否正常而导致一些开销(认为没有JIT)测试表明只有~0.1s)

令人惊讶的是,在我的机器上的R2012b上,逻辑索引似乎比#4慢。

我认为这再一次表明,MathWorks在加速代码方面做得非常出色,而且“不使用循环”并不总是最好的,如果你想要获得最快的执行时间(至少在目前)。然而,我发现矢量化通常是一种很好的方法,因为(a)JIT在更复杂的循环上失败,并且(b)学习矢量化使得你更好地理解Matlab。

结论:如果您想要速度,请使用分析器,并在切换Matlab版本时重新配置。


作为参考,我使用了以下略微修改过的测试函数

function tt = speedTest

tt = zeros(8,1);

tic; x = zeros(1,1e8); tt(1)=toc;

x(:) = 2;

tic; x(:) = 1; tt(2)=toc;
tic; x(1:end) = 2; tt(3)=toc;
tic; x(1:1e8) = 3; tt(4)=toc;

tic;
id = 1:1e8; % colon(1,1e8);
x(id) = 4;
tt(5)=toc;

tic;
for i=1:numel(x)
    x(i) = 5;
end
tt(6)=toc;

tic;
for i=1:1e8
    x(i) = 6;
end
tt(7)=toc;

%# logical indexing
tic;
id = true(1e8,1));
x(id)=7;
tt(8)=toc;

答案 1 :(得分:8)

我对所有问题都没有答案,但我确实对方法2,3和4做了一些改进的推测。

关于方法2和3.确实看起来MATLAB为向量索引分配内存并用11e8的值填充它。为了理解它,让我们看看发生了什么。默认情况下,MATLAB使用double作为其数据类型。分配索引数组的时间与分配x

的时间相同
tic; x = zeros(1e8,1); toc
Elapsed time is 0.260525 seconds.

目前,索引数组只包含零。以最佳方式将值分配给x向量,如方法1所示,需要0.094316秒。现在,必须从内存中读取索引向量,以便可以在索引中使用它。那是额外的0.094316/2秒。回想一下,x(:)=1向量x必须同时读取和写入内存。所以只读它需要一半的时间。假设这是在x(1:end)=value中完成的所有操作,方法2和3的总时间应为

t = 0.260525+0.094316+0.094316/2 = 0.402

这几乎是正确的,但并不完全正确。我只能推测,但用值填充索引向量可能是作为一个额外的步骤完成的,需要额外的0.094316秒。因此,t=0.4963,或多或少地适合方法2和3的时间。

这些只是推测,但它们似乎确实证实MATLAB 显式在进行本机向量索引时创建索引向量。就个人而言,我认为这是一个性能错误。 MATLAB JIT编译器应该足够聪明,能够理解这个简单的构造,并将其转换为对正确内部函数的调用。就像现在一样,在今天的内存带宽限制架构上,索引执行的理论峰值约为20%。

因此,如果您关心性能,则必须将x(1:step:end)实现为MEX函数,例如

set_value(x, 1, step, 1e8, value);

现在这在MATLAB中显然是非法的,因为你 NOT ALLOWED 来修改MEX文件中的数组。

编辑关于方法4,可以尝试按如下方式分析各个步骤的效果:

tic;
id = 1:1e8; % colon(1,1e8);
toc
tic
x(id) = 4;
toc

Elapsed time is 0.475243 seconds.
Elapsed time is 0.763450 seconds.

第一步,分配和填充索引向量的值与方法2和3相同。它似乎太过分了 - 最多需要分配内存和设置值(0.260525s+0.094316s = 0.3548s)所需的时间,因此在某处会有0.12秒的额外开销,我不明白。第二部分(x(id) = 4)看起来效率也很低:它应该花费时间来设置x的值,并阅读id向量(0.094316s+0.094316/2s = 0.1415s)加上对id值进行一些错误检查。在C中编程,这两个步骤采取:

create id                              0.214259
x(id) = 4                              0.219768

使用的代码检查double索引实际上代表一个整数,并且它符合x的大小:

tic();
id  = malloc(sizeof(double)*n);
for(i=0; i<n; i++) id[i] = i;
toc("create id");

tic();
for(i=0; i<n; i++) {
  long iid = (long)id[i];
  if(iid>=0 && iid<n && (double)iid==id[i]){
    x[iid] = 4;
  } else break;
}
toc("x(id) = 4");

第二步需要比预期0.1415s更长的时间 - 这是由于必须对id值进行错误检查。对我来说,开销似乎太大了 - 也许它可以写得更好。不过,所需时间为0.4340s,而不是1.208419s。 MATLAB在幕后做了什么 - 我不知道。也许有必要这样做,我只是没有看到它。

当然,使用doubles作为索引会引入两个额外的开销级别:

  • double的大小是uint32大小的两倍。回想一下,内存带宽是限制因素。
  • 需要将双精度转换为整数以进行索引

方法4可以使用整数索引在MATLAB中编写:

tic;
id = uint32(1):1e8;
toc
tic
x(id) = 8;
toc

Elapsed time is 0.327704 seconds.
Elapsed time is 0.561121 seconds.

这显然将性能提高了30%,并证明应该使用整数作为向量索引。但是,开销仍然存在。

正如我现在所看到的,我们无法做任何事情来改善MATLAB框架内的工作情况,我们必须等到Mathworks修复这些问题。

答案 2 :(得分:3)

快速说明一下,在8年的发展中,MATLAB的性能特征发生了很大变化。

这是在R2017a(OP发布后的5年)上:

Elapsed time is 0.000079 seconds.    % x = zeros(1,1e8);
Elapsed time is 0.101134 seconds.    % x(:) = 1;
Elapsed time is 0.578200 seconds.    % x(1:end) = 2;
Elapsed time is 0.569791 seconds.    % x(1:1e8) = 3;
Elapsed time is 1.602526 seconds.    % id = 1:1e8; x(id) = 4;
Elapsed time is 0.373966 seconds.    % for i=1:numel(x), x(i) = 5; end
Elapsed time is 0.374775 seconds.    % for i=1:1e8, x(i) = 6; end

请注意,1:numel(x)的循环比索引x(1:end)快得多,似乎仍在创建数组1:end,而对于循环却没有。现在最好在MATLAB中向量化!

(我在将矩阵分配到任何定时区域之外之后确实添加了一个分配x(:)=0,以便实际上已分配了内存,因为zeros仅保留了内存。)


在MATLAB R2020b(在线)(三年后)上,我看到了这些时间:

Elapsed time is 0.000073 seconds.    % x = zeros(1,1e8);
Elapsed time is 0.084847 seconds.    % x(:) = 1;
Elapsed time is 0.084643 seconds.    % x(1:end) = 2;
Elapsed time is 0.085319 seconds.    % x(1:1e8) = 3;
Elapsed time is 1.393964 seconds.    % id = 1:1e8; x(id) = 4;
Elapsed time is 0.168394 seconds.    % for i=1:numel(x), x(i) = 5; end
Elapsed time is 0.169830 seconds.    % for i=1:1e8, x(i) = 6; end

x(1:end)现在与x(:)相同,但不再显式创建向量1:end