跳到主要内容

LabVIEW 程序的内存优化

· 阅读需 29 分钟

全书: https://labview.qizhen.xyz/

打开一个VI的属性面板(VI Properties),其中的“内存使用”(Memory Usage)是用来查看这个VI内存占用情况的。它显示了一个VI内存占用所包含的四个主要部分:前面板、框图、代码和数据,以及这四个部分的总和。但在打开一个VI时,这四段内容并不是同时都会被LabVIEW调入内存的。

当我们打开一个主VI时,主VI连同它的所有子VI的代码和数据段都会被调入内存。由于主VI的前面板一般情况下是打开的,它的前面板也就同时被调入内存。但是此时主VI的框图和子VI的前面板、框图并没有被调入内存。只有当主动查看主VI的框图或是打开子VI的前面板和框图时,它们才会被调入。 基于LabVIEW的这种内存管理的特性,我们在编写VI的时候可以通过以下方法来优化LabVIEW程序的内存使用。 第一,把一个复杂VI分解为数个子VI。子VI的使用会增添额外的前面板和框图的空间,但并不增添额外的代码和数据空间。由于程序运行时只有代码和数据被调入内存,因此使用子VI不会占用额外的内存。使用子VI的好处还在于当子VI运行结束时,LabVIEW可以及时收回子VI的数据空间,从而改善了内存的使用效率。 第二,在没有必要时不要设置子VI的重入(Reentrant)属性。重入型VI每次运行时都会对自己使用的数据生成一个副本,这增加了内存开销。 第三,主VI的面板通常就是用户界面,需要显示给用户。但是要尽量避免开启子VI前面板。比如,在子VI中使用与其前面板控件有关的属性节点(Property Node)会导致它的前面板被调入内存中,增加了内存开销,所以要尽量避免在子VI中使用主面板控件的属性节点来设置控件的值,而可以用局部变量等方法来替代。 第四,我们可以放心地在 VI 的前面板(对于非界面VI)和框图里添加图片,注释等信息来帮助你编写、维护LabVIEW程序,这些帮助信息不会在VI运行时占用内存。

二. 内存泄漏。

LabVIEW与C语言不同,它没有任何分配或释放内存的语句,LabVIEW可以自动管理内存,在适当的时候分配或收回内存资源[1]。这样就避免了C语言中常见的因为内存管理语句使用不当而引起的内存泄漏。 在LabVIEW中一般只有一种情况能够引起内存泄漏,即你打开了某些资源,却忘记了关闭它们。比如,在对文件操作时,我们需要先打开这个文件,返回它的句柄。随后如果忘记了关闭这个句柄,它所占用的内存就始终不会被释放,从而产生内存泄漏。LabVIEW中其它带有打开句柄的函数或VI也会引起同样的问题。 由于内存泄漏是动态产生的,我们无法通过VI的属性面板来查看,但可以通过Windows自带的任务管理工具来查看LabVIEW程序内存是否有泄漏。也可以使用LabVIEW的Profile (Tools>>Advanced>>Profile VIs)工具来查看某个VI运行时内存的分配情况。

三. 缓存重用

LabVIEW程序主要是数据流驱动型的。数据传递到不同节点时往往需要复制一个副本。这是LabVIEW为了防止数据被节点改变引起错误所做的一种数据保护措施。只有当目标节点为只读节点,不可能对输入数据作任何更改时,才不在这些节点处做备份。例如,数组索引节点(Index)是不会改变数组值的,LabVIEW在这里就不为输入数组做备份。对于加减法运算等肯定改变输入数据的节点,LabVIEW往往需要对输入或输出数据作备份。有些LabVIEW程序,比如涉及到大数组运算的程序,内存消耗极大。其主要原因就是LabVIEW在运算时为数组数据生成了过多的副本。 实际上很多LabVIEW节点是允许使用缓存重用的,这类似C语言调用子函数所使用的地址传递。通过合理设计和使用缓存重用节点,可以大大优化LabVIEW程序的内存使用。使用LabVIEW 7.1的Tool>>Advanced>>Show Buffer Allocations (LabVIEW 8.0 之后使用 Tool>>Profile>>Show Buffer Allocations)工具可以在VI框图中查看缓存的分配情况。打开该工具,凡是在框图中有缓存分配的地方,都会显示出一个黑点。 下面是几个最常用节点的试验结果。LabVIEW节点众多,不可能一一列举,文中未提及的节点读者在编程时自己可以尝试。

1. 一般顺序执行VI中的运算节点

图1:简单的顺序执行程序

