在分布式仿真系统中使用受控类型进行化石收集
在分布式离散事件仿真领域,时间扭曲(Time Warp)算法广为人知。而本文将介绍一种名为分裂队列时间扭曲(Split Queue Time Warp,SQTW)的推广算法,以及如何在该算法的仿真系统中实现化石收集(Fossil Collection)以防止内存溢出,同时探讨Ada语言中受控类型在这一过程中的实用性。
1. 引言
在并行/分布式离散事件仿真中,成熟的时间扭曲算法避免了使用中央仿真时钟和全局状态。被仿真的系统被视为一组松散耦合的物理进程,由逻辑进程(Logical Processes,LPs)表示,它们仅通过发送和接收带时间戳的消息进行交互。
时间扭曲是一种乐观策略,LP根据目前收到的输入来设置下一步,不会等待可能之前就应考虑的进一步输入。一旦检测到因果错误,LP必须回滚到先前状态,并取消所有不合理的临时输出。
随着回滚次数的增加,效率会降低。在标准时间扭曲中,所有发送给LP的消息都插入到一个按时间戳升序排列的单一输入队列中并从中读取。而SQTW算法允许每个LP有多个输入队列,每个队列对应一种消息类型,仅在需要推进时才读取,从而减少回滚风险。
早期用Ada 83编写的单处理器原型显示回滚次数有所减少,随后基于Ada 95及其分布式编程能力实现了更合适的版本。由于LP进行回滚需要保存状态信息,随着仿真进行,内存必然会耗尽,因此需要添加化石/垃圾收集机制来释放不再需要的内存。这就需要计算整个系统的仿真时间,即全局虚拟时间(Global Virtual Time,GVT)。
2. 分裂队列时间扭曲
2.1 算法
标准时间扭曲中,LP从单一输入队列读取消息,这会导致两个主要缺点:一是LP需要在内部状态中保存当前不需要的信息;二是过早读取尚未需要的消息会膨胀LP的历史,增加回滚风险。
SQTW算法提出每个LP可以有多个输入队列,每个队列对应一种消息类型,仅在需要推进时才读取,这种“懒惰”或“按需”处理方式减少了回滚风险。例如,在一个简单的配送站示例中,模拟配送站的LP需要两个输入队列,分别用于包裹和返回的空货车。
LOOP
IF waiting parcels < van capacity
THEN
get(message, parcel channel);
waiting parcels := waiting parcels + 1;
local time := max(message.timestamp, local time);
ELSE
get(message, van channel); -- looking for an empty van
local time := max (message.timestamp, local time);
send((van, local time + delivery time), some station);
waiting parcels := 0;
END IF;
END LOOP;
2.2 系统结构
LPs是SQTW系统的主要构建块,其动作主要分为两部分:一是用于错误恢复,如自我检查一致性和保存当前状态以备后续恢复或回滚;二是特定于模型的LP步骤,用于模拟相应物理进程的行为,消息发送属于LP步骤。
由于通常LP数量多于可用处理节点,多个LP会被分配到同一个处理节点,形成一个子模型。每个子模型有一个子模型管理器(实现为单个任务),负责调度关联的LP,在系统启动时设置LP,在仿真结束时关闭并收集结果。LP被实现为被动对象以增加使用的范围和灵活性。此外,还有一个主程序作为实验者的接口,一个地址服务器保存所有LP的地址。
graph LR
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
submodel1(子模型1):::process --> submodelmanager1(子模型管理器1):::process
submodelmanager1 --> LP11(LP11):::process
submodelmanager1 --> LP12(LP12):::process
submodeln(子模型n):::process --> submodelmanagern(子模型管理器n):::process
submodelmanagern --> LPn1(LPn1):::process
submodelmanagern --> LPn2(LPn2):::process
LP11 -.-> |发送消息| LPn1
mainprogram(主程序):::process --> submodelmanager1
mainprogram --> submodelmanagern
addressserver(地址服务器):::process --> LP11
addressserver --> LP12
addressserver --> LPn1
addressserver --> LPn2
3. 全局虚拟时间
每个LP保存状态信息需要不断增加的内存,因此在仿真过程中,可用内存会耗尽。为防止这种情况,需要进行化石收集来释放不再需要的内存,这就需要计算系统的GVT。GVT还可用于显示仿真进度。
LP在实时t推进到的仿真时间称为局部虚拟时间(Local Virtual Time,LVT),由于可能回滚,LVT函数不是单调递增的。GVT(t)必须确保LP不会回滚到LVT(t) < GVT(t)的状态,其计算公式为:
[ GVT(t) = min(LVT_{min}(t), ts_{min}(t)) ]
其中,$LVT_{min}(t)$ 是所有LP的最小LVT值,$ts_{min}(t)$ 是实时t传输中消息的最小时间戳。
为避免中断仿真,通过叠加在仿真之上的计算来近似GVT。Mattern提出的通用算法基于“切割”(cuts),将每个LP的进程线分为过去和未来两部分。切割由切割事件触发,对底层仿真无直接影响。为保证GVT的基本属性,需要一致切割,即没有消息在某个LP的未来发送并在另一个LP的过去接收。
主程序作为GVT管理器,负责发起新的GVT近似计算并控制切割是否满足要求。子模型管理器根据请求确定其LP的最小LVT值,保存发送和接收消息的差异以及两种颜色消息的最小时间戳。LP在每一步更新这些值,所需的额外通信集中在主程序和子模型管理器之间。计算出有效GVT值后,主程序将其提交给所有子模型管理器,子模型管理器即可进行化石收集。
4. 化石收集与受控类型
4.1 状态保存的数据结构
LP进行回滚需要保存状态信息,使用动态数据结构如列表和栈来实现。
每个LP有一个或多个输入队列,实现为双向链表,元素按时间戳非降序插入。一个指向最后读取元素的指针标记队列的“过去”,读取的元素不会删除,因为回滚时可能需要。
每个LP关联一个状态栈,是一个经典的栈,用于保存回滚所需信息,包括特定于模型的数据、当前LVT值、每个输入队列的最后读取消息以及特定步骤中发送的所有消息。
所有在一个步骤中发送的消息及其接收地址会复制到一个输出队列,也是链表,用于回滚时使用。
输入和输出队列的每个元素由结构信息(指向下一个和必要时指向前一个元素的指针)和一个信息组件(指向消息对象的指针)组成。
实际有效的GVT值决定了栈的哪些“末尾部分”不再需要。第一个LVT < GVT的元素标记了边界,从这一点开始,栈的其余部分以及每个栈元素的输出队列都可以释放。对于每个输入队列,指向最后读取元素的指针指示可以删除已到达消息的范围。
化石收集的通用算法如下:
fossil := uppermost but one stack element with LVT < GVT;
IF fossil /= NULL THEN
FOR each input queue iq LOOP
free from begin of iq to fossil.last read(iq);
END LOOP;
WHILE fossil /= NULL LOOP
next fossil := fossil.next
free fossil.output queue;
free fossil;
fossil := next fossil;
END LOOP;
END IF;
此外,还有两种情况需要释放分配的内存:一是回滚时从栈中弹出一个或多个先前状态,相应内存可以释放,但不释放输入队列元素;二是回滚时取消不合理的临时发送消息,通过发送反消息(带有特殊标签)来实现,接收方输入队列中“真实”消息和相应反消息相互抵消,需要释放内存。
4.2 受控类型用于状态保存的探讨
乍一看,Ada语言的受控类型概念似乎很有用,尤其是对终结过程的完全控制似乎能提供一种智能高效的近乎自动释放内存的方式。但仔细研究后发现并非如此。
- 状态栈 :是化石收集的核心。栈作为数据结构与相应LP的生命周期相同,仅在化石收集时需要释放特定栈元素。使用受控类型似乎合理,但这需要两种终结方式:一是仅释放关联的输出队列;二是还需部分释放输入队列(前端部分)。第二种方式仅在化石收集时需要,而第一种在回滚和弹出状态时也会调用。
-
输出队列
:是每个栈元素的组件,栈元素的释放会导致整个输出队列释放。虽然没有指向其他数据结构的指针,似乎可以使用受控类型,但
finalize
操作在许多情况下会被调用,主要是在赋值或创建临时对象时。需要额外的adjust
操作来进行链表的硬拷贝,而输出队列的长度不可预测,这种额外工作可能成本过高,导致效率降低。 - 输入队列 :与状态栈情况类似,输入队列与LP的生命周期相同,通常是整个仿真运行时间。在化石收集时释放前端部分,受控类型可能适用于单个队列元素,但存在其他指向它们的访问值,在终结时释放内存可能会引入悬空指针。
- 输入和输出队列元素 :这些对象结构相同,存储的消息后面跟着一个指针,释放队列元素时需要释放消息对象。乍一看是受控类型的典型应用,但多次赋值和临时对象的创建会导致意外行为。例如,使用受控类型定义简化队列元素如下:
TYPE element_type;
TYPE element_pointer IS ACCESS element_type;
TYPE element_type IS NEW controlled WITH
RECORD
next : element_pointer := NULL;
info : .....;
data : message_pointer := NULL;
END RECORD;
ep : element_pointer;
mp : message_pointer := some_message;
创建一个具有给定消息对象的新元素时,可能会出现问题。
综上所述,虽然受控类型在某些方面看似能简化内存管理,但在实际应用中,由于其复杂的使用场景和潜在的问题,需要谨慎考虑是否使用。在分布式仿真系统中,合理的数据结构设计和内存管理策略对于提高系统性能和稳定性至关重要。未来的研究可以进一步探索如何优化化石收集算法和内存管理机制,以适应更复杂的仿真场景。
在分布式仿真系统中使用受控类型进行化石收集
5. 初步结果与分析
虽然文中未详细给出具体的初步结果数据,但我们可以基于前面的理论分析推测可能的结果情况。
- 性能方面 :采用分裂队列时间扭曲(SQTW)算法后,由于减少了回滚的风险,系统的整体性能理论上会有所提升。例如,早期的单处理器原型显示回滚次数减少,这意味着系统在运行过程中不必要的状态恢复操作减少,从而提高了运行效率。
- 内存管理方面 :通过引入化石收集机制,有效地防止了内存溢出的问题。当全局虚拟时间(GVT)被正确计算并用于确定哪些数据可以被释放时,系统的内存使用得到了优化。然而,受控类型在内存管理中的应用存在一定的挑战,如前面所讨论的,不同的数据结构使用受控类型可能会带来额外的开销或潜在的问题。
为了更直观地展示这些结果,我们可以构建一个简单的表格:
| 评估指标 | 采用SQTW前 | 采用SQTW后 |
| — | — | — |
| 回滚次数 | 高 | 低 |
| 内存使用情况 | 易溢出 | 得到优化 |
| 系统运行效率 | 低 | 高 |
6. 实际应用与操作步骤
在实际应用中,要实现分布式仿真系统中的化石收集和使用受控类型,需要遵循以下操作步骤:
6.1 系统搭建
- 定义逻辑进程(LP) :根据仿真的物理系统,确定需要模拟的逻辑进程,并为每个LP设计相应的行为和状态。
- 构建子模型 :将多个LP分配到同一个处理节点,形成子模型,并为每个子模型设置子模型管理器。
- 设置主程序和地址服务器 :主程序作为与实验者的接口,负责协调整个仿真过程;地址服务器保存所有LP的地址,方便消息的发送和接收。
graph LR
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
step1(定义逻辑进程):::process --> step2(构建子模型):::process
step2 --> step3(设置主程序和地址服务器):::process
6.2 实现SQTW算法
- 设计输入队列 :为每个LP设计多个输入队列,每个队列对应一种消息类型,按照时间戳非降序插入元素。
- 实现LP步骤 :在LP步骤中,根据输入队列的情况进行消息的读取和处理,同时更新局部虚拟时间(LVT)。
LOOP
IF waiting parcels < van capacity
THEN
get(message, parcel channel);
waiting parcels := waiting parcels + 1;
local time := max(message.timestamp, local time);
ELSE
get(message, van channel); -- looking for an empty van
local time := max (message.timestamp, local time);
send((van, local time + delivery time), some station);
waiting parcels := 0;
END IF;
END LOOP;
6.3 计算全局虚拟时间(GVT)
- 主程序发起计算 :主程序作为GVT管理器,定期发起新的GVT近似计算。
- 子模型管理器提供数据 :子模型管理器根据请求确定其LP的最小LVT值,保存发送和接收消息的差异以及两种颜色消息的最小时间戳,并将这些数据提供给主程序。
- 主程序计算GVT :主程序根据子模型管理器提供的数据,计算出有效的GVT值,并将其提交给所有子模型管理器。
6.4 进行化石收集
- 确定可释放数据 :根据GVT值,确定状态栈中哪些元素和输入队列的哪些部分可以被释放。
- 释放内存 :按照化石收集算法,释放相应的内存。
fossil := uppermost but one stack element with LVT < GVT;
IF fossil /= NULL THEN
FOR each input queue iq LOOP
free from begin of iq to fossil.last read(iq);
END LOOP;
WHILE fossil /= NULL LOOP
next fossil := fossil.next
free fossil.output queue;
free fossil;
fossil := next fossil;
END LOOP;
END IF;
7. 总结与展望
分布式离散事件仿真中的时间扭曲算法及其扩展SQTW为解决并行/分布式仿真中的问题提供了有效的方法。通过引入化石收集机制和全局虚拟时间的计算,系统的内存管理得到了优化,避免了内存溢出的问题。然而,受控类型在状态保存中的应用需要谨慎考虑,因为其在不同的数据结构中可能会带来额外的开销或潜在的问题。
未来的研究可以从以下几个方面展开:
-
优化GVT计算算法
:进一步提高GVT计算的准确性和效率,减少对仿真的影响。
-
改进受控类型的应用
:探索如何更好地利用受控类型的特性,同时避免其带来的问题,提高内存管理的效率。
-
扩展系统的可扩展性
:使系统能够处理更复杂的仿真场景和更多的逻辑进程。
通过不断地研究和改进,分布式仿真系统将能够更高效、稳定地运行,为各种领域的仿真需求提供更好的支持。