深入理解 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
2
class SimObject : public EventManager, public Serializable, public Drainable,
public statistics::Group, public Named

其中仅有 statistics::Group 类我未作介绍,最后来理解一下 statistics::Group 的作用及实现。

statistics::Group

statistics 是 gem5 项目中C++的一个命名空间,statistics::Group 类是统计数据的容器。Group 对象之间可组成一个树状的层次结构。数据统计子系统用 Groups 之间的关系来反推 SimObject 层次结构,并暴露对象内部的层次结构,从而可以更方便地将统计数据分组到它们自己的类中,然后合并到父类 Group(通常是一个 SimObject)中。

Group 类中,有一个指向父节点的指针,以及包含本级信息的数组 stats,和包含了子类 Group 数组的 statGroups 和 mergedStatGroups。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Group {
/** Parent pointer if merged into parent */
Group *mergedParent;
std::map<std::string, Group *> statGroups;
std::vector<Group *> mergedStatGroups;
std::vector<Info *> stats;
}

Group::Group(Group *parent, const char *name)
: mergedParent(nullptr) {
if (parent && name) {
parent->addStatGroup(name, this);
} else if (parent && !name) {
parent->mergeStatGroup(this);
}
}

从构造函数可以看出,当子类 Group 未提供姓名时,使用 mergedStatGroups 存储信息。Group 类可以很轻松地构造出复杂的树状层次,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// test code
statistics::Group root(nullptr);
statistics::Group node1(&root, "Node1");
statistics::Group node2(&root, "Node2");
statistics::Group node1_1(&node1, "Node1_1");
statistics::Group node2_1(&node2, "Node2_1");
statistics::Group node2_2(&node2, "Node2_2");

