authors: Jason Lowe-Power
last edited: 2022-09-27 19:05:47 +0000

Action code blocks

The next section of the state machine file is the action blocks. The action blocks are executed during a transition from one state to another, and are called by the transition code blocks (which we will discuss in the next section <MSI-transitions-section>). Actions are single action blocks. Some examples are “send a message to the directory” and “pop the head of the buffer”. Each action should be small and only perform a single action.

The first action we will implement is an action to send a GetS request to the directory. We need to send a GetS request to the directory whenever we want to read some data that is not in the Modified or Shared states in our cache. As previously mentioned, there are three variables that are automatically populated inside the action block (like the in_msg in peek blocks). address is the address that was passed into the trigger function, cache_entry is the cache entry passed into the trigger function, and tbe is the TBE passed into the trigger function.

action(sendGetS, 'gS', desc="Send GetS to the directory") {
    enqueue(request_out, RequestMsg, 1) {
        out_msg.addr := address;
        out_msg.Type := CoherenceRequestType:GetS;
        out_msg.Destination.add(mapAddressToMachine(address,
                                MachineType:Directory));
        // See mem/protocol/RubySlicc_Exports.sm for possible sizes.
        out_msg.MessageSize := MessageSizeType:Control;
        // Set that the requestor is this machine so we get the response.
        out_msg.Requestor := machineID;
    }
}

When specifying the action block, there are two parameters: a description and a “shorthand”. These two parameters are used in the HTML table generation. The shorthand shows up in the transition cell, so it should be as short as possible. SLICC provides a special syntax to allow for bold (‘’), superscript (‘\^’), and spaces (‘_’) in the shorthand to help keep them short. Second, the description also shows up in the HTML table when you click on a particular action. The description can be longer and help explain what the action does.

Next, in this action we are going to send a message to the directory on the request_out port as declared above the in_port blocks. The enqueue function is similar to the peek function since it requires a code block. enqueue, however, has the special variable out_msg. In the enqueue block, you can modify the out_msg with the current data.

The enqueue block takes three parameters, the message buffer to send the message, the type of the message, and a latency. This latency (1 cycle in the example above and throughout this cache controller) is the cache latency. This is where you specify the latency of accessing the cache, in this case for a miss. Below we will see that specifying the latency for a hit is similar.

Inside the enqueue block is where the message data is populated. For the address of the request, we can use the automatically populated address variable. We are sending a GetS message, so we use that message type. Next, we need to specify the destination of the message. For this, we use the mapAddressToMachine function that takes the address and the machine type we are sending to. This will look up in the correct MachineID based on the address. We call Destination.add because Destination is a NetDest object, or a bitmap of all MachineID.

Finally, we need to specify the message size (from mem/protocol/RubySlicc_Exports.sm) and set ourselves as the requestor. By setting this machineID as the requestor, it will allow the directory to respond to this cache or forward it to another cache to respond to this request.

Similarly, we can create actions for sending other get and put requests. Note that get requests represent requests for data and put requests represent requests where we downgrading or evicting our copy of the data.

action(sendGetM, "gM", desc="Send GetM to the directory") {
    enqueue(request_out, RequestMsg, 1) {
        out_msg.addr := address;
        out_msg.Type := CoherenceRequestType:GetM;
        out_msg.Destination.add(mapAddressToMachine(address,
                                MachineType:Directory));
        out_msg.MessageSize := MessageSizeType:Control;
        out_msg.Requestor := machineID;
    }
}

action(sendPutS, "pS", desc="Send PutS to the directory") {
    enqueue(request_out, RequestMsg, 1) {
        out_msg.addr := address;
        out_msg.Type := CoherenceRequestType:PutS;
        out_msg.Destination.add(mapAddressToMachine(address,
                                MachineType:Directory));
        out_msg.MessageSize := MessageSizeType:Control;
        out_msg.Requestor := machineID;
    }
}

action(sendPutM, "pM", desc="Send putM+data to the directory") {
    enqueue(request_out, RequestMsg, 1) {
        out_msg.addr := address;
        out_msg.Type := CoherenceRequestType:PutM;
        out_msg.Destination.add(mapAddressToMachine(address,
                                MachineType:Directory));
        out_msg.DataBlk := cache_entry.DataBlk;
        out_msg.MessageSize := MessageSizeType:Data;
        out_msg.Requestor := machineID;
    }
}

Next, we need to specify an action to send data to another cache in the case that we get a forwarded request from the directory for another cache. In this case, we have to peek into the request queue to get other data from the requesting message. This peek code block is exactly the same as the ones in the in_port. When you nest an enqueue block in a peek block both in_msg and out_msg variables are available. This is needed so we know which other cache to send the data to. Additionally, in this action we use the cache_entry variable to get the data to send to the other cache.

