为何 IDL 中的 for 循环如此缓慢?
原文链接: https://www.nv5geospatialsoftware.com/Learn/Blogs/Blog-Details/why-are-for-loops-in-idl-so-slow
19270 为本文评分:
3.8
为何 IDL 中的 for 循环如此缓慢?
Anonym 星期四,2016年1月7日
曾几何时,我作为一名本科生程序员,编写了计算大气辐射算法的代码。在我对 IDL 了解不多时,我写了一些执行所需计算的代码,并将其放在一个巨大的 for 循环中。退一步说,这段代码运行起来既慢又痛苦。事实上,情况非常糟糕,以至于 IT 部门的一位同事来到我的办公室,告诉我必须停止使用他们那么多的处理器资源。不仅如此,计算一天大气辐射量的代码需要运行超过一天,如果数据输入的速度超过了处理速度,这些算法就没多大用处了。
那时我决定拿起一些 IDL 书籍,学习如何编写更好的代码。事实证明,有很多方法可以将计算移出循环,并减少循环内的工作量。
为什么 IDL 中的循环看起来很慢?
首先,这并非 IDL 独有。如果你不相信我,看看人们通常向谷歌搜索什么:

IDL 是一种解释型语言,根据 维基百科 的定义:
| 解释型语言 是一种编程语言,其大多数实现直接执行指令,而无需事先将程序编译成机器语言指令。解释器 直接执行程序,将每个语句翻译成一个或多个已编译成机器码的子程序序列。 |
这意味着当 IDL 执行一个 for 循环时,循环内的每个语句都会在 IDL 层面被解释,然后发送到机器进行处理,并且每次循环迭代都会执行此操作。在具有非常多迭代次数的循环中,或者当循环内有多个语句时,循环完成的速度会非常慢。
话虽如此,避免这个问题的最佳方法是什么?
向量化
向量化,即一次性对整个数组进行操作,可能是从上述谷歌搜索结果中得到的首要建议。几乎所有的 IDL 数学函数都可以接受数组而不仅仅是单个值,结果将是一个对应的数组。考虑以下语句
array = Dindgen(100000, INCREMENT=.001)
arcsines = DblArr(100000, /NOZERO)
for i=0,99999 do arcsines[i] = asin([i])
这个循环在我的电脑上运行了 0.047 秒。但是,通过在循环外执行反正弦操作可以获得显著的性能提升。
arcsines = asin(array)
事实上,对我来说,执行这条语句的时间非常短,以至于 IDL 围绕它的 tic/toc 调用报告为零秒。
这是因为不是将每个反正弦调用单独传递给机器执行,而是整个数组一次性传递,由机器处理数组。换句话说,IDL 和机器之间只有一次来回通信,而不是 100000 次。
此外,WHERE(在我看来)是 IDL 中最方便的向量化操作之一。使用它通常可以完全避免循环的需要。Where 语句内部的条件值可以是任何维度的数组。
result = Where(condition, count)
还要记住,如果你只关心 Where 调用中的计数而不需要结果变量,可以通过在条件上使用 TOTAL 来避免创建新变量,其中条件值中的项也可以是任何维度的数组:
count = Total(condition)
将条件判断和计算移出循环
条件判断总是会带来性能开销。如果可能,再次考虑向量化,但如果不行,请尝试使条件判断尽可能简单。
一种可能是,如果你知道只处理数据的子集,请使用 WHERE 语句并仅迭代子集。例如,不要这样写:
for i=0,N_Elements(array) do begin
if (array[i] mod 3 eq 0) then begin
...
endif
endfor
试试这样:
result = Where(array mod 3 eq 0, count)
for ii=0, count-1 do begin
i = result[ii]
...
endfor
现在你只进行了三分之一的循环迭代,并且条件判断已移出循环。
还要考虑将对常量的数学计算移出循环,以便在循环内执行最少的操作。例如,不要这样写:
for i=0,N_Elements(array) do begin
value = array[i] * !pi/2
endfor
试试这样:
piOverTwo = !pi/2
for i=0,N_Elements(array) do begin
value = array[i] * piOverTwo
endfor
当常量被计算并设置为单个变量时,循环完成时间缩短了一半。再次强调,尽量保持循环内容尽可能简单。
使用原地操作
如果可以在原地对变量进行操作,而不是重新定义变量,则效率更高。在这条语句中:
var = var + 1
IDL 在内存中创建一个新项,执行操作,然后忘记旧项。在大型循环中,这可能代价很高。
IDL 包含以下原地运算符:
| 运算符 | 定义 | 示例 | 非原地等价形式 |
| ++ | 变量加一 | a++ | a = a + 1 |
| -- | 变量减一 | a-- | a = a - 1 |
| += | 为变量增加指定值(运算符后指定值) | a += 2 | a = a + 2 |
| -= | 从变量中减去指定值 | a -= 2 | a = a -2 |
| *= | 将变量乘以指定值 | a *= 2 | a = a * 2 |
| /= | 将变量除以指定值 | a /= 2 | a = a /2 |
这可以对数组的元素以及单个变量进行:
array[i]++
array[i] /= 2
它甚至可以对整个数组(向量化操作)进行:
array--
array *= 3
如果可能,使用提前退出
正如大约一年半前 一篇 IDL Data Point 文章 中所述,在无需继续当前循环迭代的情况下,使用 CONTINUE 跳转到循环的下一次迭代很方便。同样,如果循环中完全没有更多工作要做,那么使用 BREAK 提前退出循环可以节省时间。例如,如果你有办法确定数组中剩余的数据是缺失或不重要的数据,这将很有用。
注意顺序很重要
最后,请注意 IDL 是列优先而不是行优先(就像 Fortran,而不是 C)。虽然这段代码更直观,也是你在 C 语言中会写的:
array = Lindgen(10000,100000)
for i=0,9999 do begin
for j=0,9999 do begin
value = array[i,j]/2
endfor
endfor
这个循环在我的电脑上运行了 9.96 秒。然而,通过交换 i 和 j:
for j=0,9999 do begin
for i=0,9999 do begin
value = array[i,j]/2
endfor
endfor
它只运行了 8 秒。这是因为当 IDL 先遍历列再遍历行时,它是在按照数据在内存中的存储顺序访问数据。
总结
IDL 中的 for 循环有时可能看起来是件坏事,但它们其实没那么糟,有时甚至不可避免。有很多技术可以使循环尽可能高效和快速,本文仅概述了其中的一部分。总结如下:
- 首先尝试向量化。如果 IDL 有办法使用对数组的单个调用执行所需计算,则将整行操作移出循环,甚至完全消除循环。
- 将尽可能多的内容移出循环,例如设置变量、使用常量进行计算以及执行条件判断。在检查条件或在数组中查找特定项时,考虑使用 WHERE 语句。
- 对简单算术使用原地运算符,避免复制变量或数组。
- 如果完成工作,尽早退出循环。
- 最后,按照数据存储的顺序访问数据。
总之,核心思想是尽可能使循环保持简单和精简。
顺便说一句,本文只提到了 for 循环。然而,这些完全相同的技术适用于 IDL 中的任何类型的循环,包括 While、Repeat 和 Foreach。