authors: Tiago Mück

CHI

CHI ruby 协议提供了一个单一的缓存控制器,可以在缓存层次结构的多个级别重用,并配置为模拟 MESI 和 MOESI 缓存一致性协议的多个实例。此实现基于 Arm 的 AMBA 5 CHI 规范,并为大型 SoC 设计的设计空间探索提供了可扩展的框架。

CHI 概述和术语

CHI (Coherent Hub Interface) 提供了一种组件架构和事务级规范,用于对 MESI 和 MOESI 缓存一致性进行建模。CHI 定义了三个主要组件,如下图所示:

CHI components

HNF 是特定地址范围的一致性点 (PoC) 和序列化点 (PoS)。HNF 负责向 RNF 发出任何所需的监听请求或向 SNF 发出内存访问请求以完成事务。HNF 还可以封装共享的最后一级缓存并包含用于定向监听的目录。

CHI 规范 还为非一致性请求者 (RNI) 和非一致性地址范围 (HNI 和 SNI) 定义了特定类型的节点,例如属于 IO 组件的内存范围。在 Ruby 中,IO 访问不通过缓存一致性协议,因此仅实现了 CHI 的完全一致性节点类型。在本文档中,我们交替使用术语 RN / RNF,HN / HNF 和 SN / SNF。我们还使用术语 upstream (上游)downstream (下游) 分别指代内存层次结构中上一级(即朝向 CPU)和下一级(即朝向内存)的组件。

协议概述

CHI 协议实现主要由两个控制器组成:

为了允许完全灵活的缓存层次结构,Cache_Controller 可以配置为模拟请求节点和主节点内的任何缓存级别(例如 L1D、私有 L2、共享 L3)。此外,它还支持其他 Ruby 协议中不可用的多种功能:

该实现定义了以下缓存状态:

下图概述了控制器配置为 L1 缓存时的状态转换:

L1 cache state machine

转换标有来自 cpu 的传入请求(或内部生成的,例如 Replacements)和向下游发送的结果传出请求。为了简单起见,该图省略了不改变状态的请求(例如,缓存命中)和无效监听(最终状态始终为 I)。为了简单起见,它也只显示了 MOESI 协议中的典型状态转换。在 CHI 中,最终状态最终将由响应者返回的数据类型决定(例如,请求者可能会收到 UDUC 数据作为对 ReadShared 的响应)。

下图显示了 中间级 缓存控制器(例如,私有 L2,共享 L3,HNF 等)的转换:

Intermediate cache state machine

Intermediate cache directory states

与前一种情况一样,为了简单起见省略了缓存命中。除了缓存状态外,还定义了以下目录状态以跟踪上游缓存中存在的行:

当该行同时存在于本地缓存和上游缓存中时,可能有以下组合状态:

RUSCRUSD 状态(上图中省略)用于跟踪控制器仍具有独占访问权限但不在其本地缓存中的行。这在非包含 (non-inclusive) 缓存中是可能的,其中本地块可以被释放而无需反向无效上游副本。

当缓存控制器是 HNF(主节点)时,状态事务与中间级缓存基本相同,除了以下差异:

有关 DCT 和 DMT 事务的更多信息,请参见 CHI 规范 中的第 1.7 节和第 2.3.1 节。DMT 和 DCT 是 CHI 功能,允许请求的数据源直接将数据发送给原始请求者。在 DMT 请求中,SN 直接将数据发送给 RN(而不是先发送给 HN,然后再转发给 RN),而在 DCT 中,HN 请求被监听的 RN(监听对象)直接将行的副本发送给原始请求者。启用 DCT 后,HN 还可以请求监听对象将数据发送给 HN 和原始请求者,以便 HN 也可以缓存数据。这取决于配置参数定义的分配策略。请注意,分配策略也会改变缓存状态转换。为了简单起见,上图说明了一个包含式缓存。

以下是影响协议行为的缓存控制器的主要配置参数列表(有关详细信息和完整参数列表,请参阅协议 SLICC 规范)

这些参数影响缓存控制器性能:

协议实现 节概述了协议实现,而第 支持的 CHI 事务 节描述了实现的 AMBA 5 CHI 规范子集。接下来的部分参考协议源代码中的特定文件,并包括协议的 SLICC 片段。与实际的 SLICC 规范相比,一些片段略有简化。

协议实现

下图概述了缓存控制器实现。

Cache controller architecture

在 Ruby 中,通过使用 SLICC 语言定义状态机来实现缓存控制器。状态机中的转换由到达输入队列的消息触发。在我们的特定实现中,为每个 CHI 通道定义了单独的传入和传出消息队列。传入的请求和监听消息,如果是开始新事务的消息,则通过相同的 请求分配 (Request allocation) 过程,在此过程中我们分配一个事务缓冲区条目 (TBE) 并将请求或监听移动到准备好启动的事务的内部队列。如果事务缓冲区已满,则拒绝请求并发送重试消息。

从 input / rdy 队列中出队的消息要执行的操作取决于目标缓存行的状态。如果行在本地缓存,则行的数据状态存储在缓存中,如果行存在于任何上游缓存中,则目录状态存储在目录条目中。对于具有未完成请求的行,瞬态存储在 TBE 中,并在事务完成时复制回缓存和/或目录。下图描述了事务生命周期的各个阶段以及缓存控制器中主要组件(输入/输出端口、TBETable、Cache、Directory 和 SLICC 状态机)之间的交互。后续部分将更详细地描述这些阶段。

Transaction lifetime

事务分配

下面的代码片段显示了如何处理 reqIn 端口中的传入请求。reqIn 端口从 CHI 的请求通道接收传入消息:

in_port(reqInPort, CHIRequestMsg, reqIn) {
  if (reqInPort.isReady(clockEdge())) {
    peek(reqInPort, CHIRequestMsg) {
      if (in_msg.allowRetry) {
        trigger(Event:AllocRequest, in_msg.addr,
              getCacheEntry(in_msg.addr), getCurrentActiveTBE(in_msg.addr));
      } else {
        trigger(Event:AllocRequestWithCredit, in_msg.addr,
              getCacheEntry(in_msg.addr), getCurrentActiveTBE(in_msg.addr));
      }
    }
  }
}

allowRetry 字段指示可以重试的消息。无法重试的请求仅由先前收到信用的请求者发送(请参阅 CHI 规范中的 RetryAckPCrdGrant)。由 Event:AllocRequestEvent:AllocRequestWithCredit 触发的转换执行单个操作,该操作要么在 TBE 表中为请求保留空间并将其移动到 reqRdy 队列,要么发送 RetryAck 消息:

action(AllocateTBE_Request) {
  if (storTBEs.areNSlotsAvailable(1)) {
    // 为此请求保留一个插槽
    storTBEs.incrementReserved();
    // 将请求移动到 rdy 队列
    peek(reqInPort, CHIRequestMsg) {
      enqueue(reqRdyOutPort, CHIRequestMsg, allocation_latency) {
        out_msg := in_msg;
      }
    }
  } else {
    // 我们没有资源来跟踪此请求;排队重试
    peek(reqInPort, CHIRequestMsg) {
      enqueue(retryTriggerOutPort, RetryTriggerMsg, 0) {
        out_msg.addr := in_msg.addr;
        out_msg.event := Event:SendRetryAck;
        out_msg.retryDest := in_msg.requestor;
        retryQueue.emplace(in_msg.addr,in_msg.requestor);
      }
    }
  }
  reqInPort.dequeue(clockEdge());
}

注意我们不直接从此操作创建和发送 RetryAck 消息。相反,我们在内部 retryTrigger 队列中创建一个单独的触发事件。这是为了防止资源停顿停止此操作。下面的 性能建模 部分更详细地解释了资源停顿。

来自 Sequencer 对象(当控制器用作 L1 缓存时通常连接到 CPU)的传入请求和通过 seqInsnpIn 端口到达的监听请求的处理方式类似,除了:

事务初始化

一旦请求被分配了 TBE 并移动到 reqRdy 队列,就会触发一个事件来启动事务。我们为每种不同的请求类型触发不同的事件:

in_port(reqRdyPort, CHIRequestMsg, reqRdy) {
  if (reqRdyPort.isReady(clockEdge())) {
    peek(reqRdyPort, CHIRequestMsg) {
      CacheEntry cache_entry := getCacheEntry(in_msg.addr);
      TBE tbe := getCurrentActiveTBE(in_msg.addr);
      trigger(reqToEvent(in_msg.type), in_msg.addr, cache_entry, tbe);
    }
  }
}

