深入理解 Gem5 之二

深入理解 Gem5 之二

紧接着对 gem5 事件机制的研究,本篇博文我重点研究了 gem5 中对象序列化的操作。总的来说,gem5 在对模拟器中的对象序列化前,需要先将其排水(Drain,由于中文翻译的限制,下文统称为 Drain),将不确定的状态先清除,等到一切安排妥当后,再将对象序列化到磁盘中。

Drain

DrainState

之前的博文详细解释了事件在 gem5 的作用以及其实现机制。本文开始介绍 gem5 中其他比较重要的机制。

当 gem5 正常运行时,模拟器中的对象在一开始时都处于 DrainState::Running 状态,并用事件驱动模拟器的运行,这会导致很多对象在运行时处于似是而非的状态——部分信号正在传递,部分程序正在运行,缓冲区还待处理等。然而,模拟器总要在某些时刻有所停顿——准备快照(snapshot)、准备移交 CPU 等。这时候就需要引入 drain 的概念,将这些中间态清除。drain 指系统清空 SimObject 对象中内部状态的过程。通常,drain 会在序列化、创建检查点、切换 CPU 模型或 timing 模型前使用。对象会调用 drain() 函数将对象转移到 draining 或 drained 状态。然后进入 drained 状态。下面的代码介绍了四种 drain 状态。

1
2
3
4
5
6
enum class DrainState {
Running, /**< Running normally */
Draining, /**< Draining buffers pending serialization/handover */
Drained, /**< Buffers drained, ready for serialization/handover */
Resuming, /**< Transient state while the simulator is resuming */
};

当某一个对象进入 drained 状态(drain::drain() 返回 DrainState::drained 时表示该对象已经 drain 干净了)后,模拟仍会继续,直到所有对象都进入 drained 状态。如果该对象需要更多的时间来处理,那么它返回 DrainState::draining 状态。注意:一个对象的 drain 状态可能会被其他状态干扰,因此模拟器需要不停地重复 drain 来保证所有对象已经进入 DrainState::drained 状态。当系统不再需要所有对象维持在 drained 状态时,会调用 resume() 函数,它将让所有对象调用 drainResume() 返回到正常 DrainState::Running 的状态。注意,在恢复过程中可能会创建新的 Drainable 对象。在这种情况下,新对象将在 Resuming 状态下创建,然后再恢复到正常。

drain 的工作流程

根据 gem5 的文档以及源文件中的注释,总结一下 drain 工作的主要流程:

  1. 调用 DrainManager::tryDrain() 函数,该函数会让每个对象调用 Drainable::drain() 函数。如果它们全部返回 true,则 drain 已经完成。否则,DrainManager 将跟踪仍在 draining 的对象。
  2. 模拟器会继续仿真。当一个对象完成 drain 时,它会调用 DrainManager::signalDrainDone() 函数,向 DrainManager 报告 drain 已完成。
  3. 检查是否有对象仍然需要 drain(DrainManager::tryDrain()),如果是,重复上面的过程。
  4. 一旦模拟器中的所有对象的内部状态被清空,这些对象就被序列化到磁盘上,或者发生配置更改:切换CPU模型或更改 timing 模型,总之做一些只能在 drained 后做的事情。
  5. 完成后,调用 DrainManager::resume() 函数,该函数会让所有对象调用 Drainable::drainResume(),返回到正常运行的状态。

接下来,我们随着代码逐步分析上面的工作流程:

DrainManager

DrainManager 类负责管理全局对象的 drain 工作,显然它必须是个单例。它内部维护了一个包含全局的可 Drainable 的对象数组 _allDrainable,以方便管理所有对象的 drain 工作,并用一个状态变量 _state 指示模拟器的状态。

1
2
3
4
5
6
7
8
9
10
11
12
class DrainManager {
public:
/** singleton DrainManager instance */
static DrainManager &instance() { return _instance; }
private:
/** Global simulator drain state */
DrainState _state;
/** Lock protecting the set of drainable objects */
mutable std::mutex globalLock;
/** Set of all drainable objects */
std::vector<Drainable *> _allDrainable;
};