如图1所示,程序对一个常量加1,然后将结果输出。 “+1”节点输出端有一个黑点,表示LabVIEW在此处开辟了一个缓存用于保存运算结果。 其实完全可以利用输入数据的内存空间来保存这个运算结果。我们可以通过如下的方法来告知LabVIEW编译器,在此运算节点处重用输入数据的内存空间。 首先,用一个控制型数值控件代替图中的数值常量,然后分别将VI中的两个控件与VI的接线器(Connector Pane)相连。

图2:实现缓存重用

图2是经过我们优化后的VI,LabVIEW在“+1”节点处没有开辟新的缓存。LabVIEW中其它运算节点也有类似的性质。

2. 移位寄存器(Shift Register in the Loop Structure)

移位寄存器是LabVIEW内存优化中最为重要的一个节点,因为移位寄存器在循环结构两端的接线端是强制使用同一内存的。这一特性可以被用来通知LabVIEW在编译循环内代码时,重用输入输出缓存。

图3: 对数组进行数值运算的顺序执行程序

让我们分析一下图3所示的程序:它首先构造了一个数组,然后对这个数组进行了几次数学运算。每一步运算,LabVIEW都要开辟一块缓存用以保存运算结果的副本。打开VI属性面板上的内存使用,可以看到这个VI大约会占用2.7M的内存空间。其实这些副本都是不必要的,每一步运算的结果都可以被保存到输入数据的内存空间。我们可以把所用的运算节点都放到一个子VI中,然后利用上一段提到的方法,使子VI中的代码缓存重用。还有一种方法,利用移位寄存器也可以实现缓存重用。

图4: 利用移位寄存器实现缓存重用

如图4,我们可以将运算代码放在一个只运行一次的循环结构内,由于运算部分的输入和输出都与移位寄存器相连,这就相当于通知了LabVIEW,在运算的输入输出需要使用同一块缓存。因而,LabVIEW 不再为每一步运算开辟新的缓存而是直接利用输入数据的缓存保存结果。打开VI属性面板上的内存使用,可以查看到这个VI的内存占用已经减少到了原来的六分之一。 在 LabVIEW 8.5 中,有了一个新的结构——缓存重用结构,专门用于优化代码的内存使用。可以不必再使用移位寄存器来完成这项工作了。

3. 库函数调用节点(Call Library Node)

以传递整型参数为例:在参数配置面板,我们可以选择值传递(Pass Value)或选择指针传递(Pass Pointer to Value)。 当选择了值传递时,库函数调用节点是不会改变该参数的内容的。如果我们在该库函数调用节点参数的左侧接线端引入输入数据,在输出端引出输出参数,那么输出数据其实是直接由输入数据引出的,LabVIEW不会在这个节点处开辟缓存。 在指针传递方式时,LabVIEW则认为传入的数据会被改变。如果输入数据同时还要发往其它节点,LabVIEW会在此处开辟缓存,为输入数据作一个副本。选用指针传递方式,库函数调用节点的每一对接线端也同样是缓存重用的。就是说,库函数调用节点的输出值是直接存放在输入值的缓存空间的。 如果一个参数只用作输出,我们通常会在库函数调用节点的输入接线端为它建立一个输入常数,这个常数的地址空间并不能直接被利用,它只是为库函数调用节点开辟的缓存而设置的初始值。不接输入常数,LabVIEW也会为此参数开辟一块缓存。但是,这样每次传入的参数值都会有变化。例如图5,库函数调用节点调用的函数功能是为把输入的值加1,然后输出。图5-a中的输出值永远都是1,而图5-b,每次运行输出结果都会比前次增加1。这是因为库函数调用节点每个指针传递的参数的输入输出用的是同一块缓存,即每次运行输入值是上回的输出值。

图5: 库函数调用节点

我们可以利用图5-c的例子证明LabVIEW某些节点是缓存重用的。每次运行5-c的例子,输出结果都会比前次增加2。这是因为示例中的参数接线端以及“+1”节点的输入输出端所使用的都是同一缓存。

如果,库函数调用节点中某个参数只有输入链进去,没有输出。那么,LabVIEW 是假设你调用的函数不会修改这个参数的。LabVIEW 不会为这个数据做拷贝,它会重用这个数据的缓存。但如果你调用的函数修改的这个数据,你的程序就会面临这样一个潜在的危险:这个数据可能被程序其它部分的代码使用了,在那里,你看不出这个数据有任何被改动的地方,但它在运行时却不是你期望的数值。因为这个数据所在的缓存,被程序其它一个地方的一个库函数调用节点给重用了,而这个节点又偷偷摸摸的修改了它。