根据行的初始状态,每个请求都需要不同的初始化操作。为了说明此过程,让我们以处于 SC_RSC 状态(本地缓存中的共享干净和上游缓存中的共享干净)的行的 ReadShared 请求为例:

transition(SC_RSC, ReadShared, BUSY_BLKD) {
  Initiate_Request;
  Initiate_ReadShared_Hit;
  Profile_Hit;
  Pop_ReqRdyQueue;
  ProcessNextState;
}

Initiate_ReadShared_Hit 定义如下:

action(Initiate_ReadShared_Hit) {
  tbe.actions.push(Event:TagArrayRead);
  tbe.actions.push(Event:ReadHitPipe);
  tbe.actions.push(Event:DataArrayRead);
  tbe.actions.push(Event:SendCompData);
  tbe.actions.push(Event:WaitCompAck);
  tbe.actions.pushNB(Event:TagArrayWrite);
}

tbe.actions 存储完成操作所需触发的事件列表。在这种特定情况下,TagArrayReadReadHitPipeDataArrayRead 引入延迟来模拟缓存控制器流水线延迟以及读取缓存/目录标记数组和缓存数据数组(参见 性能建模 部分)。SendCompData 设置并发送 ReadShared 请求的数据响应,WaitCompAck 设置 TBE 以等待来自请求者的完成确认。最后,TagArrayWrite 引入更新目录状态以跟踪新共享者的延迟。

事务执行

初始化后,该行将转换到 BUSY_BLKD 状态,如 transition(SC_RSC, ReadShared, BUSY_BLKD) 所示。BUSY_BLKD 是一个瞬态,表示该行现在有一个未完成的事务。在此状态下,事务由 rspIndatIn 端口中的传入响应消息或 tbe.actions 中定义的触发事件驱动。

ProcessNextState 操作负责检查 tbe.actions 并将触发事件消息入队到 actionTriggers,在所有转换到 BUSY_BLKD 状态结束时执行。ProcessNextState 首先检查挂起的响应消息。如果没有挂起的消息,它将消息入队到 actionTriggers 以触发 tbe.actions 头部的事件。如果有挂起的响应,则 ProcessNextState 不做任何事情,因为事务将在收到所有预期响应后继续进行。

挂起的响应由 TBE 中的 expected_req_respexpected_snp_resp 字段跟踪。例如,由 WaitCompAck 触发的转换执行的 ExpectCompAck 操作定义如下:

action(ExpectCompAck) {
  tbe.expected_req_resp.addExpectedRespType(CHIResponseType:CompAck);
  tbe.expected_req_resp.addExpectedCount(1);
}

这会导致事务等待直到收到 CompAck 响应。

允许在事务有挂起响应时执行某些操作。这些操作使用 tbe.actions.pushNB(即推送/非阻塞)入队。在上面的示例中,tbe.actions.pushNB(Event:TagArrayWrite) 模拟了在事务等待 CompAck 响应时执行的标记写入。

事务完成

当事务没有更多挂起响应且 tbe.actions 为空时,事务结束。ProcessNextState 检查此条件并将“终结器”触发消息入队到 actionTriggers。在处理此事件时,当前的缓存行状态和共享/所有权信息决定了该行的最终稳定状态。如有必要,将在缓存和目录中更新数据和状态信息,并释放 TBE。

冒险处理

每个控制器只允许每行缓存一个活动事务。如果新请求或监听在缓存行处于瞬态时到达,这会产生 CHI 标准中定义的冒险。我们按如下方式处理冒险:

请求冒险: 如前所述分配 TBE,但新事务的初始化被延迟,直到当前事务完成且该行回到稳定状态。这是通过将请求消息从 reqRdy 移动到单独的 stall buffer 来完成的。当当前事务完成时,所有停顿的消息都会添加回 reqRdy,并按原始到达顺序进行处理。

监听冒险: CHI 规范不允许现有请求停顿监听。如果事务正在等待发送到下游的请求的响应(例如,我们发送了 ReadShared 并且正在等待数据响应),我们必须接受并处理监听。只有当请求已被响应者接受并且保证完成(例如,具有挂起数据但也收到 RespSepData 响应的 ReadShared)时,监听才可以停顿。为了区分这些情况,我们使用 BUSY_INTR 瞬态。

