gem5を構成するSimObjectを追加するためのチュートリアルをやってみる。
- gem5記事一覧インデックス
本節では、前回作成したメモリ・オブジェクトを継承してキャッシュの論理を追加する。
大まかな作業工程は以下のようになる:
- SimpleCache SimObjectの作成
- キャッシュの論理回路部分の実装
- Configファイルの作成
- 統計情報取得機能の追加
SimpleCache SimObjectの追加
まずは以下ようなSimpleCacheクラスをPythonラッパとして作成する。
from m5.params import * from m5.proxy import * from MemObject import MemObject 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")
ここで注意すべきなのは、継承元はSimObjectではなくMemObjectとなっている点である。これにより、いくつかのパラメータが追加されている。
latency
:キャッシュのレイテンシ- ヒットの判別、もしくはミスの解決に要するサイクル数
size
:キャッシュのサイズsystem
:キャッシュが接続されるシステムのポインタ- これにより、キャッシュを初期化する際にキャッシュ・ブロック・サイズなどの情報をシステムから読み取ることができる
- ここでは、
Parent.any
と呼ばれる特殊なプロキシパラメータを設定する
- CPU側のポートは、
inst_port
とdata_port
に分けるのではなく、VectorSlavePort
にという名前で宣言されており、複数のポートを宣言することができる- これにより、
getPort()
による識別が不要となる
- これにより、
SimpleCacheの実装
殆どのSimpleCacheのコードはSimpleMemobjと同一である。コンストラクタと主要なメモリアクセス関数に違いが生じる。
まず、CPU側のポートを動的に生成する必要がある。いくつのポートを生成するかは、SimObjectのパラメータに基づく。
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); } }
この関数では、cacheLineSizeはシステムパラメータから取得しており、blockSize変数に適用している。ブロックサイズとパラメータによって指定されるsizeにもとづいてキャッシュの大きさを決めている。
CPUSidePortsの宣言は、パラメータによって指定されるport_cpu_side_connection_countによって決められている。これにより、cpuPortsに新たなポートを付け加えていくことになる。
次に、getMasterPortおよびgetSlavePortを実装する必要がある。
- getMasterPort():SimpleMemobjと同様のもの
- getSlavePort():IDの番号に基づいてポートのオブジェクトを返す
BaseSlavePort& SimpleCache::getSlavePort(const std::string& if_name, PortID idx) { if (if_name == "cpu_side" && idx < cpuPorts.size()) { return cpuPorts[idx]; } else { return MemObject::getSlavePort(if_name, idx); } }
CPUSidePort
とMemSidePort
の実装はSimpleMemobj
と同一である。違いは、handleRequest
により処理する際にもう一つ引数を追加してポートのIDを指定する点である。このIDによって、どのポートからのリクエストを処理するのかが判別できるようになる。
SimpleMemobj
では、リプライをどのCPU側のポートに転送すればよいかについてはパケットがその情報を持っていたが、今回のSimpleCache
ではそうではない。
handleRequest
関数はSimpleMemobj
のものと2つ異なる機能を持っている。
- リクエストが行われたポートのIDを記憶する領域を持っている
- キャッシュのアクセスタイムを持っている
bool SimpleCache::handleRequest(PacketPtr pkt, int port_id) { if (blocked) { return false; } DPRINTF(SimpleCache, "Got request for addr %#x\n", pkt->getAddr()); blocked = true; waitingPortId = port_id; schedule(new AccessEvent(this, pkt), clockEdge(latency)); return true; }
AccessEvent
は、event
の章で使ったEventWrapperよりも少し複雑である:
SimpleCache
では新しいクラス(AccessEvent
)を使う。EventWrapper
を使えない理由:handleRequest
からイベントハンドラ関数にパケット(pkt
)を渡す必要があるからだ。
- イベント・ハンドラとして使いたい関数(この場合はaccessTming)を呼び出すprocess関数だけを実装すればよい。
- イベントのコンストラクタに
AutoDelete
フラグを渡すことで、動的に生成されるオブジェクトのメモリの解放を心配する必要がなくなる。 - イベント・コードは、プロセス関数が実行された後、自動的にオブジェクトを削除する。
class AccessEvent : public Event { private: SimpleCache *cache; PacketPtr pkt; public: AccessEvent(SimpleCache *cache, PacketPtr pkt) : Event(Default_Pri, AutoDelete), cache(cache), pkt(pkt) { } void process() override { cache->accessTiming(pkt); } };
accessTimingの実装は以下のようになる:ヒットすればそのままレスポンスを返し、ミスだとミス処理を行う。
void SimpleCache::accessTiming(PacketPtr pkt) { bool hit = accessFunctional(pkt); if (hit) { pkt->makeResponse(); sendResponse(pkt); } else { <キャッシュ・ミス時の処理> } }
accessFunctional
(後述)はキャッシュの機能的アクセスを実行する
- ヒットした場合はキャッシュを読み書きする
- アクセスがミスであればそれを通知する(後述)
void SimpleCache::sendResponse(PacketPtr pkt) { int port = waitingPortId; blocked = false; waitingPortId = -1; cpuPorts[port].sendPacket(pkt); for (auto& port : cpuPorts) { port.trySendRetry(); } }
sendResponse
()関数- 正しいポートにパケットを送信する
waitingPortId
を使用する- CPU側のピアがすぐに
sendTimingReq
を呼び出す場合に備えて、sendPacket
を呼び出す前にSimpleCacheをブロックされていない状態にする必要がある。 - SimpleCacheがリクエストを受信できるようになり、ポートにリトライを送る必要がある場合、CPU側のポートにリトライを送ろうとする。
accessTiming関数に戻って、キャッシュ・ミスのケースを処理する必要がある。
- ミスした場合:
- 欠落しているパケットがキャッシュ・ブロック全体に対するものかどうかをチェックする
- パケットがアラインされていて、リクエストのサイズがキャッシュブロックのサイズである場合
SimpleMemobj
と同じように、単純にリクエストをメモリに転送することができる。
- パケットがキャッシュ・ブロックより小さい場合は
- メモリからキャッシュ・ブロック全体を読み出すために新しいパケットを作成する必要がある。
- パケットが読み取り要求であれ書き込み要求であれ、キャッシュ・ブロックのデータをキャッシュにロードするために、メモリに読み取り要求を送る。
- 書き込みの場合は、メモリからデータをロードした後にキャッシュで書き込みをお行う。
- ブロック・サイズの新しいパケットを作成する
allocate
関数を呼び出して、メモリから読み込むデータ用にPacketオブジェクトにメモリを割り当てる。- 元のパケットポインタ(
pkt
)をメンバ変数standingPacket
に保存し、SimpleCache
が応答を受け取ったときにそれを回復できるようにする。 - 新しいパケットをメモリ側のポートに送る。
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を保存する outstandingPacket = pkt; // メモリ・バスにパケットを送信する memPort.sendPacket(new_pkt); } } }
メモリからのレスポンスの処理は、必ずキャッシュ・ミスの応答なので、まずはレスポンス・パケットをキャッシュに挿入する。
outstandingPacket
がある場合pkt
にデータをフォワードしてCPUに返信する- 新たに作成したパケットを削除する
outstandingPacket
がない場合- そのままパケットをフォワードしてCPUに返信する
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; }
キャッシュの機能的な部分の実装
さらに、2つの機能的な関数を実装する:
accessFuncional()
insert()
まずはキャッシュのストレージ部分を作成する。もっとも単純な方法はハッシュ・マップを使用することである:
std::unordered_map<Addr, uint8_t*> cacheStore;
- パケットのアドレスと一致するエントリがマップにあるかどうかをチェックする
- Packet型のgetBlockAddr関数を使い、ブロックアラインメントされたアドレスを取得する
- マップからそのアドレスを探す
- もしアドレスが見つからなければ、この関数はfalseを返し、データはキャッシュになく、ミスである。
- そうでない場合、パケットが書き込み要求であれば、キャッシュ内のデータを更新する必要がある。
- パケットのデータをキャッシュに書き込む。
writeDataToBlock
関数を使用し、パケット内のデータを書き込みオフセットに書き込む- 潜在的により大きなデータ・ブロックに書き込む
- キャッシュ・ブロック・オフセットとブロック・サイズ(パラメータとして)を受け取り、正しいオフセットを最初のパラメータとして渡されたポインタに書き込む。
- パケットが読み取り要求の場合、パケットのデータをキャッシュからのデータで更新する必要がある。
setDataFromBlock
関数は、writeDataToBlock
関数と同じオフセット計算を行うが、最初のパラメータにあるポインタのデータをパケットに書き込む。
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; }
insert関数の実装。この関数は、メモリ側ポートがリクエストに応答するたびに呼び出される。
- キャッシュが現在満杯かどうかをチェックする
- キャッシュに、
SimObject
パラメータで設定されたキャッシュの容量よりも多くのエントリ(ブロック)がある場合、何かを退避させる必要がある。 - 以下のコードでは、C++の
unordered_map
のハッシュテーブル実装を利用して、ランダムなエントリを退避させている。 - 退避時に、データが更新された場合に備えて、データをメモリ・バスに書き戻す必要がある。
- 新しいRequest-Packetペアを作成する
- このパケットは新しいメモリーコマンドを使う: MemCmd::WritebackDirty
- パケットをメモリ側ポート(memPort)に送り、キャッシュ・ストレージ・マップのエントリを消去する。
- ブロックが消去された可能性がある後、新しいアドレスをキャッシュに追加する。
- 単純にブロック用の領域を確保し、マップにエントリーを追加する。
- 最後に、レスポンス・パケットのデータを新しく割り当てられたブロックに書き込む。
- パケットがキャッシュ・ブロックより小さい場合、キャッシュ・ミス・ロジックで新しいパケットを作成するようにしたので、このデータはキャッシュ・ブロックのサイズであることが保証される。