在图5中的示例中,如果库函数调用节点输出的参数是个数组或者字符串,那么就必须为它相对应的输入端联入一个与输出数据大小一致的数组或字符串。否则,LabVIEW无法知道输出数据的大小,而使用默认分配的缓存空间很容易出现数组越界错误。

四. 小结

缓存重用是LabVIEW内存优化的最重要的一个环节。精心设计的LabVIEW程序可以大大节约内存的占用,提高运行效率。但是,在编写完程序后再按照程序优化的技巧回头去优化一段已有的程序,这并不是一个好的编程方法。我们应该先熟悉理解优化的方法,在以后的开发过程中自然而然地将它们应用在编程中。

五. 子 VI 的优化

1. 子 VI 参数的缓存重用

数据在子 VI 间传入传出,如果程序设计的好,可以做到缓存重用,使得数据在主 VI 和子 VI 中都不发生拷贝,提高程序的效率。

我们先来看一下图1所示的 VI。打开 Tool>>Profile>>Show Buffer Allocations 工具查看一下这个 VI 中内存分配的情况,会发现在代码的加法函数处有一个黑点。这个黑点说明程序在这里有分配了一块内存,这个内存是用来存储加法运算结果的。

图1:控件不与接线器相连时,加法处有内存分配

    为什么加法函数在这里不做缓存重用呢?利用其中一个加数的内存空间来保存计算结果。 当这个 VI 运行的时候,图2中,加数 Numeric 的数据是由 VI 前面板的控件提供的。如果用户不修改控件的值,每次 VI 运行,这个数值应该是保持不变的。如果加法函数在这里做缓存重用,加数或者说它对应的控件中的数据,就会在加法运算执行后被修改。这样程序就会出现逻辑上的错误。 所以把一个这样的控件联在 LabVIEW 的运算节点上,运算节点是不能重用控件的数据内存的。同样的道理,链接一个常量到运算节点上,节点同样不能做缓存重用。在子 VI 中,没有连到接线器上的输入控件就相当与一个常量。

但是,如果我们让 VI 上的控件与 VI 的接线器(Connector Pane)相连,情况就不一样了。入图2所示,把三个控件连到接线器上,程序中加法节点上那个黑点就消失了,不再为运算结果分配新的内存。

图2:控件不与接线器相连时,加法处有内存分配

    这是因为,当输入控件与接线器连接后,LabVIEW 就认为这个输入值应当是由子 VI 的调用者(父 VI)提供的:连到接线器上,逻辑上,这个输入控件就不再是常量,而是一个输入变量了。既然是输入变量,子 VI 不需要记住输入的数据共下次调用时使用,因此可以把新产生的数据放在输入参数所在的内存,做到缓存重用。

你可能在想,这个输入参数的内存不一定可以被修改吧,万一它的数据还要在父 VI 中被其它节点使用呢? 子 VI 是不需要考虑这点的,输入数据的数据被修改肯定是安全的,这一点是由父 VI 来保证的。如果输入数据不能被修改,父 VI 会把传入的数据拷贝一份再传到子 VI 中去。 比如图3中的程序,它所调用的子 VI 就是图2中那个 VI。由于与它的第一个输入参数相连的是一个常量,而常量的值是不能被改变的。所以 LabVIEW 要把这个常量的值复制一份,再传到子 VI 中去,以保证子 VI 中的运算节点可以做缓存重用。

图3:父 VI 中的数据拷贝

    如果图3中的父 VI,他也使用与接线器相连的输入控件为子 VI 提供输入参数,则 LabVIEW 会知道,父 VI 的这个数据是由再上一层 VI 提供的,这里也不需要需要做数据拷贝。这样,这个 VI 就也做到了缓存重用。设计合理,参数在传递多个深度后都不需要开辟新内存的。

    从上面的说明中,还可以发现一个问题。就是,有时候子 VI 的改动,会影响父 VI 的行为,比如是否为传入子 VI 的数据做个拷贝等等。有时候我们发现改动了一个子 VI,它的父 VI 也需要重新保存,就是由这个原因引起的。

2. 输入输出参数的排布

    在子 VI 的程序框图上,不论代码有多复杂,有多少嵌套的结构,控件终端最好按照这样的方式排布:所有输入参数(控制型控件的终端)都放在代码的最左端排成一列;所有的输出参数(显示型控件的终端)都放在代码。比如图4中的代码的风格就比较好。