从代码上看,第一步中的 tryDrain() 函数实现其实很简单,就是通过 for 循环让每个对象调用 Drainable::drain() 函数,记录并输出 drain 失败的对象(若存在的话),统计其个数,必要时需请求下一轮 drain。

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
// in DrainManager:  
public:
bool tryDrain() {
// 1. change simulator state to Draining
_state = DrainState::Draining;
// 2. let all Drainable objects to drain, call dmDrain()
for (auto *obj : _allDrainable) {
DrainState status = obj->dmDrain();
if (debug::Drain && status != DrainState::Drained) {
Named *temp = dynamic_cast<Named*>(obj);
if (temp)
DPRINTF(Drain, "Failed to drain %s\n", temp->name());
}
_count += status == DrainState::Drained ? 0 : 1;
}
if (_count == 0) {
// Drain done.
_state = DrainState::Drained;
return true;
} else {
DPRINTF(Drain, "Need another drain cycle. %u/%u objects not ready.\n",
_count, drainableCount());
return false;
}
}

题外话,Named 类为 SimObject 对象提供了名字,所有 SimObject 对象都继承了该类。

1
2
3
4
5
6
7
8
9
/** Interface for things with names. */
class Named {
private:
const std::string _name;
public:
Named(const std::string &name_) : _name(name_) { }
virtual ~Named() = default;
virtual std::string name() const { return _name; }
};

此外,在创建一个可 Drainable 的类对象时,DrainManager 类通过注册机制来管理这些对象:

1
2
3
4
5
6
7
8
9
10
void DrainManager::registerDrainable(Drainable *obj) {
std::lock_guard<std::mutex> lock(globalLock);
_allDrainable.push_back(obj);
}

void DrainManager::unregisterDrainable(Drainable *obj) {
std::lock_guard<std::mutex> lock(globalLock);
auto o = std::find(_allDrainable.begin(), _allDrainable.end(), obj);
_allDrainable.erase(o);
}

Drainable

至于 Drainable 类,它是 SimObject 类中的一个基类。Drainable 的所有派生类都是可 Drain 的,drain() 函数要求所有派生类都必须实现,dmDrain() 函数是为 DrainManager 类方便调用 drain() 而实现的。Drainable 类包括了一个指示状态的变量以及指向全局 DrainManager 类的指针(引用)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Drainable {
friend class DrainManager;
protected:
virtual DrainState drain() = 0;
virtual void drainResume() {};
private:
/** interface for DrainManager */
DrainState dmDrain();
void dmDrainResume();
/** Convenience reference to the global DrainManager */
DrainManager &_drainManager;
/**
* Current drain state of the object. Needs to be mutable since
* objects need to be able to signal that they have transitioned
* into a Drained state even if the calling method is const.
*/
mutable DrainState _drainState;
};

当一个对象完成 drain 后,调用 signalDrainDone() 函数,该函数会通知 DrainManager 其 drain 工作已完成。若 tryDrain() 函数返回值为 false,那么就需要不停地调用 tryDrain() ,此时模拟仍将继续。直到所有对象完成 drain。此时,DrainManager 会退出模拟循环(exitSimLoop()),开始进行第四步中所说的其他操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void signalDrainDone() const {
switch (_drainState) {
case DrainState::Running:
case DrainState::Drained:
case DrainState::Resuming:
return;
case DrainState::Draining:
_drainState = DrainState::Drained;
_drainManager.signalDrainDone();
return;
}
}

void DrainManager::signalDrainDone() {
assert(_count > 0);
if (--_count == 0) {
DPRINTF(Drain, "All %u objects drained..\n", drainableCount());
exitSimLoop("Finished drain", 0);
}
}

