38

深入理解 Gem5 之三

 2 years ago
source link: https://dingfen.github.io/cpp/2022/03/13/gem5-3.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

深入理解 Gem5 之三——SimObjectPermalink

之前的两篇博文分别介绍了 gem5 的事件触发机制序列化问题,它们都和 SimObject 类有密切的联系。正所谓万事俱备,只欠东风。基于目前的理解,我可以更深入地看看 SimObject 类的实现方式。

父类Permalink

SimObject 类是一个非常复杂但又十分重要的类,它在 Gem5 中占有极为重要的地位。gem5 的模块化设计是围绕 SimObject 类型构建的。 模拟系统中的大多数组件都是 SimObjects 的子类,如CPU、缓存、内存控制器、总线等。gem5 将所有这些对象从其 C++ 实现导出到 python。使用提供的 python 配置脚本便可以创建任何 SimObject 类对象,设置其参数,并指定 SimObject 之间的交互。理解该类的实现有助于我们理解整个 gem5 模拟器的运作逻辑。我们先从它的父类开始讲起,它一共有 5 个父类:EventManger、Serializable、Drainable、statistics::Group、Named。

class SimObject : public EventManager, public Serializable, public Drainable,
    public statistics::Group, public Named

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

statistics::GroupPermalink

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

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

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 类可以很轻松地构造出复杂的树状层次,例如:

// 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 类维护统计数据以及对象的基本信息,由于和本篇博文主题关系不大,不再赘述。

小结Permalink

在正式开始之前,再回顾一下五个父类各自的作用:

  • 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 起名。

SimObjectPermalink

对其父类有了充足的理解后,我们再来看一下 SimObject 类中的静态变量:

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 类有哪些成员:

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 类只是搭建了模拟对象的运行框架,规定了对象的运行步骤。

最后再介绍一些有意义的成员函数:

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 模型时,我们需要确保系统中没有任何在我们返回时陈旧的缓存数据。该函数将所有此类状态刷新回主存储器,但它不会将任何脏状态写回内存。

时钟 与 ClockedObjectPermalink

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

时钟Permalink

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

typedef uint64_t Tick;
__thread Tick *_curTickPtr;

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

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

Clocked 类Permalink

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

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()

// Update the tick to the current tick
void updateClockPeriod() {
  // tick and cycle update
  update();
  // hook function to add extra work.
  clockPeriodUpdated();
}

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

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 数。

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;
}

ClockedObjectPermalink

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

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 类以及其派生类在模拟系统时的作用。

SimpleCachePermalink

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

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

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

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 访问/读写请求。下面代码展示了请求被处理的过程:

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 事件并调度:

  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 执行并完成。

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK