使用手动突发 - 2023.2 简体中文

Vitis 高层次综合用户指南 (UG1399)

Document ID
UG1399
Release Date
2023-12-18
Version
2023.2 简体中文

突发传输通过在全局存储器上读取或写入大量数据区块来改善内核 I/O 的吞吐量。突发越大,吞吐量越高,该指标计算方式如下: ((传输的字节数)* (内核频率)/(时间))。最大内核接口带宽为 512 位,如果内核以 300 MHz 频率进行编译,那么理论上每个 DDR 可以实现 = (80-95% 的 DDR 效率)*(512 * 300 MHz)/1 秒 = ~17-19 GB/s。如前文所述,Vitis HLS 可以执行自动突发最优化,以智能化方式从用户代码汇总循环/函数的存储器访问,并在单个突发请求内执行特定大小的读取/写入操作。但正如 突发传输的前置条件和限制 中所述,突发传输也有要求,这些要求有时可能负担过重或者难以满足。

在某些情况下,如果自动突发访问失败,那么有效的解决方案是重写代码或者使用手动突发。在此类情况下,如果您熟悉 AXI4 m_axi 协议,并且理解硬件传输事务建模,那么您可以使用 hls::burst_maxi 类来实现手动突发传输,如下所述。请参阅 GitHub 上的 Vitis-HLS-Introductory-Examples/Interface/Memory/manual_burst,以获取这些概念的示例。另一个解决方案可能是在 AXI4 接口中通过 CACHE 编译指示或指令来使用高速缓存存储器。

hls::burst_maxi 类

hls::burst_maxi 类可以提供一种机制,用于对 DDR 存储器执行读/写访问。这些方法将把类方法的使用行为转换为相应的 AXI4 协议,并在 AXI4 总线信号上发送和接收请求:AW、AR、WDATA、BVALID 和 RDATA。这些方法可以控制 HLS 调度器的突发行为。适配器可从调度器接收命令,并负责向 DDR 存储器发送数据。这些请求将遵循用户指定的 INTERFACE 编译指示选项,例如,max_read_burst_lengthmax_write_burst_length。类方法应仅在内核代码中使用,不得在测试激励文件中使用(类构造函数除外,如下所述)。

  • 构造函数:
    • burst_maxi(const burst_maxi &B) : Ptr(B.Ptr) {}
    • burst_maxi(T *p) : Ptr(p) {}
      重要: HLS 设计和测试激励文件必须位于不同文件内,因为构造函数 burst_maxi(T *p) 仅在 C 语言仿真模型内可用。
  • 读取方法:
    • void read_request(size_t offset, size_t len);

      此方法用于对 m_axi 适配器执行读取请求。如果 m_axi 适配器内部的读取请求队列未满,那么该函数就会立即返回,否则它会等待至空间变为可用为止。

      • offset:指定存储器偏移,将从该指定偏移位置读取数据
      • len:指定调度器突发长度。此突发长度将发送至适配器,随后,适配器即可将其转换为标准 AXI AMBA 协议
    • T read();

      此方法用于将数据从 m_axi 适配器传输至调度器 FIFO。如果数据不可用,read() 将执行阻塞。read() 方法应调用 len 次,具体次数在 read_request() 中指定。

  • 写入方法:
    • void write_request(size_t offset, size_t len);

      此方法用于对 m_axi 适配器执行写入请求。如果 m_axi 适配器内部的写入请求队列未满,那么该函数就会立即返回。

      • offset:指定存储器偏移,数据应写入该指定偏移位置
      • len:指定调度器突发长度。此突发长度将发送至适配器,随后,适配器即可将其转换为标准 AXI AMBA 协议
    • void write(const T &val, ap_int<sizeof(T)> byteenable_mask = -1); 

      此方法用于将数据从调度器的内部缓冲器传输到 m_axi 适配器。如果内部写入缓冲器已满,那么它会执行阻塞。byteenable_mask 用于在 WDATA 中启用字节。默认情况下,它将启用传输的所有字节。write() 方法应调用 len 次,具体次数在 write_request() 中指定。

    • void write_response();

      此方法会执行阻塞,直至从全局存储器返回所有写入响应为止。此方法的调用次数应与 write_request() 相同。

在 HLS 设计中使用手动突发

在 HLS 设计中,如果您发现自动突发传输未按期望方式工作,并且您无法按需对设计进行最优化,那么您可以使用 hls::burst_maxi 对象来实现读取和写入传输事务。在此情况下,您将需要修改自己的代码,将原始指针实参替换为 burst_maxi(作为函数实参)。这些实参必须供 burst_maxi 类的 readwrite 显式方法访问,如以下示例所示。

以下显示的是原始代码样本,它使用指针实参从全局存储器读取数据。
void dut(int *A) {
  for (int i = 0; i < 64; i++) {
  #pragma pipeline II=1
      ... = A[i]
  }
}

在以下修改后的代码中,该指针被替换为 hls::burst_maxi<> 类对象和方法。在此示例中,HLS 调度器将 4 个请求(len 均为 16)从端口 A 放置到 m_axi 适配器上。该适配器将其存储在 FIFO 内部,只要 AW/AR 总线可用,它就会将请求发送到全局存储器。在 64 次循环迭代中,read() 命令会发出阻塞调用,调用将等待全局存储器返回的数据。数据可用后,HLS 调度器将从 m_axi 适配器 FIFO 读取此数据。

#include "hls_burst_maxi.h"
void dut(hls::burst_maxi<int> A) {
  // Issue 4 burst requests
  A.read_request(0, 16); // request 16 elements, starting from A[0]
  A.read_request(128, 16); // request 16 elements, starting from A[128]
  A.read_request(256, 16); // request 16 elements, starting from A[256]
  A.read_request(384, 16); // request 16 elements, starting from A[384]
  for (int i = 0; i < 64; i++) {
  #pragma pipeline II=1
      ... = A.read(); // Read the requested data
  }
}

在以下示例 2 中,HLS 调度器/内核会将 2 个请求从端口 A 放置到适配器上,第一个请求的 len 为 2,第二个请求的 len 为 1,总计 2 个写入请求。随后,它会发出对应的请求,因为总计突发长度为 3 条写入命令。该适配器将这些请求存储在 FIFO 内部,只要 AW 和 W 总线可用,它就会将请求和数据发送到全局存储器。最后,使用 2 条 write_response 命令等待 2 个 write_requests 的响应。

void trf(hls::burst_maxi<int> A) {
  A.write_request(0, 2);
  A.write(x); // write A[0]
  A.write_request(10, 1);
  A.write(x, 2); // write A[1] with byte enable 0010
  A.write(x); // write A[10]
  A.write_response(); // response of write_request(0, 2)
  A.write_response(); // response of write_request(10, 1)
}

在 C 语言仿真中使用手动突发

您可将常规阵列传递到顶层函数,构造函数将把此阵列自动转换为 hls::burst_maxi

重要: HLS 设计和测试激励文件必须位于不同文件内,因为 burst_maxi(T *p) 构造函数只能在 C 语言仿真模型内使用。
#include "hls_burst_maxi.h"
void dut(hls::burst_maxi<int> A);
 
int main() {
  int Array[1000];
  dut(Array);
  ......
}

使用手动突发进行性能最优化

Vitis HLS 将突发行为划分为两种类型:流水线突发和顺序突发。