BUSY_INTR 表示事务可以被监听中断。当针对处于此状态的行到达监听时,如前所述分配监听 TBE,并根据当前活动的 TBE 初始化其状态。然后监听 TBE 成为当前活动的 TBE。在释放监听之前,监听引起的任何缓存状态和共享/所有权更改都将复制回原始 TBE。当针对处于 BUSY_BLKD 状态的行到达监听时,我们将监听停顿,直到当前事务完成或转换为 BUSY_INTR

性能建模

如前所述,当事务初始化时,缓存行状态立即可知,并且可以无延迟地读取和写入缓存行。这使得实现协议的功能方面变得更加容易。为了模拟时序,我们使用显式操作向事务引入延迟。例如,在 ReadShared 代码片段中:

action(Initiate_ReadShared_Hit) {
  tbe.actions.push(Event:TagArrayRead);
  tbe.actions.push(Event:ReadHitPipe);
  tbe.actions.push(Event:DataArrayRead);
  tbe.actions.push(Event:SendCompData);
  tbe.actions.push(Event:WaitCompAck);
  tbe.actions.pushNB(Event:TagArrayWrite);
}

TagArrayReadReadHitPipeDataArrayReadTagArrayWrite 没有任何功能意义。它们的存在是为了引入真实缓存控制器流水线中存在的延迟,在本例中为:标记读取延迟、命中流水线延迟、数据数组读取延迟和标记更新延迟。这些操作引入的延迟由配置参数定义。

除了显式添加的延迟外,SLICC 还有 资源停顿 (resource stalls) 的概念来模拟资源争用。给定转换期间执行的一组操作,SLICC 编译器自动生成检查这些操作所需的所有资源是否可用的代码。如果有任何资源不可用,则会生成资源停顿并且不执行转换。导致资源停顿的消息保留在输入队列中,协议尝试在下一个周期再次触发转换。

SLICC 编译器以不同方式检测资源:

  1. 隐式。这是输出端口的情况。如果操作将新消息入队,则会自动检查输出端口的可用性。
  2. check_allocate 语句添加到操作中。
  3. 使用资源类型注释转换。

我们使用 (2) 来检查 TBE 的可用性。参见下面的代码片段:

action(AllocateTBE_Snoop) {
  // No retry for snoop requests; just create resource stall
  check_allocate(storSnpTBEs);
  ...
}

这会向 SLICC 编译器发出信号,要求在执行任何包含 AllocateTBE_Snoop 操作的转换之前检查 storSnpTBEs 结构是否有可用的 TBE 插槽。

下面的代码片段举例说明了 (3):

transition({BUSY_INTR,BUSY_BLKD}, DataArrayWrite) {DataArrayWrite} {
  ...
}

DataArrayWrite 注释向 SLICC 编译器发出信号,要求检查 DataArrayWrite 资源类型的可用性。这些注释中使用的 资源请求类型 必须由协议显式定义,以及如何检查它们。在我们的协议中,我们定义了以下类型来检查缓存标记和数据数组中 bank 的可用性:

enumeration(RequestType) {
  TagArrayRead;
  TagArrayWrite;
  DataArrayRead;
  DataArrayWrite;
}

void recordRequestType(RequestType request_type, Addr addr) {
  if (request_type == RequestType:DataArrayRead) {
    cache.recordRequestType(CacheRequestType:DataArrayRead, addr);
  }
  ...
}

bool checkResourceAvailable(RequestType request_type, Addr addr) {
  if (request_type == RequestType:DataArrayRead) {
    return cache.checkResourceAvailable(CacheResourceType:DataArray, addr);
  }
  ...
}

当我们在事务上使用注释时,SLICC 编译器需要 checkResourceAvailablerecordRequestType 的实现。

缓存块分配和替换建模

考虑以下针对 ReadShared 未命中的事务初始化代码:

action(Initiate_ReadShared_Miss) {
  tbe.actions.push(Event:ReadMissPipe);
  tbe.actions.push(Event:TagArrayRead);
  tbe.actions.push(Event:SendReadShared);
  tbe.actions.push(Event:SendCompData);
  tbe.actions.push(Event:WaitCompAck);
  tbe.actions.push(Event:CheckCacheFill);
  tbe.actions.push(Event:TagArrayWrite);
}