action(sendCacheDataToReq, "cdR", desc="Send cache data to requestor") {
    assert(is_valid(cache_entry));
    peek(forward_in, RequestMsg) {
        enqueue(response_out, ResponseMsg, 1) {
            out_msg.addr := address;
            out_msg.Type := CoherenceResponseType:Data;
            out_msg.Destination.add(in_msg.Requestor);
            out_msg.DataBlk := cache_entry.DataBlk;
            out_msg.MessageSize := MessageSizeType:Data;
            out_msg.Sender := machineID;
        }
    }
}

Next, we specify actions for sending data to the directory and sending an invalidation ack to the original requestor on a forward request when this cache does not have the data.

action(sendCacheDataToDir, "cdD", desc="Send the cache data to the dir") {
    enqueue(response_out, ResponseMsg, 1) {
        out_msg.addr := address;
        out_msg.Type := CoherenceResponseType:Data;
        out_msg.Destination.add(mapAddressToMachine(address,
                                MachineType:Directory));
        out_msg.DataBlk := cache_entry.DataBlk;
        out_msg.MessageSize := MessageSizeType:Data;
        out_msg.Sender := machineID;
    }
}

action(sendInvAcktoReq, "iaR", desc="Send inv-ack to requestor") {
    peek(forward_in, RequestMsg) {
        enqueue(response_out, ResponseMsg, 1) {
            out_msg.addr := address;
            out_msg.Type := CoherenceResponseType:InvAck;
            out_msg.Destination.add(in_msg.Requestor);
            out_msg.DataBlk := cache_entry.DataBlk;
            out_msg.MessageSize := MessageSizeType:Control;
            out_msg.Sender := machineID;
        }
    }
}

Another required action is to decrement the number of acks we are waiting for. This is used when we get a invalidation ack from another cache to track the total number of acks. For this action, we assume that there is a valid TBE and modify the implicit tbe variable in the action block.

Additionally, we have another example of making debugging easier in protocols: APPEND_TRANSITION_COMMENT. This function takes a string, or something that can easily be converted to a string (e.g., int) as a parameter. It modifies the protocol trace output, which we will discuss in the debugging section. On each protocol trace line that executes this action it will print the total number of acks this cache is still waiting on. This is useful since the number of remaining acks is part of the cache block state.

action(decrAcks, "da", desc="Decrement the number of acks") {
    assert(is_valid(tbe));
    tbe.AcksOutstanding := tbe.AcksOutstanding - 1;
    APPEND_TRANSITION_COMMENT("Acks: ");
    APPEND_TRANSITION_COMMENT(tbe.AcksOutstanding);
}

We also need an action to store the acks when we receive a message from the directory with an ack count. For this action, we peek into the directory’s response message to get the number of acks and store them in the (required to be valid) TBE.

action(storeAcks, "sa", desc="Store the needed acks to the TBE") {
    assert(is_valid(tbe));
    peek(response_in, ResponseMsg) {
        tbe.AcksOutstanding := in_msg.Acks + tbe.AcksOutstanding;
    }
    assert(tbe.AcksOutstanding > 0);
}

The next set of actions are to respond to CPU requests on hits and misses. For these actions, we need to notify the sequencer (the interface between Ruby and the rest of gem5) of the new data. In the case of a store, we give the sequencer a pointer to the data block and the sequencer updates the data in-place.

action(loadHit, "Lh", desc="Load hit") {
    assert(is_valid(cache_entry));
    cacheMemory.setMRU(cache_entry);
    sequencer.readCallback(address, cache_entry.DataBlk, false);
}

action(externalLoadHit, "xLh", desc="External load hit (was a miss)") {
    assert(is_valid(cache_entry));
    peek(response_in, ResponseMsg) {
        cacheMemory.setMRU(cache_entry);
        // Forward the type of machine that responded to this request
        // E.g., another cache or the directory. This is used for tracking
        // statistics.
        sequencer.readCallback(address, cache_entry.DataBlk, true,
                               machineIDToMachineType(in_msg.Sender));
    }
}

action(storeHit, "Sh", desc="Store hit") {
    assert(is_valid(cache_entry));
    cacheMemory.setMRU(cache_entry);
    // The same as the read callback above.
    sequencer.writeCallback(address, cache_entry.DataBlk, false);
}

action(externalStoreHit, "xSh", desc="External store hit (was a miss)") {
    assert(is_valid(cache_entry));
    peek(response_in, ResponseMsg) {
        cacheMemory.setMRU(cache_entry);
        sequencer.writeCallback(address, cache_entry.DataBlk, true,
                               // Note: this could be the last ack.
                               machineIDToMachineType(in_msg.Sender));
    }
}

action(forwardEviction, "e", desc="sends eviction notification to CPU") {
    if (send_evictions) {
        sequencer.evictionCallback(address);
    }
}

In each of these actions, it is vital that we call setMRU on the cache entry. The setMRU function is what allows the replacement policy to know which blocks are most recently accessed. If you leave out the setMRU call, the replacement policy will not operate correctly!

