感觉官网确实讲的不详细,讲了个囫囵,很多函数是啥都没说,调用的函数参数也没有说清楚,运行逻辑也不是很明白,估计我太菜了看的稀里糊涂的。记录一下这一小节遇到的问题,包括调用逻辑和一些C++的知识。
目录
三、Implementing the SimpleCache
(4)accessTiming和handleResponse的关系
四、Adding statistics to the cache
一、关于SConscript 文件
是我没头脑,我可能之前看了又忘记了也有可能官网教程没说清楚,关于如何声明SConscript文件……SConscript是SCons构建工具的脚本文件。在gem5模拟器中,SConscript文件用于描述模拟器的构建过程,包括如何编译源代码、链接库文件和生成执行文件等。
SCons是一种构建工具,可以用来构建软件系统。它通过扫描文件系统中的文件,并根据文件之间的依赖关系来决定如何构建软件。SConscript文件用于描述这些依赖关系,并为SCons提供所需的信息。
(1)声明源文件
在gem5模拟器中,SConscript文件通常包含源文件的路径、库文件的路径以及编译和链接选项等信息。这些信息被用来生成gem5模拟器的可执行文件。例如,在gem5模拟器的SConscript文件中,你可能会看到如下内容:
env = Environment()
src_files = ['main.cc', 'sim.cc', 'mem.cc']
env.Object(src_files)
env.Program('gem5.opt', src_files)
这段代码指定了源文件的路径(src_files
),并使用SCons的Object方法将这些文件编译成目标文件。然后,使用SCons的Program
方法将目标文件链接成可执行文件(gem5.opt
)。
(2)无需声明头文件
通常情况下,在C++中使用的头文件不需要在SConscript文件中显式声明,但是,有时你可能需要在SConscript文件中显式声明头文件。例如,假设你在SConscript文件中使用了一些额外的C++编译器选项,这些选项需要在头文件目录列表中指定。在这种情况下,你可以使用SCons的C++编译器工具(如env.CC)来指定这些选项。例如:
env = Environment()
env.CC(['simple_cache.cc', 'main.cc'], CPPPATH = ['path/to/headers'])
在这种情况下,SCons将在构建过程中搜索指定的头文件目录,并尝试找到你的头文件。
(3)写代码顺序
在写gem5模拟器的代码时,通常建议先写C++头文件(.hh
文件)和源文件(.cc
文件),然后再写Python文件(.py
文件)。这是因为,在写C++头文件和源文件时,你需要考虑模拟对象的数据结构和函数实现,这通常是代码的核心部分。而Python文件的作用是将这些C++代码封装成一个可以在gem5模拟器中使用的模拟对象,因此Python文件通常是C++代码的补充。
在写完C++头文件和源文件后,你还需要修改SConscript文件,把新的文件加入构建过程中。这样,SCons才会把新的文件编译进模拟器的可执行文件中。
当然,上述流程是一个建议的顺序,你可以根据你的需要进行调整。一定要注意的是,在写C++头文件和源文件之前,你应该先考虑你的模拟对象的数据结构和函数实现,以便更好地设计代码。
二、SimpleCache SimObject
class SimpleCache(MemObject):
type = 'SimpleCache'
cxx_header = "learning_gem5/simple_cache/simple_cache.hh"
cpu_side = VectorSlavePort("CPU side port, receives requests")
mem_side = MasterPort("Memory side port, sends requests")
latency = Param.Cycles(1, "Cycles taken on a hit or to resolve a miss")
size = Param.MemorySize('16kB', "The size of the cache")
system = Param.System(Parent.any, "The system this cache is part of")
- VectorPort 与普通端口的行为类似(例如,它们通过 getMasterPort 和 getSlavePort 解析),但它允许该对象与多个对等体连接。然后,在解析函数中,我们之前忽略的参数(PortID idx)被用来区分不同的端口。
- Parent.any 表示 system 参数可以接受任何类型的值。Parent.any 是一个特殊的参数类型,它可以用来表示任何类型的值,但通常用于表示对象的父对象。在 gem5 模拟器中,Parent 类型是用来表示对象间的继承关系的。例如,如果某个对象的父对象类型被声明为 Parent.any,则该对象可以被任何类型的对象所拥有。
- 此外,由于cpu_side 是一个向量类型的从属端口,接收来自 CPU 的请求;mem_side 是一个主端口,向内存发送请求。因此,数据流向是从 CPU 向缓存流动,再从缓存向内存流动。
三、Implementing the SimpleCache
(1)关于构造函数的实现
SimpleCache::SimpleCache(SimpleCacheParams *params) :
MemObject(params),
latency(params->latency),
blockSize(params->system->cacheLineSize()),
capacity(params->size / blockSize),
memPort(params->name + ".mem_side", this),
blocked(false), outstandingPacket(nullptr), waitingPortId(-1)
{
for (int i = 0; i < params->port_cpu_side_connection_count; ++i) {
cpuPorts.emplace_back(name() + csprintf(".cpu_side[%d]", i), i, this);
}
}
其中memPort
、blocked
、outstandingPacket
、waitingPortId
、cpuPorts
和 port_cpu_side_connection_count
都是类 SimpleCache
中从类 MemObject
继承的成员变量。
memPort
是类MasterPort
的一个实例,用于向内存端发送请求。blocked
是一个布尔变量,表示缓存当前是否被阻塞。outstandingPacket
是一个指向当前正在被缓存处理的数据包的指针。waitingPortId
是一个整数,用于存储当前正在等待响应的端口的 ID。cpuPorts
是一个由VectorSlavePort
对象组成的向量,表示缓存的 CPU 端口。port_cpu_side_connection_count
是一个整数,指定了缓存的 CPU 端连接数。cacheLineSize()
返回系统中缓存行的大小,缓存行是缓存系统中最小的存储单元。当 CPU 尝试访问内存中的某个地址时,它会先读取该地址所在的缓存行,如果该缓存行不在缓存中,则会从内存中读取该缓存行并加载到缓存中。例如,假设系统的缓存行大小为 16 字节,那么当 CPU 尝试访问内存地址 0x1000 时,它会先读取内存地址 0x1000 到 0x10FF 范围内的所有数据,并将这些数据加载到缓存中。emplace_back()
方法是 C++ 中的一个 STL(标准模板库)函数,它用于向容器的末尾添加一个元素。与push_back()
相比,emplace_back()
更快,因为它可以在容器内部直接构造新元素,而无需先在堆上分配内存再拷贝数据。-
csprintf()
是一个 C 函数,用于将格式化的字符串写入给定的字符数组中。它的语法与printf()
函数类似,但是它只进行输出,而不进行输入。例如,当i
等于 0 时,调用csprintf(".cpu_side[%d]", i)
将返回字符串 ".cpu_side[0]"。
(2)关于accessTiming
void
SimpleCache::accessTiming(PacketPtr pkt)
{
bool hit = accessFunctional(pkt);
if (hit) {
pkt->makeResponse();
sendResponse(pkt);
} else {
Addr addr = pkt->getAddr();
Addr block_addr = pkt->getBlockAddr(blockSize);
unsigned size = pkt->getSize();
if (addr == block_addr && size == blockSize) {
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");
assert(pkt->needsResponse());
MemCmd cmd;
if (pkt->isWrite() || pkt->isRead()) {
cmd = MemCmd::ReadReq;
} else {
panic("Unknown packet type in upgrade size");
}
PacketPtr new_pkt = new Packet(pkt->req, cmd, blockSize);
new_pkt->allocate();
outstandingPacket = pkt;
memPort.sendPacket(new_pkt);
}
}
}
accessTiming 函数的作用是处理访问缓存的命中或缺失。在函数内部,首先调用 accessFunctional 函数来检查访问是否命中,并将结果存储在布尔变量 hit 中。
如果命中,则调用 pkt 的 makeResponse 函数来创建响应,然后调用 sendResponse 函数将响应发送回 CPU。
如果未命中,则先获取数据包的地址(addr)和大小(size),然后使用 pkt 的 getBlockAddr 函数计算出缓存块的地址(block_addr)。其中:
addr
是数据包中指定的地址,即请求所需的数据在内存中的地址。block_addr
是数据包中指定地址所在的缓存块的地址。
因此,如果 addr
是一个缓存块的起始地址,并且请求的大小正好是一个缓存块的大小,则将数据包直接转发给内存。否则:
如果请求数据大于缓存块大小,则说明跨缓存行无法处理;
如果要访问的数据包小于缓存块大小,则会将请求升级为完整的缓存行访问请求,并将其发送到下一级。如果请求不是完整的缓存行访问,则会创建一个新的完整的缓存行访问请求来替换原有的请求,并将其发送到下一级。具体来说:
- 使用
Packet
类的构造函数创建一个新的Packet
对象,该对象的大小为缓存块的大小。这个新的Packet
对象将被用来发送读请求到内存。 - 调用
allocate
函数为新的Packet
对象分配空间。 - 将原有的请求存储在
outstandingPacket
中,并将新的请求发送到下一级。当接收到下一级的响应时,会在handleResponse
函数中将响应插入到缓存中,并处理outstandingPacket
中存储的请求。 - 将新的
Packet
对象发送到内存端口,挂起原始请求,等待内存响应。 - 具体来说,当你调用
memPort.sendPacket(new_pkt)
发送新的数据包时,这个数据包会被发送到内存,并等待内存的响应。一旦收到内存的响应,就会调用SimpleCache
类的recvTimingResp
函数。这个函数会检查outstandingPacket
是否为空,如果不为空,则会将内存响应复制到outstandingPacket
中,并将outstandingPacket
转换为响应。最后,调用sendResponse
函数将响应发送回请求的发起方(唤醒原始请求,使用waitingPortId
发送响应到请求的发起方)。
(3)关于handleResponse
bool
SimpleCache::handleResponse(PacketPtr pkt)
{
assert(blocked);
DPRINTF(SimpleCache, "Got response for addr %#x\n", pkt->getAddr());
insert(pkt);
if (outstandingPacket != nullptr) {
accessFunctional(outstandingPacket);
outstandingPacket->makeResponse();
delete pkt;
pkt = outstandingPacket;
outstandingPacket = nullptr;
} // else, pkt contains the data it needs
sendResponse(pkt);
return true;
}
简直是人看不懂系列,😶有被无语到,具体来说这个函数是用来处理内存响应的。当函数收到内存响应时,它会执行以下操作:
- 使用
insert
函数将数据插入缓存。 - 如果
outstandingPacket
不为空,则使则表明之前发送的请求尚未得到响应。这意味着原始请求方正在等待响应。在这种情况下,函数会调用 accessFunctional 函数,该函数可能用于在功能层面访问 outstandingPacket。 - 然后,outstandingPacket->makeResponse() 是将 outstandingPacket 设置为响应状态的函数调用。这意味着在调用该函数之后,outstandingPacket 将 被设置为响应状态。
- 然后,调用delete 语句只是将指针指向的内存块释放回系统,但指针本身并不会被删除。因此,如果不对指针赋值,则该指针可能会指向一个不确定的内存区域,这可能导致程序的潜在错误。在这种情况下,将 pkt 赋值为 outstandingPacket 的目的是确保 pkt 不再指向已被释放的内存块
- 最后,调用 sendResponse 函数发送响应。
- 如果 outstandingPacket 为空指针,则表示 pkt 已包含所需的数据,因此不需要进一步采取任何动作。函数直接调用 sendResponse 函数发送响应即可。
需要注意的是,在处理内存响应时,需要保证 blocked
变量为 true
。这是因为,在 SimpleCache
类中,请求是被阻塞的,并且只有在收到内存响应后才能发送响应。
(4)accessTiming和handleResponse的关系
在 accessTiming
函数中,如果请求没有命中缓存,则会调用 memPort.sendPacket(pkt)
将请求转发到下一级访问。当收到下一级的响应时,会调用 handleResponse
函数来处理响应。
handleResponse
函数则是用于处理从下一级获取到的响应的。它会将响应插入到缓存中,然后如果有一个升级后的请求等待响应,则会调用 accessFunctional
函数来检查这个请求是否命中,并返回响应给请求方。如果没有升级后的请求等待响应,则会直接返回响应给请求方。
有人会有疑惑,上面(3)提到的recvTimingResp 函数和(4)的handleResponse函数的功能是一样的吗?
recvTimingResp
函数是用于处理从下一级接收到的响应的,而 handleResponse
函数则是用于将响应插入到缓存中,并处理等待响应的请求的。所以,recvTimingResp
函数的功能更加基础,而 handleResponse
函数的功能则更加高级。
那accessTiming
、recvTimingResp
和handleResponse
的先后调用顺序是什么呢?
在这个流程中,recvTimingResp
函数和 handleResponse
函数的调用顺序是:
- 先调用
accessTiming
,函数内部会调用memPort.sendPacket(new_pkt)
发送新的数据包。 - 接收到下一级的响应,调用
recvTimingResp
函数处理响应。 - 在
recvTimingResp
函数中,调用handleResponse
函数将响应插入到缓存中,并处理等待响应的请求。
(5)关于accessFunctional
std::unordered_map<Addr, uint8_t*> cacheStore;
bool
SimpleCache::accessFunctional(PacketPtr pkt)
{
Addr block_addr = pkt->getBlockAddr(blockSize);
auto it = cacheStore.find(block_addr);
if (it != cacheStore.end()) {
if (pkt->isWrite()) {
pkt->writeDataToBlock(it->second, blockSize);
} else if (pkt->isRead()) {
pkt->setDataFromBlock(it->second, blockSize);
} else {
panic("Unknown packet type!");
}
return true;
}
return false;
}
accessFunctional是一个访存操作,它 在cacheStore 容器中查找给定的块地址,如果找到了,就根据 pkt 的类型进行读写操作,并返回 true;如果没有找到,则返回 false。
cacheStore 是一个 C++ 的 std::unordered_map 类型的容器,它存储了地址到字节指针的映射。
std::unordered_map 是一种关联式容器,它的 key 和 value 分别是 Addr 类型的地址和 uint8_t* 类型的字节指针。它使用哈希表实现,因此查找元素的时间复杂度为 O(1),但是插入和删除元素的时间复杂度略高。std::unordered_map 在头文件 <unordered_map> 中定义,使用时需要先 #include<unordered_map>。
注意:std::unordered_map 在 C++11 中引入,如果你使用的是早期版本的 C++,则可能需要使用 std::tr1::unordered_map。
在函数体内,它首先使用 pkt 对象的 getBlockAddr 方法计算出块地址,然后在 cacheStore 容器中查找这个块地址。如果找到了,就检查 pkt 的类型。如果是写操作,则使用 pkt 的 writeDataToBlock 方法将数据写入块;如果是读操作,则使用 pkt 的 setDataFromBlock 方法从块中读取数据;如果都不是,则调用 panic 函数,抛出一个未知的包类型的错误。最后,如果找到了块地址,就返回 true,否则返回 false。
(6)关于insert
void
SimpleCache::insert(PacketPtr pkt)
{
if (cacheStore.size() >= capacity) {
// Select random thing to evict. This is a little convoluted since we
// are using a std::unordered_map. See http://bit.ly/2hrnLP2
int bucket, bucket_size;
do {
bucket = random_mt.random(0, (int)cacheStore.bucket_count() - 1);
} while ( (bucket_size = cacheStore.bucket_size(bucket)) == 0 );
auto block = std::next(cacheStore.begin(bucket),
random_mt.random(0, bucket_size - 1));
RequestPtr req = new Request(block->first, blockSize, 0, 0);
PacketPtr new_pkt = new Packet(req, MemCmd::WritebackDirty, blockSize);
new_pkt->dataDynamic(block->second); // This will be deleted later
DPRINTF(SimpleCache, "Writing packet back %s\n", pkt->print());
memPort.sendTimingReq(new_pkt);
cacheStore.erase(block->first);
}
uint8_t *data = new uint8_t[blockSize];
cacheStore[pkt->getAddr()] = data;
pkt->writeDataToBlock(data, blockSize);
}
insert函数在内存侧端口响应请求时被调用。
- 第一步是检查缓存当前是否已满。如果缓存中的条目(块)数量超过了 SimObject 参数设定的缓存容量,则需要淘汰一些条目。通过利用 C++ unordered_map 的哈希表实现随机淘汰一个条目。
- 在淘汰时,我们需要将数据写回底层存储器(以防其更新)。为此,我们创建了一个新的 Request-Packet 对。该数据包使用了一个新的内存命令:MemCmd::WritebackDirty。然后,我们将数据包发送到内存侧端口(memPort)并在缓存存储映射中删除该条目。
- 然后,在可能已经淘汰了一个块之后,我们将新地址添加到缓存中。为此,我们只需为块分配空间并向映射中添加一个条目即可。(如果pkt->getAddr()得到的地址在cacheStore中存在,则直接覆盖写;如果不在,则新增加一个条目即可)
- 最后,我们从响应数据包中写入新分配的块。由于我们在缓存缺失逻辑中确保了如果数据包小于缓存块则创建一个新数据包,因此这些数据保证是缓存块的大小。
注意:
do {
bucket = random_mt.random(0, (int)cacheStore.bucket_count() - 1);
} while ( (bucket_size = cacheStore.bucket_size(bucket)) == 0 );
这段代码的作用是在 cacheStore 容器中随机选择一个非空哈希桶。
std::unordered_map 是一种使用哈希表实现的关联式容器。它由若干个哈希桶组成,每个哈希桶中存储了一些元素。在插入或删除元素时,哈希表会根据元素的哈希值自动将元素分配到合适的哈希桶中。
cacheStore.bucket_count() 返回哈希表中哈希桶的数量,cacheStore.bucket_size(n) 返回哈希桶 n 中的元素数量。
这段代码中的 do-while 循环使用 random_mt.random(a, b) 生成一个在区间 [a, b] 之间的随机数,并将其赋值给 bucket 变量。如果 bucket 指向的哈希桶为空,则重新生成一个随机数,直到找到一个非空的哈希桶为止。
在找到非空哈希桶之后,它使用 std::next 函数在哈希桶中随机选择一个元素,这样就实现了随机淘汰一个条目的目的。
四、Adding statistics to the cache
class SimpleCache : public MemObject
{
private:
...
Tick missTime; // To track the miss latency
Stats::Scalar hits;
Stats::Scalar misses;
Stats::Histogram missLatency;
Stats::Formula hitRatio;
public:
...
void regStats() override;
};
具体来说,这段代码声明了四个统计信息:
- hits:命中次数,类型为 Stats::Scalar。
- misses:未命中次数,类型为 Stats::Scalar。
- missLatency:满足未命中情况所需时间的直方图,类型为 Stats::Histogram。
- hitRatio:命中率,类型为 Stats::Formula。
missTime 是一个变量,用于跟踪未命中延迟。regStats() 是一个虚函数,将在子类中被重写。它将用于在 SimpleCache 类中注册统计信息。