图4:控件终端整齐的排列在程序框图左右两端

    这首先是为了保证程序有良好的可读性。我们在阅读 LabVIEW 代码的时候总是按照从左到右的顺序,所有的参数都排布在一起,我们就可以以数据线为线索,轻易的找的数据被读写的地方。其次,这种风格的 VI,在效率上也比较优化。

    对于一个输入参数(控制型控件的终端),如果把它放程序代码的最左侧,所有结构的外面,程序在运行这个子VI之前,就可以得到这个参数的确切值了。 但是,如果这个终端是在代码的某个结构中的,在某一结构的内部,那么LabVIEW必须在运行到这一结构内部的时候,才可以去读这个参数的值,否则可能会引起罗技上的错误。比如说,一个控制型控件的终端是在一个循环的内部,开始时它的值是x。在运行到第n次循环之前,这个终端对应的前面板上的控件被人改为一个新的数值y。那么逻辑上,在执行第n次循环之前,每次用到这个参数时,它的值要保持为x,而在第n次循环的时候,又要使用它的新值y。这样的数据所在的内存,LabVIEW 显然是不能将其重用的,否则下次循环再读它的时候,数据就不正确了。 如果这个终端是在所有结构之外,LabVIEW 则可以根据数据线的链接,明确的判断出在某一节点执行完之后,程序再也不需要用到这个参数的值了,那么 LabVIEW 就可以重用它所在的内存,以避免开辟新内存,拷贝数据等操作。这样就提高了程序的内存效率。

    对于一个输出参数(显示型控件的终端),如果它位于某个条件结构的内部,LabVIEW 就要考虑,程序有可能执行不到这个条件。LabVIEW 就会多添加一些代码来处理这种情况,当 VI 没有运行到这个条件时,要给输出参数准备一个默认值。 把这个终端移到所有结构之外,就可以省去这部分 LabVIEW 自动添加上去的工作和,稍微提高一点效率:)

3. 良好的数据流结构可以优化程序内存效率

    先看一个程序:

图5:程序中没有必要的数据线分枝

    图5 的程序只是一个演示,不必追究它到底实现了什么功能。图中的左半部分是主 VI,在这个 VI 中对输入的数组数据Array进行了两次操作:一次使用 subVI“My Search” ;另一次使用了数组排序函数。图5 的右半部分是 subVI“My Search”的程序框图。 需要注意的是,主 VI 上 Sort 1D Array 函数那里有个黑点(这个黑店靠近黄色方块的中心,这里看不太清楚,和图6对比一下,就可以发现了),说明这里做了一次内存分配。这是因为Array的数据被同时传递到了“My Search”和“Sort 1D Array”两个节点进行处理。这两个操作可能会同时进行,LabVIEW 为了安全(两个操作对数据的改动不能相互影响,不能同时对一块内存进行读写),就必须为这两个节点准备两份数据在两份内存中。所以在“My Search”和“Sort 1D Array”两个节点中,如果一个节点用了原来Array的内存,另一个节点就需要拷贝一份数据给自己用。 不过,如果看一下“My Search”的程序框图,它其实没有对Array数据进行任何改动,主VI完全没有比要给“Sort 1D Array”开辟一块新内存。我们只要对程序稍作改动,就可以对此进行优化。图6 是改进后的程序:

图6:符合数据流风格的主VI

    在改进后的程序中,Array 数据首先传入subVI“My Search”,然后又传出来,继续传给“Sort 1D Array”函数。这样子看上去好像数据要多到子VI中转一圈,但实际上,由于子VI中Array输入输出是缓存重用的,实际上相当于只是把数组数据的引用传给了子VI,效率是相当高的。而在主 VI 中,执行“Sort 1D Array”时,LabVIEW 知道输入数据现在是这个节点专用的,改了他也是安全的,于是也可以缓存重用。图六中,“Sort 1D Array”上的那个小黑点就消失了。

    图6 中的主 VI,它的优点首先是符合数据流的风格。一个主要的数据从左到右,流经每个节点。这样的程序非常容易阅读和理解。LabVIEW 也更容易对这样的代码进行优化,所以这样风格的程序通常效率也比较高。 有的时候,利用 LabVIEW 的自动多线程特性,书写并行代码,对程序效率有利。比如,程序中某一部分的代码需要较长时间的计算或者读写时间的情况。但是并不是任何时候并行执行都好。并行书写的程序不易理解,容易出错,多线程运行也会带来额外的开销。像图5、图6中的程序,数据量较大,但是并没有比较耗时的运算操作,或数据读写操作,这样的程序,串行运算比并行效率更高。

https://lv.qizhen.xyz/optimization\_memory