第五步中,要让系统返回正常运行状态。DrainManager 类要使用 DrainManager::resume() 函数,将 drained 系统返回到正常状态:for 循环中让每个对象调用 dmDrainResume() 函数。dmDrainResume() 就是对 drainResume() 的包装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/** Resume normal simulation in a Drained system. */
void DrainManager::resume() {
// New objects (i.e., objects created while resuming) will
// inherit the Resuming state from the DrainManager.
_state = DrainState::Resuming;
do {
for (auto *obj : _allDrainable) {
if (obj->drainState() != DrainState::Running) {
assert(obj->drainState() == DrainState::Drained ||
obj->drainState() == DrainState::Resuming);
obj->dmDrainResume();
}
}
} while (!allInState(DrainState::Running));
_state = DrainState::Running;
}

Serialization

Serialization(序列化)指将对象转换成二进制,以方便长期保存、网络传递等。而 deserialization(反序列化)就是将二进制转换成对象。当前文提到的 drain 操作完成后,通常会跟上对象的序列化操作,将对象转换成二进制,用作快照(snapshot)保存或切换模型。在 gem5 中,Serializable 类为 SimObject 类提供序列化支持。Serializable 类通常用于创建检查点(Checkpoints)。所谓检查点其本质上是模拟的快照。当模拟需要非常长的时间时(几乎总是如此),用户可以使用检查点,在自己感兴趣的时间处加上检查点,以便稍后使用 DerivO3CPU 从该检查点恢复。

检查点创建与使用

通常,检查点会保存在新的文件夹目录 cpt.TICKNUMBER,其中 TICKNUMBER 指 要插入的检查点的 tick 时间值。要创建新的检查点,有以下几种方法:

  • 启动 gem5 模拟器后,执行 m5 的命令插入检查点。
  • 一个伪指令可以用来创建检查点。例如,可以在应用程序中包含这个pseduo指令,以便当应用程序达到某种状态时创建检查点
  • 使用 --take-checkpoints 可以周期性的输出检查点,–checkpoint-at-end 用于在模拟后创建检查点

使用Ruby内存模型创建检查点时,必须使用MOESI锤协议。这是因为检查指向正确的内存状态要求缓存刷新到内存中。这种刷新操作目前仅支持MOESI锤协议。

从检查点恢复通常可以很容易地从命令行完成:

1
2
3
build/<ISA>/gem5.debug configs/example/fs.py -r N
OR
build/<ISA>/gem5.debug configs/example/fs.py --checkpoint-restore=N

整数N表示检查点编号,通常从1开始递增。

CheckPointIn

深入理解序列化的实现离不开对 CheckpointIn 类的剖析。该类主要负责完成检查点的创建与恢复工作。_cptDir 就是检查点保存的目录位置,db 表示 ini 文件。ini 文件就是由很多 section 组成的初始化文件,其中每个 section 包含有若干 key-value 的 entry。通过 ini 文件以及 IniFile 类,检查点将模拟时的对象保存在了磁盘中,这便是序列化。

1
2
3
4
5
6
7
8
9
class CheckpointIn {
private:
IniFile db;
const std::string _cptDir;
// current directory we're serializing into.
static std::string currentDirectory;
// Filename for base checkpoint file within directory.
static const char *baseFilename;
};

IniFile 类详细实现了 ini 文件的读写,section 的查询、访问等,这不是本博客的主题,不在详述。

Serializable

任何继承并实现此接口的对象都可以包含在 gem5 的检查点系统中。所有支持序列化的对象都应该继承该类。继承该类对象可大致分为两类:1)真正的 SimObjects(继承了 SimObject 类,SimObject 类继承了 Serializable 类)和 2)未继承 SimObject 类,仅继承 Serializable 类的普通对象。

SimObjects 可通过 SimObject::serializeAll() 函数自动完成序列化,写入到自己的 sections 中。前文提到,SimObjects 也可以包含其他未继承 SimObject 类的可序列化对象。然而,这些“普通”的可序列化成员不会自动序列化,因为它们没有 SimObject::serializeAll() 函数。因此有这些对象的类在实现时需要主动调用其序列化/反序列化函数,以完成序列化。