流水线突发
流水线突发通过在单个请求内读取或写入最大量的数来改善吞吐量。如果 read_requestwrite_requestwrite_response 调用位于循环外部,那么编译器就会推断流水线突发,如以下代码示例所示。在以下示例中,大小 (size) 是从测试激励文件发送的变量。
9  int buf[8192];
10  in.read_request(0, size);
11  for (int i = 0; i < size; i++) {
12  #pragma HLS PIPELINE II=1
13     buf[i] = in.read();
14     out.write_request(0, size*NT);
17     for (int i = 0; i < NT; i++) {
19        for (int j = 0; j < size; j++) {
20        #pragma HLS PIPELINE II=1
21           int a = buf[j];
22           out.write(a);
23  }
24 }
25 out.write_response();
图 1. 综合结果

如上图所示,该工具从用户代码推断突发,并在编译时提及长度(作为变量)。

图 2. 性能优势

在运行时期间,HLS 编译器会发送 length = size 突发请求,适配器按用户指定的 burst_length 编译指示选项对该请求进行分区。在此情况下,默认突发长度设置为 16,并在 ARlen 通道和 AWlen 通道内使用。由于传输期间没有气泡,因此读/写通道已实现最大吞吐量。

图 3. 协同仿真结果
顺序突发

此突发为数据大小较小的顺序突发,其中读取请求、写入请求和写入响应均位于循环主体内部,如以下代码片段中所示。顺序突发的缺点在于后续请求 (i+1) 依赖于先前请求 (i) 才能完成,因为它会等待读取请求、写入请求和写入响应完成,这将导致请求之间出现间隔。顺序突发效率不及流水线突发,因为它多次读取或写入少量数据,以补偿循环边界。虽然这将限制吞吐量改善,但是顺序突发仍好于无突发。

  void transfer_kernel(hls::burst_maxi<int> in,hls::burst_maxi<int> out, const int size )
{
  #pragma HLS INTERFACE m_axi port=in depth=512 latency=32 offset=slave
  #pragma HLS INTERFACE m_axi port=out depth=5120 offset=slave latency=32
 
        int buf[8192];
 
 
        for (int i = 0; i < size; i++) {
             in.read_request(i, 1);
        #pragma HLS PIPELINE II=1
            buf[i] = in.read();
        }
 
 
 
        for (int i = 0; i < NT; i++) {
            for (int j = 0; j < size; j++) {
                out.write_request(j, 1);
#pragma HLS PIPELINE II=1
                int a = buf[j];
                out.write(a);
                out.write_response();
 
            }
 
        }
 
    }
图 4. 综合结果

如上报告样本所示,该工具实现了长度为 1 的突发。

图 5. 性能影响

读/写循环 R/WDATA 通道的间隔与读/写时延相等,如 AXI4 主接口 中所述。对于读取通道,循环会等待全局存储器返回所有读取数据。对于写入通道,最内层循环会等待全局存储器返回响应 (BVALID)。这将导致性能降级。协同仿真结果同样显示,对于此突发语义,性能减半。

图 6. 性能估算

功能特性和局限性

  1. 如果 m_axi 元素为结构体:

    • 此结构体将打包到宽整型 (wide int) 内。不允许对此结构体执行分解。
    • 结构体大小必须为 2 的幂,不应超过 1024 位或 config_interface -m_axi_max_bitwidth 命令指定的最大宽度。
  2. 不允许 burst_maxi 端口的 ARRAY_PARTITION 和 ARRAY_RESHAPE。

  3. 您可将 INTERFACE 编译指示或指令应用于 hls::burst_maxi,以定义 m_axi 接口。如果 burst_maxi 端口与其他端口捆绑,则此捆绑中的所有端口都必须设置为 hls::burst_maxi 并且必须具有相同的元素类型。

    void dut(hls::burst_maxi<int> A, hls::burst_maxi<int> B, int *C, hls::burst_maxi<short> D) {
      #pragma HLS interface m_axi port=A offset=slave bundle=gmem // OK
      #pragma HLS interface m_axi port=B offset=slave bundle=gmem // OK
      #pragma HLS interface m_axi port=C offset=slave bundle=gmem // Bad. C must also be hls::burst_maxi type, because it shares the same bundle 'gmem' with A and B
      #pragma HLS interface m_axi port=D offset=slave bundle=gmem  // Bad. D should have 'int' element type,  because it shares the same bundle 'gmem' with A and B
    }
  4. 您可以使用 INTERFACE 编译指示或指令来指定 num_read_outstandingnum_write_outstanding,并使用 max_read_burst_lengthmax_write_burst_length 来定义 m_axi 适配器的内部缓冲器大小。
    void dut(hls::burst_maxi<int> A) {
      #pragma HLS interface m_axi port=A num_read_outstanding=32 num_write_outstanding=32 max_read_burst_length=16 max_write_burst_length=16
    }
  5. 不支持 INTERFACE 编译指示或指令 max_widen_bitwidth,因为 HLS 将不会更改 hls::burst_maxi 端口的位宽。
  6. 您必须先发出 read_request,再发出 read,或者先发出 write_request,再发出 write
    void dut(hls::burst_maxi<int> A) {
      ... = A.read();  // Bad because read() before read_request(). You can catch this error in C-sim.
      A.read_request(0, 1); 
    }
  7. 如果读取组的地址和生存期 (read_request() > read()) 与写入组 (write_request() > write() > write_response()) 重叠,那么该工具将无法保证访问顺序。C 语言仿真将报告错误。
    void dut(hls::burst_maxi<int> A) {
      A.write_request(0, 1);
      A.write(x);
      A.read_request(0, 1);
      ... = A.read();  // What value is read? It is undefined. It could be original A[0] or updated A[0].
      A.write_response();
    }
     
    void dut(hls::burst_maxi<int> A) {
      A.write_request(0, 1);
      A.write(x);
      A.write_response();
      A.read_request(0, 1);
      ... = A.read();  // this will read the updated A[0].
    }
  8. 如有多个 hls::burst_maxi 端口捆绑到同一个 m_axi 适配器,并且其传输事务生存期重叠,那么行为将不可预测。
    void dut(hls::burst_maxi<int> A, hls::burst_maxi<int> B) {
        #pragma HLS INTERFACE m_axi port=A bundle=gmem depth = 10
        #pragma HLS INTERFACE m_axi port=B bundle=gmem depth = 10 
        A.read_request(0, 10);
        B.read_request(0, 10);
         
        for (int i = 0; i < 10; i++) {
            #pragma HLS pipeline II=1
            …… = A.read(); // get value of A[0], A[2], A[4] …
            …… = B.read();  // get value of A[1], A[3], A[5] …
        }
    }
  9. 不支持将读取/写入请求与读取/写入操作包含在不同数据流进程内。数据流检查程序将报告错误:multiple writes in different dataflow processes are not allowed
    例如:
    void transfer(hls::burst_maxi<int> A)  {
    #pragma HLS dataflow
       IssueRequests(A); // issue multiple wirte_request() of A
       Write(A); // multiple writes to A
       GetResponse(A); // write_response() of  A
    }

潜在陷阱

以下是您在实现手动突发方法时必须留意的部分问题:

  • 死锁:错误使用手动突发可能导致死锁。

    read() 命令前发出过多 read_requests 将导致死锁,因为 read_request 循环将把请求推入读取请求 FIFO,仅在全局存储器读取操作完成后才会清空此 FIFO。read() 命令的工作是从适配器 FIFO 读取数据,并将请求标记为已完成,然后,将从 FIFO 迅速发出 read_request,随后即可将新请求推送到此 FIFO。

    //reads/writes. will deadlock if N is larger
    for (i = 0; i < N; i++)
     {   A.read_request(i * 128, 16);} 
    for (i = 0; i < 16 *N; i++) {  … = A.read();}
     
     
    for (int i = 0; i < N; i++) {
        p.write_request(i * 128, 16);
      }
      
      for (int i = 0; i < N * 16; i++) {
        p.write(i);
      }
      
      for (int i = 0; i < N; i++) {
        p.write_response();
      }

    在以上示例中,如果 N 大于 read_request,并且读取 FIFO 将满,因为它趋向于 N/2。读取请求循环将无法完成,读取命令循环将无法启动,从而导致死锁。

    注释: 对于 write_request()write() 命令也同样如此。
  • AXI 协议违例:写入请求与写入响应数量应相等。请求与响应数量不相等将导致发生 AXI 协议违例