深入理解 Gem5 之三
深入理解 Gem5 之三——SimObject
之前的两篇博文分别介绍了 gem5 的事件触发机制和序列化问题,它们都和 SimObject 类有密切的联系。正所谓万事俱备,只欠东风。基于目前的理解,我可以更深入地看看 SimObject 类的实现方式。
父类
SimObject 类是一个非常复杂但又十分重要的类,它在 Gem5 中占有极为重要的地位。gem5 的模块化设计是围绕 SimObject 类型构建的。 模拟系统中的大多数组件都是 SimObjects 的子类,如CPU、缓存、内存控制器、总线等。gem5 将所有这些对象从其 C++ 实现导出到 python。使用提供的 python 配置脚本便可以创建任何 SimObject 类对象,设置其参数,并指定 SimObject 之间的交互。理解该类的实现有助于我们理解整个 gem5 模拟器的运作逻辑。我们先从它的父类开始讲起,它一共有 5 个父类:EventManger、Serializable、Drainable、statistics::Group、Named。
1 |
|
其中仅有 statistics::Group 类我未作介绍,最后来理解一下 statistics::Group 的作用及实现。
statistics::Group
statistics 是 gem5 项目中C++的一个命名空间,statistics::Group 类是统计数据的容器。Group 对象之间可组成一个树状的层次结构。数据统计子系统用 Groups 之间的关系来反推 SimObject 层次结构,并暴露对象内部的层次结构,从而可以更方便地将统计数据分组到它们自己的类中,然后合并到父类 Group(通常是一个 SimObject)中。
Group 类中,有一个指向父节点的指针,以及包含本级信息的数组 stats,和包含了子类 Group 数组的 statGroups 和 mergedStatGroups。
1 |
|
从构造函数可以看出,当子类 Group 未提供姓名时,使用 mergedStatGroups 存储信息。Group 类可以很轻松地构造出复杂的树状层次,例如:
1 |
|
gem5 中用 Info 类维护统计数据以及对象的基本信息,由于和本篇博文主题关系不大,不再赘述。
小结
在正式开始之前,再回顾一下五个父类各自的作用:
- EventManager 类:负责调度、管理、执行事件。EventManager 类是对 EventQueue 类的包装,SimObject 对象中所有的事件实际都由 EventQueue 队列管理。该队列以二维的单链表的形式管理着所有事件,事件以触发时间点从近到远排列。
- Serializable 类:负责对象的序列化。SimObjects 可通过
SimObject::serializeAll()
函数自动完成序列化,写入到自己的 sections 中。Serializable 类根据 SimObject 类对象的名字以及对象间的包含关系,帮助用户构建起了层次化的序列化模型,并使用该模型完成 SimObject 的序列化,以 ini 文件格式输出。 - Drainable 类:负责 drain 对象。DrainManager 类以单例的方式管理整个模拟器的 drain 过程。只有系统中所有的对象都被 drained,才能开始序列化、更改模型等操作。完成后需要使用
DrainManager::resume()
函数将系统回归到正常运行状态。 - statistics::Group 类:负责运行过程中统计、管理数据。Group 对象之间可组成树状层次,从而反应出 SimObject 对象间的树状层次。
- Name 类:负责给 SimObject 起名。
SimObject
对其父类有了充足的理解后,我们再来看一下 SimObject 类中的静态变量:
1 |
|
SimObject 类中维护了一个数组,记录所有被例化的对象,方便统一管理。SimObjectResolver 类根据传入的 SimObject 路径名字,解析出 SimObject 对象;维护 simObjectList 数组的目的是方便实现 serializeAll()
函数,也方便用户通过对象名找到对应的 SimObject。
再来看看 SimObject 类有哪些成员:
1 |
|
ProbeManager 类是一个可连接探测点和监视器的协调类。所谓探测点主要用于 PMU(Performance Measurement Unit) 的实现,PMU 在 RTL 实现中,通常用于评估处理器模块的性能。而在 gem5 模拟器中,需要使用探测点(Probe Point)为 SimObject 类实现 PMU 提供了统一的接口,从而易于维护,simobject 对象调用 notify 时,需将事件计数增量作为它的唯一参数。
从 gem5 的官方文档中,可了解到 simulate.py 使用以下函数完成对模拟对象的初始化:
SimObject::init()
只有当 C++ SimObject 对象被创建,且所有接口都被连上后,该函数会被调用SimObject::regStats()
本是 Group 类的回调函数,用于设置需要复杂参数的统计信息。
(例如,分布)SimObject::initState()
若 SimObject 不是从检查点恢复时,需要调用该函数。该函数标记了状态的初始点,仅在冷启动时会被使用,让 simobject 回到初始状态。SimObject::loadState()
若从检查点恢复,调用该函数。其默认实现是调用unserialize()
函数。因为从检查点恢复的过程就如同序列化后,装载之前保存的状态的过程。
SimObject::resetStats()
重置统计数据。SimObject::startup()
是模拟前的最终的启动函数。此时所有状态都已初始化(包括未序列化的状态,如果有的话,如curTick()
的值),因此这是调度初始事件的合适时间点。- Drainable::drainResume() 如果从检查点恢复。
以上这些函数(除 loadState()
有默认非空的实现)都需要继承 SimObject 类的派生类来实现。SimObject 类只是搭建了模拟对象的运行框架,规定了对象的运行步骤。
最后再介绍一些有意义的成员函数:
1 |
|
其中,getPort()
用于获取给定名称和索引的端口。通常在绑定时使用,返回对协议无关端口的引用。
注意到,gem5 有一对请求和响应端口接口。 所有内存对象都通过端口连接在一起。这些端口在内存对象之间提供了三种不同的内存系统模式:时序(timing)、原子(atomic)和功能(functional)。 最重要的模式是时序模式,即 cycle-level 级别的时序周期模式。其他模式仅在特殊情况下使用,这些端口可以让 SimObject 相互通信。
memWriteback()
函数将脏缓冲区写回内存。函数完成后,对象内所有脏数据都已写回内存。带缓存的系统通常用该函数来为检查点前做准备。memInvalidate()
函数使内存缓冲区的内容无效。当切换到硬件虚拟化 CPU 模型时,我们需要确保系统中没有任何在我们返回时陈旧的缓存数据。该函数将所有此类状态刷新回主存储器,但它不会将任何脏状态写回内存。
时钟 与 ClockedObject
接下来,研究一个常见的 SimObject 派生类 ClockedObject,同时也了解一下 Gem5 中时钟的实现方式
时钟
curTick()
全局函数,通常使用 curTick()
来获取全局时钟值。
1 |
|
__thread
将变量存储到线程的局部空间中,在线程的生命周期内有效。因此,在多线程程序中,每个线程都创建了该变量的唯一实例,并在线程终止时销毁。__thread
存储类说明符能被 gcc 识别,可确保线程安全:因为变量被多个线程访问时无需担心竞争,同时避免处理线程同步带来的繁琐编程。
Clocked 类
Clocked 类为 SimObject 类提供时钟周期模拟。Gem5 在模拟 SimObject 对象的工作流程时,会模拟硬件中时钟打拍行为,进而得到准确的模拟性能。
1 |
|
Clocked 类中有三个变量:
- tick 变量类型为
uint64_t
,指示下一个时钟边缘沿到来的 tick 值。tick 值是模拟器中时间的最小单位 - cycle 类型为 Cycles,该类表示当前经过的时钟周期总数,其内部包装了
uint64_t
。这是硬件中常说的时钟周期。之所以不直接使用uint64_t
,是为避免混淆 Tick 和 Cycles 这两个类型。 - clockDomain 表示位于的时钟域。时钟域是若干个共享同一个时钟的 SimObject 对象集合。其中,
clockPeriod()
记录了时钟的周期(单位:Tick)。
Clocked 类中最重要的函数就是 updateClockPeriod()
。
1 |
|
其中,clockPeriodUpdated()
函数是 hook 函数,由子类负责实现,用来增加一些与时钟周期打拍有关的功能。而 update()
函数的实现如下:
1 |
|
update()
对齐了 cycle 和 tick 到下一个时钟沿,若 tick >= curTick(),即当前 tick 已经与全局时钟对齐时,那么时钟就是最新的。大部分情况下,累加一个时钟周期就可以达到最新,但也有例外,需要增加更多的时钟周期才行。
clockEdge()
函数根据传入的 cycles 数,换算出未来达到这一时钟周期数要求的 tick 数。
1 |
|
ClockedObject
ClockedObject 类继承了 Clocked 类和 SimObject 类,以 Tick 与对象的 cycle 相关联。其中 PowerState 类也是 SimObject 类的派生类,它提供了描述功耗状态和切换功耗状态的功能。
1 |
|
在 gem5 的教程代码 part2 中,使用 ClockedObject 类实现了一个简单的 Cache:SimpleCache 类。借助这一例子,我们可以从中看出 SimObject 类以及其派生类在模拟系统时的作用。
SimpleCache
现在,根据我之前对 gem5 底层的了解,我试图理解 SimpleCache 类中的实现原理。SimpleCache 类描述的 Cache 在硬件上是一种
- 全相联的 Cache
- 使用随机替换算法来实现新旧 Cacheline 替换
- 只能同时处理一个请求
- 写回式的 Cache
要实现这样一个 SimpleCache,首先需要1)连接 Cache 的端口。2)Cache 的块大小以及容量,存储数据等。3)Cache 的命中、丢失延迟等。这些需求都在下面的类定义中有所体现:
1 |
|
对于函数实现,直接看我们能理解的部分。例如,handleRequest()
函数用于处理来自 CPU 的 Cache 访问/读写请求。下面代码展示了请求被处理的过程:
1 |
|
由于 Cache 一次只能处理一个请求,因此当 Cache 状态为 blocked 时,请求无法被处理,直接返回 false。否则,将状态设置为 blocked,然后创建一个 Event 事件并调度:
1 |
|
回顾一下之前博客中的分析,schedule()
函数负责事件调度,其参数是将要被执行的事件 event 和具体执行时间 when。在这里,被执行的事件 event 就是 accessTiming(pkt)
函数,它被 EventFunctionWrapper 进一步封装,出入到 schedule()
中,而具体执行的时间,是 clockEdge(latency)
。其中 latency 是 Cache 对于检查数据是否命中的延迟,而 clockEdge()
将 latency 的时间单位从 cycles 转换为 ticks 的函数。当时间和事件本身都被设置好后,如同前面提到的那样,事件会按照调度的时间先后顺序,被放入事件队列中等待执行。最后产生的效果就是,等待了 latency 个时钟周期后,这一请求被 Cache 执行并完成。
accessTiming(pkt)
函数功能是访问 Cache,判断读取的请求是否命中,并统计出所需要的延迟。如果命中,那么直接进入返回应答的操作,发送应答消息给 CPU 端:
1 |
|
而若没有命中,那么需要先记录开始替换的时刻,然后进行目标块的替换。这里需要注意到,因为请求的地址不一定与块地址大小对齐,而内存端口取 Cache 块大小时要求必须是块的首地址。因此,需要分情况讨论是否可以直接访问内存取块(即forward),若未对齐,那么需要重新生成一个块对齐的 Packet 来访问 DRAM 端。
1 |
|
SimpleCache 类其余部分的实现也非常有意思,但与该博文的主题偏离太远,我们之后再来细细品读。