FPGA開発日記

カテゴリ別記事インデックス https://msyksphinz.github.io/github_pages , English Version https://fpgadevdiary.hatenadiary.com/

Gem5のチュートリアル "Learning Gem5"をやってみる(15. SimpleCacheの実装)

gem5を構成するSimObjectを追加するためのチュートリアルをやってみる。

www.gem5.org

  • gem5記事一覧インデックス

msyksphinz.hatenablog.com


本節では、前回作成したメモリ・オブジェクトを継承してキャッシュの論理を追加する。

大まかな作業工程は以下のようになる:

  • 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_portdata_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);
    }
}

CPUSidePortMemSidePortの実装はSimpleMemobjと同一である。違いは、handleRequestにより処理する際にもう一つ引数を追加してポートのIDを指定する点である。このIDによって、どのポートからのリクエストを処理するのかが判別できるようになる。

SimpleMemobjでは、リプライをどのCPU側のポートに転送すればよいかについてはパケットがその情報を持っていたが、今回のSimpleCacheではそうではない。

handleRequest関数はSimpleMemobjのものと2つ異なる機能を持っている。

  • リクエストが行われたポートのIDを記憶する領域を持っている
  • キャッシュのアクセスタイムを持っている
    • handleRequestはイベントを用いてリクエストを必要なサイクル数だけストールさせる
    • 下記の実装では、リクエストはlatencyサイクルのみ遅らせられるようにスケジュールされる。
    • clockEdge()関数は、nthサイクルに必要なtickを返す。
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を使えない理由:
  • イベント・ハンドラとして使いたい関数(この場合は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(後述)はキャッシュの機能的アクセスを実行する

  • ヒットした場合はキャッシュを読み書きする
    • アクセスがヒットした場合は、パケットに応答する
    • パケットに対してmakeResponse関数を呼び出す必要がある
      • パケットをリクエスト・パケットからレスポンス・パケットに変換する。
      • パケット内のメモリーコマンドがReadReqだった場合、これはReadRespに変換される
    • レスポンスをCPUに送り返す
  • アクセスがミスであればそれを通知する(後述)
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関数に戻って、キャッシュ・ミスのケースを処理する必要がある。

  • ミスした場合:
  • 欠落しているパケットがキャッシュ・ブロック全体に対するものかどうかをチェックする
  • パケットがアラインされていて、リクエストのサイズがキャッシュブロックのサイズである場合
    1. SimpleMemobjと同じように、単純にリクエストをメモリに転送することができる。
  • パケットがキャッシュ・ブロックより小さい場合は
    1. メモリからキャッシュ・ブロック全体を読み出すために新しいパケットを作成する必要がある。
    2. パケットが読み取り要求であれ書き込み要求であれ、キャッシュ・ブロックのデータをキャッシュにロードするために、メモリに読み取り要求を送る。
    3. 書き込みの場合は、メモリからデータをロードした後にキャッシュで書き込みをお行う。
    4. ブロック・サイズの新しいパケットを作成する
    5. allocate関数を呼び出して、メモリから読み込むデータ用にPacketオブジェクトにメモリを割り当てる。
      1. 注意:このメモリは、パケットを解放するときに解放される。
      2. メモリ側のオブジェクトが統計のために元のリクエスト元と元のリクエストタイプを知ることができるように、パケットの中で元のリクエストオブジェクトを使う。
    6. 元のパケットポインタ(pkt)をメンバ変数standingPacketに保存し、SimpleCacheが応答を受け取ったときにそれを回復できるようにする。
    7. 新しいパケットをメモリ側のポートに送る。
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;
  1. パケットのアドレスと一致するエントリがマップにあるかどうかをチェックする
    1. Packet型のgetBlockAddr関数を使い、ブロックアラインメントされたアドレスを取得する
    2. マップからそのアドレスを探す
    3. もしアドレスが見つからなければ、この関数はfalseを返し、データはキャッシュになく、ミスである。
    4. そうでない場合、パケットが書き込み要求であれば、キャッシュ内のデータを更新する必要がある。
      1. パケットのデータをキャッシュに書き込む。
      2. writeDataToBlock関数を使用し、パケット内のデータを書き込みオフセットに書き込む
        1. 潜在的により大きなデータ・ブロックに書き込む
        2. キャッシュ・ブロック・オフセットとブロック・サイズ(パラメータとして)を受け取り、正しいオフセットを最初のパラメータとして渡されたポインタに書き込む。
    5. パケットが読み取り要求の場合、パケットのデータをキャッシュからのデータで更新する必要がある。
      1. 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関数の実装。この関数は、メモリ側ポートがリクエストに応答するたびに呼び出される。

  1. キャッシュが現在満杯かどうかをチェックする
  2. キャッシュに、SimObjectパラメータで設定されたキャッシュの容量よりも多くのエントリ(ブロック)がある場合、何かを退避させる必要がある。
  3. 以下のコードでは、C++unordered_mapのハッシュテーブル実装を利用して、ランダムなエントリを退避させている。
  4. 退避時に、データが更新された場合に備えて、データをメモリ・バスに書き戻す必要がある。
    1. 新しいRequest-Packetペアを作成する
    2. このパケットは新しいメモリーコマンドを使う: MemCmd::WritebackDirty
    3. パケットをメモリ側ポート(memPort)に送り、キャッシュ・ストレージ・マップのエントリを消去する。
  5. ブロックが消去された可能性がある後、新しいアドレスをキャッシュに追加する。
    • 単純にブロック用の領域を確保し、マップにエントリーを追加する。
  6. 最後に、レスポンス・パケットのデータを新しく割り当てられたブロックに書き込む。
    • パケットがキャッシュ・ブロックより小さい場合、キャッシュ・ミス・ロジックで新しいパケットを作成するようにしたので、このデータはキャッシュ・ブロックのサイズであることが保証される。