其中,首选方法是使用 serializeSection() 函数,这会将序列化对象放入当前 section(此section 就是 ini 文件中的section) 中的新 subsection。另一种选择是直接调用 serialize() ,它将对象序列化到当前 section,但不推荐使用后者,因为这会导致可能存在的命名冲突。下面代码给出了 Serializable 类中最重要的函数。serialize()unserializa() 函数都需要子类实现。serializeAll() 函数,从后往前(why?)遍历所有的对象,调用 serializeSection() 函数将它们序列化。因此,不应在其他任何地方对 SimObject 对象调用序列化函数;否则,这些对象将被不必要地序列化多次。

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
/* In class SimObject
* Create a checkpoint by serializing all SimObjects in the system.*/
static void serializeAll(const std::string &cpt_dir) {
std::ofstream cp;
Serializable::generateCheckpointOut(cpt_dir, cp);
SimObjectList::reverse_iterator ri = simObjectList.rbegin();
SimObjectList::reverse_iterator rend = simObjectList.rend();
for (; ri != rend; ++ri) {
SimObject *obj = *ri;
// This works despite name() returning a fully qualified name
// since we are at the top level.
obj->serializeSection(cp, obj->name());
}
}

class Serializable {
/* Serialize an object
* Output an object's state into the current checkpoint section. */
virtual void serialize(CheckpointOut &cp) const = 0;

/* Unserialize an object
* Read an object's state from the current checkpoint section. */
virtual void unserialize(CheckpointIn &cp) = 0;

/* Serialize an object into a new section in a checkpoint
* and calls serialize() to serialize the current object into
* the new section. */
void serializeSection(CheckpointOut &cp, const char *name) const;
private:
static std::stack<std::string> path;
}

注意到 Serializable 类中仅维护了一个路径堆栈 path,实时记录对象所在的位置。下面代码可以看出,当新的 section 被创建后,其名字会被压栈进入到 path 中。ScopedCheckpointSection 类是为命名 section 时更加方便:section 名字便是该对象所在的系统位置。例如,Section1.Section2.Section3 表示对象从内到外处于Section3 Section2 Section1 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Serializable::ScopedCheckpointSection(CP &cp, const char *name) {
pushName(name);
nameOut(cp);
}

void Serializable::ScopedCheckpointSection::pushName(const char *obj_name) {
if (path.empty()) {
path.push(obj_name);
} else {
path.push(csprintf("%s.%s", path.top(), obj_name));
}
DPRINTF(Checkpoint, "ScopedCheckpointSection::pushName: %s\n", obj_name);
}


void Serializable::ScopedCheckpointSection::nameOut(CheckpointOut &cp) {
DPRINTF(Checkpoint, "ScopedCheckpointSection::nameOut: %s\n",
Serializable::currentSection());
cp << "\n[" << Serializable::currentSection() << "]\n";
}
1
2
3
4
Serializable::ScopedCheckpointSection::~ScopedCheckpointSection() {
DPRINTF(Checkpoint, "Popping: %s\n", path.top());
path.pop();
}

下面给出创建检查点的函数。给定了文件目录名后,创建文件夹和 ini 文件,然后输出检查点即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
void Serializable::generateCheckpointOut(const std::string &cpt_dir,
std::ofstream &outstream) {
std::string dir = CheckpointIn::setDir(cpt_dir);
if (mkdir(dir.c_str(), 0775) == -1 && errno != EEXIST)
fatal("couldn't mkdir %s\n", dir);

std::string cpt_file = dir + CheckpointIn::baseFilename;
outstream = std::ofstream(cpt_file.c_str());
time_t t = time(NULL);
if (!outstream)
fatal("Unable to open file %s for writing\n", cpt_file.c_str());
outstream << "## checkpoint generated: " << ctime(&t);
}

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