/* we can get
* root
* / \
* node1 node2
* | / \
* node1_1 node2_1 node2_2
*/

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
2
3
4
5
6
7
8
class SimObject : public EventManager, public Serializable, public Drainable,
public statistics::Group, public Named {
private:
typedef std::vector<SimObject *> SimObjectList;
static SimObjectList simObjectList;

/** Helper to resolve an object given its name. */
static SimObjectResolver *_objNameResolver;

SimObject 类中维护了一个数组,记录所有被例化的对象,方便统一管理。SimObjectResolver 类根据传入的 SimObject 路径名字,解析出 SimObject 对象;维护 simObjectList 数组的目的是方便实现 serializeAll() 函数,也方便用户通过对象名找到对应的 SimObject。

再来看看 SimObject 类有哪些成员:

1
2
3
4
5
6
7
8
class SimObject : public EventManager, public Serializable, public Drainable,
public statistics::Group, public Named {
private:
/** Manager coordinates hooking up probe points with listeners. */
ProbeManager *probeManager;
/** Cached copy of the object parameters. */
const SimObjectParams &_params;
};

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
2
3
4
5
6
7
8
class SimObject {
public:
virtual Port &getPort(const std::string &if_name, PortID idx=InvalidPortID);
/** Write back dirty buffers to memory using functional writes. */
virtual void memWriteback() {};
/** Invalidate the contents of memory buffers. */
virtual void memInvalidate() {};
}

其中,getPort() 用于获取给定名称和索引的端口。通常在绑定时使用,返回对协议无关端口的引用。

注意到,gem5 有一对请求和响应端口接口。 所有内存对象都通过端口连接在一起。这些端口在内存对象之间提供了三种不同的内存系统模式:时序(timing)、原子(atomic)和功能(functional)。 最重要的模式是时序模式,即 cycle-level 级别的时序周期模式。其他模式仅在特殊情况下使用,这些端口可以让 SimObject 相互通信。

memWriteback() 函数将脏缓冲区写回内存。函数完成后,对象内所有脏数据都已写回内存。带缓存的系统通常用该函数来为检查点前做准备。memInvalidate() 函数使内存缓冲区的内容无效。当切换到硬件虚拟化 CPU 模型时,我们需要确保系统中没有任何在我们返回时陈旧的缓存数据。该函数将所有此类状态刷新回主存储器,但它不会将任何脏状态写回内存。

时钟 与 ClockedObject

接下来,研究一个常见的 SimObject 派生类 ClockedObject,同时也了解一下 Gem5 中时钟的实现方式

时钟

curTick() 全局函数,通常使用 curTick() 来获取全局时钟值。

1
2
3
4
typedef uint64_t Tick;
__thread Tick *_curTickPtr;

inline Tick curTick() { return *Gem5Internal::_curTickPtr; }

__thread 将变量存储到线程的局部空间中,在线程的生命周期内有效。因此,在多线程程序中,每个线程都创建了该变量的唯一实例,并在线程终止时销毁。__thread 存储类说明符能被 gcc 识别,可确保线程安全:因为变量被多个线程访问时无需担心竞争,同时避免处理线程同步带来的繁琐编程。

Clocked 类

Clocked 类为 SimObject 类提供时钟周期模拟。Gem5 在模拟 SimObject 对象的工作流程时,会模拟硬件中时钟打拍行为,进而得到准确的模拟性能。

1
2
3
4
5
6
7
8
9
class Clocked {
/** Tick value of the next clock edge (>= curTick()) at the
* time of the last call to update() */
mutable Tick tick;
/* Cycle counter value corresponding to the current value of 'tick' */
mutable Cycles cycle;
/* The clock domain this clocked object belongs to */
ClockDomain &clockDomain;
}

Clocked 类中有三个变量:

  • tick 变量类型为 uint64_t,指示下一个时钟边缘沿到来的 tick 值。tick 值是模拟器中时间的最小单位
  • cycle 类型为 Cycles,该类表示当前经过的时钟周期总数,其内部包装了 uint64_t。这是硬件中常说的时钟周期。之所以不直接使用 uint64_t,是为避免混淆 Tick 和 Cycles 这两个类型。
  • clockDomain 表示位于的时钟域。时钟域是若干个共享同一个时钟的 SimObject 对象集合。其中,clockPeriod() 记录了时钟的周期(单位:Tick)。

Clocked 类中最重要的函数就是 updateClockPeriod()

1
2
3
4
5
6
7
// Update the tick to the current tick
void updateClockPeriod() {
// tick and cycle update
update();
// hook function to add extra work.
clockPeriodUpdated();
}

其中,clockPeriodUpdated() 函数是 hook 函数,由子类负责实现,用来增加一些与时钟周期打拍有关的功能。而 update() 函数的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
void update() const {
if (tick >= curTick())
return;
/** in most case, add one cycle */
tick += clockPeriod();
++cycle;
if (tick >= curTick()
return;
/* special case, add one more cycle. divCeil(a, b): (a + b -1) / b */
Cycles elapsedCycles(divCeil(curTick() - tick, clockPeriod()));
cycle += elapsedCycles;
tick += elapsedCycles * clockPeriod();
}

update() 对齐了 cycle 和 tick 到下一个时钟沿,若 tick >= curTick(),即当前 tick 已经与全局时钟对齐时,那么时钟就是最新的。大部分情况下,累加一个时钟周期就可以达到最新,但也有例外,需要增加更多的时钟周期才行。

clockEdge() 函数根据传入的 cycles 数,换算出未来达到这一时钟周期数要求的 tick 数。

1
2
3
4
5
6
Tick clockEdge(Cycles cycles = Cycles(0)) const {
// align tick to the next clock edge
update();
// figure out when this future cycle is
return tick + clockPeriod() * cycles;
}

ClockedObject

ClockedObject 类继承了 Clocked 类和 SimObject 类,以 Tick 与对象的 cycle 相关联。其中 PowerState 类也是 SimObject 类的派生类,它提供了描述功耗状态和切换功耗状态的功能。

1
2
3
4
5
6
7
8
9
10
11
class ClockedObject : public SimObject, public Clocked {
public:
ClockedObject(const ClockedObjectParams &p);

/** Parameters of ClockedObject */
using Params = ClockedObjectParams;

void serialize(CheckpointOut &cp) const override;
void unserialize(CheckpointIn &cp) override;
PowerState *powerState;
}

在 gem5 的教程代码 part2 中,使用 ClockedObject 类实现了一个简单的 Cache:SimpleCache 类。借助这一例子,我们可以从中看出 SimObject 类以及其派生类在模拟系统时的作用。

SimpleCache

现在,根据我之前对 gem5 底层的了解,我试图理解 SimpleCache 类中的实现原理。SimpleCache 类描述的 Cache 在硬件上是一种

  • 全相联的 Cache
  • 使用随机替换算法来实现新旧 Cacheline 替换
  • 只能同时处理一个请求
  • 写回式的 Cache

要实现这样一个 SimpleCache,首先需要1)连接 Cache 的端口。2)Cache 的块大小以及容量,存储数据等。3)Cache 的命中、丢失延迟等。这些需求都在下面的类定义中有所体现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class SimpleCache : public ClockedObject {
// Latency to check the cache. Number of cycles for both hit and miss
const Cycles latency;

// The block size for the cache
const unsigned blockSize;

// Number of blocks in the cache (size of cache / block size)
const unsigned capacity;

// Instantiation of the CPU-side port
std::vector<CPUSidePort> cpuPorts;

// Instantiation of the memory-side port
MemSidePort memPort;

// True if this cache is currently blocked waiting for a response.
bool blocked;

// Packet that we are currently handling. Used for upgrading to larger
// cache line sizes
PacketPtr originalPacket;

// The port to send the response when we recieve it back
int waitingPortId;

// For tracking the miss latency
Tick missTime;

// An incredibly simple cache storage. Maps block addresses to data
std::unordered_map<Addr, uint8_t*> cacheStore;
}