所有修改缓存行或作为监听或向下游发送请求的结果接收缓存行数据的事务都使用 CheckCacheFill 操作触发事件。此事件触发一个执行以下操作的转换:

当执行替换时,会初始化一个新的事务来跟踪向下游发送的任何 WriteBack 或 Evict 请求和/或用于反向无效的监听(如果缓存控制器配置为强制包含)。根据配置参数,替换的 TBE 使用来自专用 TBETable 的资源或重用触发替换的 TBE 的相同资源。在这两种情况下,触发替换的事务都会在不等待替换过程的情况下完成。

注意 CheckCacheFill 实际上并不将数据写入缓存块。如果只需要确保分配缓存块,触发替换,并模拟缓存填充延迟。如前所述,如果需要,TBE 数据会在事务完成期间复制到缓存。

支持的 CHI 事务

所有事务均按照 AMBA5 CHI Issue D 规范 中的描述实施。下一节提供了对未由公开文档固定的特定于实现的选项的更详细说明。

支持的请求

支持以下传入请求:

当接收到任何请求时,包含性配置参数会在事务初始化期间进行评估,并且在为请求分配的事务缓冲区条目中设置 doCacheFilldataToBeInvalid 标志。doCacheFill 表示我们应该在本地缓存中保留该行的任何有效副本;dataToBeInvalid 表示我们在完成事务时必须使本地副本无效。

当接收到 ReadSharedReadUnique 时,如果数据以所需状态存在于本地缓存中(例如 ReadUniqueUCUD),则向请求者发送 CompData 响应。响应类型取决于 dataToBeInvalid 的值。

当接收到 ReadOnce 时,如果数据存在于本地缓存中,则始终发送 CompData_I。对于 WriteUniquePtl 处理,请参见下文。

如果发生缓存未命中,可能会执行多种操作,具体取决于 doCacheFilldataToBeInvalid==false 是否成立;以及是否启用了 DCT 或 DMT:

支持的监听 (snoops)

缓存控制器发出并接受以下监听:

监听响应根据规范定义的行当前状态生成。根据数据状态和监听者设置的 retToSrc 的值返回数据。如果设置了 retToSrc,则监听响应始终包含数据。

如果监听对象在任何状态下都有共享者,则将相同的请求发送到上游的所有共享者。对于 SnpSharedFwd/SnpNotSharedDirtyFwdSnpUniqueFwd,分别发送 SnpShared/SnpNotSharedFwdSnpUnique。对于收到的 SnpOnce,仅当该行不在本地存在时才向上游发送 SnpOnce。在这个特定的实现中,总是有上游缓存拥有该行的目录条目。监听永远不会发送到没有该行的缓存

写回和驱逐

当由于容量原因需要驱逐缓存行时(当前不支持缓存维护操作),控制器内部会触发写回。有关替换的更多信息,请参阅第 缓存块分配和替换建模 节。这些内部事件是根据控制器的配置参数生成的:

首先我们释放本地缓存块(以便引起驱逐的请求可以分配新块并完成)。对于 GlobalEviction,向所有上游缓存发送 SnpCleanInvalid。一旦收到所有监听响应(可能有脏数据),就会执行 LocalEviction。LocalEviction 通过发出适当的请求来完成,如下所示:

对于 HNF 配置,行为略有变化:使用向 SNF 的 WriteNoSnp 代替 WriteBackFull,如果行是干净的,则不发出请求。

WriteBack*Evict 请求在下游缓存中处理如下:

冒险 (Hazards)

对当前有未完成事务的行的请求总是停顿,直到事务完成。在有未完成请求时收到的监听按照规范中的要求处理:

在有未完成事务时可能会收到多个监听。在这个特定的实现中,SnpSharedSnpSharedFwd 之后可能是 SnpUniqueSnpCleanInvalid。但是,不可能有来自下游缓存的并发监听。

传入请求和监听都需要分配 TBE。为了防止事务缓冲区满时出现死锁,使用单独的缓冲区来分配监听 TBE。监听不允许重试,因此如果监听 TBE 表已满,snpIn 端口中的消息将被停顿,可能会导致互连中监听通道的严重拥塞。

其他实现说明

协议表

点击这里