On loads and stores, we call the read/writeCallback function on the sequencer. This notifies the sequencer of the new data or allows it to write the data into the data block. These functions take four parameters (the last parameter is optional): address, data block, a boolean for if the original request was a miss, and finally, an optional MachineType. The final optional parameter is used for tracking statistics on where the data for the request was found. It allows you to track whether the data comes from cache-to-cache transfers or from memory.

Finally, we also have an action to forward evictions to the CPU. This is required for gem5’s out-of-order models to squash speculative loads if the cache block is evicted before the load is committed. We use the parameter specified at the top of the state machine file to check if this is needed or not.

Next, we have a set of cache management actions that allocate and free cache entries and TBEs. To create a new cache entry, we must have space in the CacheMemory object. Then, we can call the allocate function. This allocate function doesn’t actually allocate the host memory for the cache entry since this controller specialized the Entry type, which is why we need to pass a new Entry to the allocate function.

Additionally, in these actions we call set_cache_entry, unset_cache_entry, and similar functions for the TBE. These set and unset the implicit variables that were passed in via the trigger function. For instance, when allocating a new cache block, we call set_cache_entry and in all actions proceeding allocateCacheBlock the cache_entry variable will be valid.

There is also an action that copies the data from the cache data block to the TBE. This allows us to keep the data around even after removing the cache block until we are sure that this cache no longer are responsible for the data.

action(allocateCacheBlock, "a", desc="Allocate a cache block") {
    assert(is_invalid(cache_entry));
    assert(cacheMemory.cacheAvail(address));
    set_cache_entry(cacheMemory.allocate(address, new Entry));
}

action(deallocateCacheBlock, "d", desc="Deallocate a cache block") {
    assert(is_valid(cache_entry));
    cacheMemory.deallocate(address);
    // clear the cache_entry variable (now it's invalid)
    unset_cache_entry();
}

action(writeDataToCache, "wd", desc="Write data to the cache") {
    peek(response_in, ResponseMsg) {
        assert(is_valid(cache_entry));
        cache_entry.DataBlk := in_msg.DataBlk;
    }
}

action(allocateTBE, "aT", desc="Allocate TBE") {
    assert(is_invalid(tbe));
    TBEs.allocate(address);
    // this updates the tbe variable for other actions
    set_tbe(TBEs[address]);
}

action(deallocateTBE, "dT", desc="Deallocate TBE") {
    assert(is_valid(tbe));
    TBEs.deallocate(address);
    // this makes the tbe variable invalid
    unset_tbe();
}

action(copyDataFromCacheToTBE, "Dct", desc="Copy data from cache to TBE") {
    assert(is_valid(cache_entry));
    assert(is_valid(tbe));
    tbe.DataBlk := cache_entry.DataBlk;
}

The next set of actions are for managing the message buffers. We need to add actions to pop the head message off of the buffers after the message has been satisfied. The dequeue function takes a single parameter, a time for the dequeue to take place. Delaying the dequeue for a cycle prevents the in_port logic from consuming another message from the same message buffer in a single cycle.

action(popMandatoryQueue, "pQ", desc="Pop the mandatory queue") {
    mandatory_in.dequeue(clockEdge());
}

action(popResponseQueue, "pR", desc="Pop the response queue") {
    response_in.dequeue(clockEdge());
}

action(popForwardQueue, "pF", desc="Pop the forward queue") {
    forward_in.dequeue(clockEdge());
}

Finally, the last action is a stall. Below, we are using a “z_stall”, which is the simplest kind of stall in SLICC. By leaving the action blank, it generates a “protocol stall” in the in_port logic which stalls all messages from being processed in the current message buffer and all lower priority message buffer. Protocols using “z_stall” are usually simpler, but lower performance since a stall on a high priority buffer can stall many requests that may not need to be stalled.

action(stall, "z", desc="Stall the incoming request") {
    // z_stall
}

There are two other ways to deal with messages that cannot currently be processed that can improve the performance of protocols. (Note: We will not be using these more complicated techniques in this simple example protocol.) The first is recycle. The message buffers have a recycle function that moves the request on the head of the queue to the tail. This allows other requests in the buffer or requests in other buffers to be processed immediately. recycle actions often improve the performance of protocols significantly.

However, recycle is not very realistic when compared to real implementations of cache coherence. For a more realistic high-performance solution to stalling messages, Ruby provides the stall_and_wait function on message buffers. This function takes the head request and moves it into a separate structure tagged by an address. The address is user-specified, but is usually the request’s address. Later, when the blocked request can be handled, there is another function wakeUpBuffers(address) which will wake up all requests stalled on address and wakeUpAllBuffers() that wakes up all of the stalled requests. When a request is “woken up” it is placed back into the message buffer to be subsequently processed.