对于函数实现,直接看我们能理解的部分。例如,handleRequest() 函数用于处理来自 CPU 的 Cache 访问/读写请求。下面代码展示了请求被处理的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bool SimpleCache::handleRequest(PacketPtr pkt, int port_id) {
if (blocked) {
// There is currently an outstanding request so we can't respond. Stall
return false;
}
DPRINTF(SimpleCache, "Got request for addr %#x\n", pkt->getAddr());

// This cache is now blocked waiting for the response to this packet.
blocked = true;

// Store the port for when we get the response
assert(waitingPortId == -1);
waitingPortId = port_id;

// Schedule an event after cache access latency to actually access
schedule(new EventFunctionWrapper([this, pkt]{accessTiming(pkt);},
name() + ".accessEvent", true), clockEdge(latency));
return true;
}

由于 Cache 一次只能处理一个请求,因此当 Cache 状态为 blocked 时,请求无法被处理,直接返回 false。否则,将状态设置为 blocked,然后创建一个 Event 事件并调度:

1
2
schedule(new EventFunctionWrapper([this, pkt]{accessTiming(pkt);},
name() + ".accessEvent", true), clockEdge(latency));

回顾一下之前博客中的分析,schedule() 函数负责事件调度,其参数是将要被执行的事件 event 和具体执行时间 when。在这里,被执行的事件 event 就是 accessTiming(pkt) 函数,它被 EventFunctionWrapper 进一步封装,出入到 schedule() 中,而具体执行的时间,是 clockEdge(latency)。其中 latency 是 Cache 对于检查数据是否命中的延迟,而 clockEdge() 将 latency 的时间单位从 cycles 转换为 ticks 的函数。当时间和事件本身都被设置好后,如同前面提到的那样,事件会按照调度的时间先后顺序,被放入事件队列中等待执行。最后产生的效果就是,等待了 latency 个时钟周期后,这一请求被 Cache 执行并完成。

accessTiming(pkt) 函数功能是访问 Cache,判断读取的请求是否命中,并统计出所需要的延迟。如果命中,那么直接进入返回应答的操作,发送应答消息给 CPU 端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void SimpleCache::accessTiming(PacketPtr pkt) {
bool hit = accessFunctional(pkt);
DPRINTF(SimpleCache, "%s for packet: %s\n", hit ? "Hit" : "Miss",
pkt->print());

if (hit) {
// Respond to the CPU side
stats.hits++; // update stats
DDUMP(SimpleCache, pkt->getConstPtr<uint8_t>(), pkt->getSize());
pkt->makeResponse();
sendResponse(pkt);
} else {
/* ... */
}
}

而若没有命中,那么需要先记录开始替换的时刻,然后进行目标块的替换。这里需要注意到,因为请求的地址不一定与块地址大小对齐,而内存端口取 Cache 块大小时要求必须是块的首地址。因此,需要分情况讨论是否可以直接访问内存取块(即forward),若未对齐,那么需要重新生成一个块对齐的 Packet 来访问 DRAM 端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
else {
stats.misses++; // update stats
missTime = curTick();
// Forward to the memory side.
// We can't directly forward the packet unless it is exactly the size
// of the cache line, and aligned. Check for that here.
Addr addr = pkt->getAddr();
Addr block_addr = pkt->getBlockAddr(blockSize);
unsigned size = pkt->getSize();
if (addr == block_addr && size == blockSize) {
// Aligned and block size. We can just forward.
DPRINTF(SimpleCache, "forwarding packet\n");
memPort.sendPacket(pkt);
} else {
DPRINTF(SimpleCache, "Upgrading packet to block size\n");
panic_if(addr - block_addr + size > blockSize,
"Cannot handle accesses that span multiple cache lines");
// Unaligned access to one cache block
assert(pkt->needsResponse());
MemCmd cmd;
if (pkt->isWrite() || pkt->isRead()) {
// Read the data from memory to write into the block.
// We'll write the data in the cache (i.e., a writeback cache)
cmd = MemCmd::ReadReq;
} else {
panic("Unknown packet type in upgrade size");
}

// Create a new packet that is blockSize
PacketPtr new_pkt = new Packet(pkt->req, cmd, blockSize);
new_pkt->allocate();

// Should now be block aligned
assert(new_pkt->getAddr() == new_pkt->getBlockAddr(blockSize));

// Save the old packet
originalPacket = pkt;

DPRINTF(SimpleCache, "forwarding packet\n");
memPort.sendPacket(new_pkt);
}

SimpleCache 类其余部分的实现也非常有意思,但与该博文的主题偏离太远,我们之后再来细细品读。


深入理解 Gem5 之三
https://dingfen.github.io/2022/03/13/2022-3-13-gem5-3/
作者
Bill Ding
发布于
2022年3月13日
更新于
2024年4月9日
许可协议