Xilinx Vitis学习-ug1393
学习Vitis
首先看了一些大神的文章,他主要讲芯片与AI,大家可以看看:AI芯片杂谈-2022年 - 吴建明wujianming - 博客园
xilinx:
xilinx推出了针对ACAP自适应加速卡的设计流程 机器学习和数据科学 - Versal ACAP 设计流程 还有开发环境Vitis,以前我们熟悉的是Vivado开发环境,他们两者的区别是:
就 RTL 设计与 IP 封装进程而言,整个进程是相同的,且都会额外输出 .xo 文件。
在 Vivado 开发流程中,您将使用该工具的 IP integrator 手动添加必需的 IP 并将其拼接在一起,或者使用 RTL 定义自上而下的系统。在 Vivado 流程中,您需要在 FPGA 设计外指定整体系统设计,包括指定完整的 PCIe 总线、全局存储器和外设功能特性。您将需要创建定制主机代码,整合驱动程序以访问系统卡或可编程逻辑的各项功能特性。
在 Vitis 应用加速流程中,编译器会将此 RTL 内核或多个内核链接到 Alveo 加速器卡的目标平台,使用 IP integrator 功能自动构建系统设计。Vitis 编译器会将 Memory Subsystem (MSS) IP 自动例化到系统设计内,以便管理内核、主机处理器和存储器资源之间的 AXI 流量。MSS 的配置衍生自链接期间所使用的配置文件的连接部分,如 链接内核 中所述。XRT 提供了底层运行时和驱动程序,并提供了 API 用于开发主机应用,以访问加速器卡。
Vivado 开发流程要求设计完成综合、布局布线和时序收敛。Vitis 流程会在链接进程期间创建 Vivado 工程,并自动完成设计的综合与实现。虽然这是在 Vitis 工具流程内自动完成的,但您也可以在 Vivado 工具内使用 Tcl 脚本或者通过交互式操作来全权控制整个进程,并生成期望的结果。
虽然 Vivado 和 Vitis 工具均可提供系统设计功能,但 Vitis 工具能够将所需生态系统的大部分功能加以标准化。Vitis 流程能够自动执行多个步骤,例如,集成 PCIe 和添加全局存储器。这样您便能专心开发 RTL 函数,缩短总体开发时间。Vitis 流程还能够进一步简化无缝移植到另一个加速器卡的过程,并且在 RTL 组件或主机代码中大部分情况下无需任何更改。
意思就是Vitis更好更方便。
需要学习的文档太多了,可在下面这里
Documentation Portalhttps://docs.xilinx.com/search/all?content-lang=en-US
搜索所有xilinx的文档。而在下面这里 Documentation Portalhttps://docs.xilinx.com/r/zh-CN/ug1393-vitis-application-acceleration
这里看到的就是中文版。我要学习的就是以下内容:
然后下面Vitis-AI开发总流程:
https://v.youku.com/v_show/id_XNDYyNjQxMDU5Ng%3D%3D.html 这里介绍了使用Vitis AI和Alveo U50进行深度学习加速。
下面是AI开发对板子的要求等,大家看可以直接看ug1414:
另外官网一个地方写错了吧,说Alveo U2000支持AI,采用的是16nm的UltraScale架构,可是AI的支持列表里却没有UltraScale!看下图16nm根本不是UltraScale,而是UltraScale+ ,所以估计是翻译错了,希望官网改过来:
另外发现使用xilinx进行AI引擎开发的人很少,根本查不到什么,只查到Vitis AI--个人调试篇 Vitis AI VART自动驾驶应用_硬码农二毛哥的博客-CSDN博客 为什么?不应该用户很多然后一查出来一大堆吗?!
另外这里Vitis-AI/model_zoo at master · Xilinx/Vitis-AI · GitHub例举了支持的模型类型,然后这里Documentation Portal例举了不支持的模型类型。
~~~~~~~~~~~~~~~~~~~~~~~~~以下是另一巨头 Intel~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
提起intel的AI加速,首先想到的就是OpenVINO,以前OpenVINO支持CPU、GPU、VPU、GNA、FPGA,但现在从21年10月开始不支持了:Download Intel® Distribution of OpenVINO™ Toolkit
Version 2020.3.2 Long-Term Support (LTS) Release
By downloading, you agree to our Privacy and Terms of use.
FPGA Support Ended October 2021
Intel® Distribution of OpenVINO™ toolkit version 2020.3.2 LTS no longer supports Intel® Vision Accelerator Design with an Intel® Arria® 10 FPGA and the Intel® Programmable Acceleration Card with Intel® Arria® 10 GX FPGA.
To increase the level of customization possible in FPGA deep learning, Intel has transitioned to the next-generation programmable deep-learning solution based on FPGAs. As part of this transition, support offered in the 2020.3.2 LTS release ended October 2021.
Customer inquiries regarding Intel® FPGA AI Suite should be directed to your Intel Programmable Solutions Group account manager or subscribe for the latest updates.
所以openvino对FPGA的支持已经从原openvino中独立出来了,现在叫Intel FPGA AI Suite。这个东西太新了,到今天为止还没满一年,搜索Intel FPGA AI Suite,都没有什么东西出来,只有intel员工自己2022.6.8发表的一篇文章:Intel® FPGA AI Suite melds with OpenVINO™ toolkit to generate heterogeneous inferencing systems - Intel Communitieshttps://community.intel.com/t5/Blogs/Products-and-Solutions/FPGA/Intel-FPGA-AI-Suite-melds-with-OpenVINO-toolkit-to-generate/post/1390694?wapkw=Intel%C2%AE%20FPGA%20AI%20Suite
所以,我们决定放弃intel了,直接选Xilinx。 那么就要开始学习xilinx的东西了。
~~~~~~~~~~~~~~~~~~~~关于xilinx Vitis的学习~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
由官方文档ug1393知道:
1,Vitis要求:操作系统64位,我用的ubuntu,只要是Ubuntu 18.04.4 LTS, 18.04.5 LTS,20.04 LTS, 20.04.1 LTS, 20.04.2 LTS,20.04.3 LTS 是这几个版本中的就行。Vitis软件平台包含IDE、命令行、Vivado和Vitis HLS。第3章一开始就讲了安装Installing the Vitis Software Platform的步骤、注意事项、用sudo installLibs.sh验证Vitis软件平台安装是否正确。vitis软件平台还包含了很多加速库如OpenCV、BLAS、Fintech、AI/ML等。接着继续安装Xilinx Runtime(XRT),它包含了用户空间和内核驱动组件,处理主机程序与内核的交互,为xilinx FPGA提供软件接口,所以除了有arm的嵌入式平台其它平台必须得安装XRT。接下来安装嵌入式平台,嵌入式平台要求有linux内核,XRT rootfs和主机端交叉编译sysroot,我们可以编译自己的平台软件,也可以使用嵌入式Vitis平台预编译的软件通用镜像(ug1393中叫pre-built software common images for Embedded Vitis platforms,经网友提醒images原来这里翻译成镜像)以上安装完毕后,为Vitis、Vivado、XRT以及平台设置环境变量。视频教程大家可以参考【全网唯一】从零开始的Vitis教程 第一集: 软件环境的安装到编译和运行第一个ZCU104加速应用_哔哩哔哩_bilibili另外ubuntu18.04的网络若连接不上可参考 Ubuntu18.04的网络配置__Zephyrus_的博客-CSDN博客 这里是一些要下载的:Downloads
2,加速开发流程(application acceleration flow,借用于XRT和Vitis SDK---提供软件开发工具链,如编译器、交叉编译器、调试工具等):软件部分(主机端编程)是在x86主机或嵌入式服务器上使用C/C++,同时调用OpenCL API控制与加速卡的交互。硬件部分(kernel)可以用C/C++、OpenCL或RTL,将程序编译成FPGA可执行的二进制文件 .xclbin然后运行在FPGA的PL区域。Vitis软件平台支持并发开发以及软硬件部分的异构应用。
主机与内核之间的通讯主要靠PCIe或AXI总线传输,控制命令的读写主要依靠内核中的特殊地址映射寄存器,主机与内核之间的数据缓存主要发生在全局内存中,不能用主机内存(因为只有主机可以访问)。所以FPGA中包含内核、全局内存以及供内存转换的direct memory access(DMA)。FPGA上可以有各种内核实例,可以有不同类型的内核,也可以有同一个内核的不同实例。以下左边是Alveo加速卡开发流程图(x86+Alveo),右边是arm+Ultrascale+等卡的开发流程图:
由左图可以看到,PL区域C/C++内核会被Vitis编译器(v++)或HLS编译器编译、RTL内核则会被package_xo命令行编译成.xo文件,然后v++会将.xo文件链接成.xclbin二进制文件,供器件加载到目标平台上(进行软硬件仿真SW/HW emulation、或直接在加速卡上运行)。如果是在目标平台上进行软硬件仿真,v++是将内核的仿真模型生成在FPGA二进制文件中,这时我们可以编译、运行、以更快的时钟周期去验证设计、调试应用并对性能评估,以此来检验我们的目的是否达到。如果是直接在加速卡上跑(默认是这项即直接在硬件平台上运行),v++会为硬件加速器生成.xclbin文件,会用Vivado对.xclbin文件和x86上的可执行文件进行综合。
上面的右图即嵌入式服务器开发流程图,就是在arm服务器上开发,然后内核是在Versal ACAP、Zynq UltraScale+ MPSoc或Zynq-7000 Soc开发板上运行。主机PS端编译即在Cortex ® -A72 or Cortex-A53服务器上用GNU arm交叉编译器生成ELF文件,后续与器件的PL区域、AI引擎区域发生交互。有些器件如Versal ACAP的某些系列就将AI 引擎数组对长指令的处理器与SIMD矢量单元结合起来对高密集型计算(如5G、AI等应用)提供性能优化。AI引擎graphs和kernels是由vitis工具如aiecompiler 进行编译的。PL端编译链接的内容左右两图一样,不再赘述。System Package就是用v++ --package命令将一些必要文件如主机端、PL端的二进制文件打包,来对系统进行配置和重启,然后加载应用并将应用跑起来。这一步是为了后续的软硬件仿真、调试或者将应用生成在SD卡中后续好在目标平台上运行。运行应用程序部分由左右两图看到略有差别,p30这页没讲具体差别在哪里。
无论左图或右图,最后的运行应用部分,大家会疑惑硬件仿真不就是直接在硬件上跑吗,为什么还分SW/HW/Running the application on hardware。原来SW是让主机端合内核代码在主机服务器上运行起来,方便我们从源代码层面检查错误、调试、验证功能。HW则是将内核代码编译成硬件模型(RTL)然后在专用的模拟器(具体是什么??是Vitis上带的模拟器吗?)上运行,这会耗时长一点但会针对我们设计的内核给出更详细的、时钟准确的报告,这方便我们测试内核在FPGA中的逻辑功能并得到更原始的性能评估报告。Running the application on hardware则是将内核代码编译成RTL然后在FPGA(这个FPGA是Vitis模拟的吗?区别于后面所说真正的物理的FPGA卡?)上执行,产生二进制文件,后续可以将二进制文件放在真正的物理FPGA卡上去运行。
Github上提供了学习导航 https://github.com/Xilinx/Vitis-Tutorials 和例子https://github.com/Xilinx/Vitis_Accel_Examples:我作为新手要看的是https://github.com/Xilinx/Vitis-Tutorials/tree/2022.1/Getting_Started 大家可以根据这几个链接选择自己最适合的学习路线。
ug1393第二大块内容包含5~6章,介绍的是软件编程人员、RTL设计人员怎么用Alveo加速卡进行加速,这部分我先跳过。ug1393第三大块是重头,包含7~12章:
3,先看第7章(编程模型):众所周知,主机端代码是在X86或arm上运行,XRT将计算密集型任务放到了FPGA的PL区域或Versal AI引擎中的硬件内核上执行。
Vitis核心开发包支持MPSoCs、Kria SOMs、Versal ACAPs、UltraScale+ FPGA(之前不是还说包含Zynq-7000 SoC、Alveo加速卡吗?这里怎么没写?)通过PCIe总线连接x86服务器、或通过AXI4接口连接arm服务器,FPGA器件的PL区域执行带xilinx对象文件 .xo 的设备二进制文件 .xclbin 。FPGA上有一或多个全局内存存储体banks,主机和kernel之间的数据传输就是通过这些全局内存banks,FPGA上跑的内核可以有多个内存接口m_axi,banks与m_axi之间如何连接就由Vitis链接器配置、定义,这在链接内核中会讲到。内核也能使用流接口axis将数据以流的形式从一个内核传到另一个内核,流接口也由v++配置并定义。
从Vitis加速开发流程图中,我们可以看到kernel也就是xilinx对象文件.xo就是在FPGA器件的PL区域执行的处理单元,无论kernel是由流程图中哪种语言写的,它们都具有相同的属性且必须满足相同的要求。内核可以被定义成软件可控(如由主机端控制)或软件不可控(即由数据驱动)。软件可控kernels会有一个可编程寄存器接口,允许主机通过寄存器读写与kernel进行交互,这是用得最广泛的一种方式。软件可控kernel要么由用户要么由XRT控制,其实XRT控制内核是用户控制内核的一种特殊形式而已。由谁控制kernel最大的区别就是执行模式,用户控制内核对RTL设计者更好(这个我不用,所以用户控制内核这部分我就暂时不看);XRT控制内核依赖的是由Vitis HLS产生的ap_ctrl_chain(默认)和ap_ctrl_hs执行结构,这对C++书写内核、Vitis HLS编译内核的C++开发者最友好。XRT控制内核用户不需知道寄存器和内核执行结构的底层细节,我们只管使用高级命令即可,XRT所控制的内核对象的类名叫做xrt::kernel。XRT控制内核与user控制内核还有一个不同就是硬件接口不一样。它们相同的是内核的接口的作用就是用于主机、其它内核、设备I/O接口之间的数据转换,接口的要求也相同,必须是可编程接口(kernels只能有一个AXI4-Lite接口)、数据接口(可有多个AXI4内存映射为AXI4流的接口)、时钟和重置接口这三大要求。其中,时钟与重置的条件对软件可控与不可控内核都一样:C内核中不需要用户给时钟端口、重置端口任何输入,因为HLS会为时钟端口ap_clk、重置端口ap_rst_n产生RTL。HLS内核只能有一个时钟/重置。
先普及一下内核接口类型:主要有三大类型:寄存器类型(AXI4-Lite)、内存映射类型(M_AXI)、流类型(AXI4流)。寄存器类型接口必须是由一个单独的AXI4-Lite来实现,作用就是主机和kernel之间的标量传输,寄存器读写在主机端进行初始化。内存映射接口可由单独或多个AXI4主接口实现,用于全局内存(DDR、PLRAM、HBM)(这三个之间吧,不是指的与主机对之间对吧?)双向数据传输,定义了内存传输的延迟(这里不是这样翻译吧?),内核就像是访问全局内存中数据的霸主,主机会为数据的大小分配不同的buffer,这些指向kernel的buffer的初始地址是由主机通过AXI4-Lite接口提供的。流类型接口是由单独或多个AXI4流接口实现的,用于kernels之间单向的数据传输,访问模式是连续访问,不用使用全局内存,对数据设置没有要求,边带信号可以用于表示流的最后一个值(这里翻译不好)。
继续说回XRT控制内核,必须定义是顺序执行还是并行/重叠执行的内核执行模式。默认使用的控制结构是ap_ctrl_chain,允许同一个kernel多种执行并行/重叠,这样可以提高吞吐率。ap_ctrl_hs则是指定kernels严格顺序执行。无论哪种控制结构,我们都可以设置kernel执行的循环次数,也可以在主机中重置即重新开始执行kernel。
软件不可控内核:这些kernels是存在于设备中但软件端不可见也不能访问,显然它们也不需要可编程寄存器接口。这些kernels至少有一个AXI4流接口用来与系统其它部分同步。软件不可控内核只有当软件可控内核用不了时候才能用,这种内核没有寄存器接口(也用不着软件API,那么主机也不能与这些kernel进行直接交互),所以控制信息只能通过内核的数据接口来传递。
4,第8章讲的是主机端编程:主机端编程语言可用XRT C++ API或OpenCL API的内置C++来写。总体来说,主机端开发主要分四个步骤:指定加速卡设备ID并加载.xclbin文件、设置kernel以及kernel参数、将数据从主机传到kernel、运行kernel并返回结果。
使用XRT API时,主机必须与xrt_coreutil库链接起来,就类似下面这样:
g++ -g -std=c++14 -I$XILINX_XRT/include -L$XILINX_XRT/lib -lxrt_coreutil -
pthread
用XRT C++ API编译主机端程序要求 -std=c++14 、gcc版本>=4.9.0或者 -std=c++1y、gcc version<4.9.0 。p75页最后一段话什么意思?如果主机端是多线程程序,如果调用Vitis SDK的fork(),并不会复制所有的线程。因此,Vitis SDK中运行的并不是完整的程序。建议在Vitis中使用posix_spawn重新启动一个处理系统?
先看第一步:指定加速卡设备ID(用XRT中的xrt::device类,在xrt/xrt_kernel.h中)并加载设备文件.xclbin到设备中(xrt::xclbin)。加载头文件后按下方这样既可,设备ID可以用xbutil命令查看:
//Setup the Environment
unsigned int device_index = 0;
std::string binaryFile = parser.value("kernel.xclbin");
std::cout << "Open the device" << device_index << std::endl;
auto device = xrt::device(device_index);
std::cout << "Load the xclbin " << binaryFile << std::endl;
auto uuid = device.load_xclbin(binaryFile);
主机端编程第二步:设置kernel参数,因为主机端会交互的所有kernels都定义在.xclbin中,所以参数也在那。XRT API提供了xrt::kernel类来访问.xclbin文件中的kernels,主机端通过这个类对象可以将.xclbin中的XRT控制内核加载到Xilinx 设备上。这些需要加载头文件xrt/xrt_kernel.h、xrt/xrt_bo.h 如下是将程序uuid中的内核vadd加载到device上,我们事先可以用xclbinutil命令查看.xclbin中有哪些内核:
auto krnl = xrt::kernel(device, uuid, "vadd");
在加载好内核后,接着要定义放参数的buffer对象,以便数据能在主机与内核实例或计算单元之间传输:
std::cout << "Allocate Buffer in Global Memory\n";
auto bo0 = xrt::bo(device, vector_size_bytes, krnl.group_id(0));
auto bo1 = xrt::bo(device, vector_size_bytes, krnl.group_id(1));
auto bo_out = xrt::bo(device, vector_size_bytes, krnl.group_id(2));
xrt::kernel中提供了返回内核每个参数对应内存的方法kernel.group_id(),可以为每一个内核的矢量参数指定一个buffer对象,因为标量参数不能用buffer(You will assign a buffer object to each kernel buffer argument because buffer is not created for scalar arguments.是这样翻译吗?)。我们之前在编译.xclbin文件时可以指定实例化的kernels数目、或者是用--connectivity.nk选项指定硬件中的计算单元CU。然后我们就可以在主机端访问这些CUs。只要这些CUs有相同的接口连接即相同的内存krnl.group_id,就能用于同一个kernel对象xrt::kernel执行。如下vadd_1、vadd_2这两个CU就有相同的接口,所以能被krnl1执行:
krnl1 = xrt::kernel(device, xclbin_uuid, "vadd:{vadd_1,vadd_2}");
主机端编程第三步:使用buffer对象xrt::bo进行主机与kernels之间的数据传输。这个类的构造函数通常会创建一个4K对齐的buffer对象,xilinx建议如果可以至少设置大小为2MB,但即使吞吐量最大也别超过4GB!下面的代码就创建了主机和设备端的buffer对象(但哪个是主机端的哪个是设备端的啊?):
std::cout << "Allocate Buffer in Global Memory\n";
auto bo0 = xrt::bo(device, vector_size_bytes, krnl.group_id(0));
auto bo1 = xrt::bo(device, vector_size_bytes, krnl.group_id(1));
auto bo_out = xrt::bo(device, vector_size_bytes, krnl.group_id(2));
上面buffer创建完毕后,就应该把数据从主机传到kernel,这有两种方式可选。第一种:用xrt::bo::write将数据从主机写到设备,然后可以用xrt::bo::sync +XCL_BO_SYNC_BO_TO_DEVICE来实现主机到设备之间的数据同步;用xrt::bo::sync+ XCL_BO_SYNC_BO_FROM_DEVICE将数据从设备同步到主机端,然后用xrt::bo::read 将数据从设备读到主机 :
bo0.write(buff_data);
bo0.sync(XCL_BO_SYNC_BO_TO_DEVICE);
bo1.write(buff_data);
bo1.sync(XCL_BO_SYNC_BO_TO_DEVICE);
...
bo_out.sync(XCL_BO_SYNC_BO_FROM_DEVICE);
bo_out.read(buff_data);
第二种方式就是xrt::bo::map如下将buffer对象的内容映射给主机端内存中,主机端随后就可以对这些数据进行读写:
auto bo0_map = bo0.map<int*>();
auto bo1_map = bo1.map<int*>();
auto bo_out_map = bo_out.map<int*>();
如果主机端对这些内存write,后续设备端还要读这些内存,一定要在主机write后sync(XCL_BO_SYNC_BO_TO_DEVICE);同步到设备中。
主机端编程第四步:用xrt::run使kernels在设备上运行起来。第一种方式如下,这是异步的,所以需要加wait等待kernel运行结束。
std::cout << "Execution of the kernel\n";
auto run = krnl(bo0, bo1, bo_out, DATA_SIZE);
run.wait();
还有一种方式如下:内核参数通过run.set_args()来指定,然后通过run.start()开始真正执行,同样需要wait等待。无论是上面还是下面的运行方式,后续都要用sync(XCL_BO_SYNC_BO_FROM_DEVICE)将结果返回到主机端。
auto run = xrt::run(krnl);
run.set_arg(0,bo0); // Arguments are specified starting from 0
run.set_arg(0,bo1);
run.set_arg(0,bo_out);
run.start();
run.wait();
5,第9章C/C++内核:内核函数必须如下结构:
extern "C" {
void kernel_function(int *in, int *out, int size);
}
第7章讲过XRT控制内核有两种执行模式,是由Vitis HLS的阻塞结构(#pragma HLS INTERFACE)来定义的如下。默认的阻塞结构是ap_ctrl_chain让内核的不同实例像流水线一样overlap执行,这大大提高了吞吐率。ap_ctrl_hs阻塞执行结构就是让kernel必须顺序即串行执行。一般默认的流水线形式pipeline执行不了时会自动转成串行执行。
void kernel_name( int *inputs,
... )// Other input or Output ports
{
#pragma HLS INTERFACE ap_ctrl_chain port=return bundle=control
默认情况下,主机端会把控内核的启动与停止,“内核自启动模式”(auto_restart)会在主机启动一次之后自动再运行直到出现“重置”或“重启”,或者可以在程序中预先指定循环启动内核的次数而不用主机显式调用那么多次。“内核自启动模式”好处就是这种半自动化的操作不用再频繁的与主机交互,不用频繁的软件控制;但同时是它提供半同步,以异步、非阻塞、安全的方式改变主机端数据。auto_restart与ap_ctrl_chain常一起出现。
说起数据类型,我们常用int、float等C/C++数据类型,但它们浪费资源且慢,降低性能。使用bit-accurate数据类型占位小且快,故允许逻辑以更快的时钟频率运行。所以我们建议使用bit-accurate或任意精度数据类型代替原始的C/C++数据类型。如使用"ap_int.h"中的ap_int<N>、ap_uint<N>(N是bit,取值1~1024)代替原始C/C++中的int、uint。使用定点Fixed-Point数据类型代替需要更多时钟来实现的浮点型,可以使硬件更少的同时更快且带来相同精度。定点数据类型由整型和小数点部分组成,使用要加上头文件ap_fixed.h。有符号定点ap_fixed<W,I,Q,O,N>和无符号定点ap_ufixed<W,I,Q,O,N>,W是bit取值<1024,I是整型bit宽度,Q是量化模式(比如向上、向下取整、约等于0等),O是溢出模式,N是溢出位宽。(官文ug1399中会详细介绍)
这两种数据类型出现在主机与FPGA的kernel之间数据通过全局内存存储体转换时,如果是标量数据则是直接从主机传到kernel。因为数据传输的时间是较长的,所以尽量让传输数据时就已经开始计算即传输与计算overlap(有点像CUDA、OpenCL的处理方式了,发现讲的很多内容都与OpenCL编程中很像,感觉这些知识都是相通的,不同厂家做同样一件事比如加速开发,遇到的问题都差不多,解决手段也差不多,只不过它们的表达不一样。),这样才是最优的(ug1399中会详细介绍)。Vitis HLS会在v++编译时为kernel函数的每个参数都设置端口interface ports。主要有四种类型:AXI4 主接口m_axi是为函数中的指针参数设置的;AXI4-Lite接口s_axilite是为标量参数、数组控制信号、全局变量、函数返回值设置的;突发传输接口AXI Burst Transfers(没懂,intel却有这个介绍3.5.5. 突发传输(Burst Transfers),应该意思差不多?反正ug1399中会详细讲)
内存映射接口:当参数是指针类型时,就是这种接口。这种接口下内核与主机之间的读写发生在全局内存,此种情况下数据共享极为方便。但是此时内核执行模式只支持顺序执行和pipelined执行。对指针参数,Vitis工具在编译期间会默认使用这种接口类型,当然也可以通过#pragama HLS INTERFACE来指定改变接口类型,如下的内核函数:
void cnn( int *pixel, // Input pixel
int *weights, // Input Weight Matrix
int *out, // Output pixel
... // Other input or Output ports
由上段落的知识可知,这三个参数都是m_axi类型。相当于用下面的方式这样指定,一样的效果:
#pragma HLS INTERFACE m_axi port=pixel offset=slave bundle=gmem
#pragma HLS INTERFACE m_axi port=weights offset=slave bundle=gmem
#pragma HLS INTERFACE m_axi port=out offset=slave bundle=gmem
其中bundle是指定端口名称,对于m_axi类型,.xo文件只有对应的一个端口名即m_axi_gmem,所以上面表示三个参数的接口类型相同,且都映射到相同的端口号上了。当然我们也可以命名属于自己的端口名称。
共享端口节约了FPGA的资源,但限制了性能,因为共享就决定了不能同时进行很多操作意味着某些阻塞。m_axi端口有独立的读/写通道,意味着即使用同一个m_axi端口也可以同时实现读/写,提高性能。但我们还可以通过创建不同bundle名称的端口来提高带宽和吞吐率,不同的bundle名称使得端口在不同的内存存储体中建立连接关系即会映射到不同的全局内存存储体中(上面的代码中没有指定不同的端口名称,故其实吞吐率不高,至少不能并行访问这三个参数)。我们其实还可以指定接口位宽,上面的代码没有指定,故使用的默认位宽64bytes(512bits,其实使用默认的就可以了,因为最大位宽就是512bits,这样可以提高访问效率但缺点是增加了资源消耗、只支持标准C数据类型,不支持聚合类型如ap_int、struct、array)。
对全局内存存储体接口的访问有很大的时延,所以对全局内存的访问必须并行(Burst Accesses to global memory怎么翻译burst?ug1399中介绍的AXI Burst Transfers也是讲这部分内容)。xilinx建议如下操作,按burst方式读取全局内存存储体:
hls::stream<datatype_t> str;
INPUT_READ: for(int i=0; i<INPUT_SIZE; i++) {
#pragma HLS PIPELINE
str.write(inp[i]); // Reading from Input interface
}
注意养成上面的习惯,为每一个for循环取一个名称如上INPUT_READ,大写小写都可。
top_function(datatype_t * m_in, // Memory data Input
datatype_t * m_out, // Memory data Output
int inp1, // Other Input
int inp2) { // Other Input
#pragma HLS DATAFLOW
hls::stream<datatype_t> in_var1; // Internal stream to transfer
hls::stream<datatype_t> out_var1; // data through the dataflow region
read_function(m_in, inp1, in_var1); // Read function contains pipelined for
loop
// to infer burst
execute_function(in_var1, out_var1, inp1, inp2); // Core compute function
write_function(out_var1, m_out); // Write function contains pipelined for
loop
// to infer burst
}
参数为标量时:内核参数为标量时,只能由主机端赋值。内核函数的所有标量参数和return的端口名称bundle必须一样。
void process_image(int *input, int *output, int width, int height)
默认情况下,端口是下面这样:
#pragma HLS INTERFACE s_axilite port=width bundle=control
#pragma HLS INTERFACE s_axilite port=height bundle=control
流接口:如果数据是被顺序访问,那么流接口可以直接让数据在主机和目标平台设备I/O之间直接通信,而不需要通过全局内存这个中间步骤。Vitis HLS中提供了hls::stream<ap_axis<N>>来指定流接口,在硬件中流接口其实是由AXI4流接口(hls::stream)实现的。(ug1399中会细讲AXI4-流接口)
循环Loops:一般来说要提高循环的性能,要么是并行pipelined要么是展开unrolled。
vadd: for(int i = 0; i < len; i++) {
#pragma HLS PIPELINE
c[i] = a[i] + b[i];
}
如上指定pipeline优化,读--加--写本来是要3个时钟完成,但pipeline后也许第一次的读--加--时,第二次的读--就已经开始了,就这样节约了性能:
但当loop中有依赖性时,使用pipeline时就无法完全达到最大性能了。
使用unroll对循环进行展开也能提高性能,但展开会消耗FPGA资源,所以建议只对循环体少的循环unroll或对循环次数少的循环进行完全展开(#pragma HLS UNROLL)。还有一个折中方案就是部分展开(#pragma HLS UNROLL factor=)这样去平衡资源与速度。其中factor可以理解成分成几个并行的loop。数据如果有依赖对unroll有严重影响,就像对pipeline的影响一样(ug1399会讲如何处理这种情况)。xilinx建议:对于loop尽量上PIPELINE优化,对于小loop或短loop则再加上UNROLL优化。对于多层循环,必须如下结构(只有内层loop有循环体,两个for之间无任何代码,内层循环次数是const类型,外层循环次数是const或变量),才可pipeline优化:
ROW_LOOP: for(int i=0; i< MAX_HEIGHT; i++) {
COL_LOOP: For(int j=0; j< MAX_WIDTH; j++) {
#pragma HLS PIPELINE
// Main computation per pixel
}
}
对于顺序的多个loops,如下,其实也可以用dataflow优化使得几个loops之间overlap:
void adder(unsigned int *in, unsigned int *out, int inc, int size) {
unsigned int in_internal[MAX_SIZE];
unsigned int out_internal[MAX_SIZE];
mem_rd: for (int i = 0 ; i < size ; i++){
#pragma HLS PIPELINE
// Reading from the input vector "in" and saving to internal variable
in_internal[i] = in[i];
}
compute: for (int i=0; i<size; i++) {
#pragma HLS PIPELINE
out_internal[i] = in_internal[i] + inc;
}
mem_wr: for(int i=0; i<size; i++) {
#pragma HLS PIPELINE
out[i] = out_internal[i];
}
}
Dataflow优化是任务级别的并行,是对kernel函数中不同的任务/子函数进行优化,估计有点像以前学的tbb中的concurrent,反正是一个可以提高吞吐率降低时延的好东西。
左图是ug1393中给出的dataflow的图解,(但感觉不对吧?func_A通过计算得到i1,func_B拿到i1计算得到i2,func_C拿到i2计算得到d。按这个逻辑那左图应用dataflow的图解怎么在func_A还没完全结束时就开始了func_B?我觉得右图才是dataflow正确图解)大家可以发现dataflow优化与上面pipeline优化的图解很像,但pipeline是应用于loop中的不同操作,是让loop在上一次操作还未结束时就又开始下一次loop的第一个操作,实现每次loop的重叠。但dataflow就不同,应用场景是kernel中不同的子函数之间,对不同的任务或子函数而言,实现的是子函数的调用重叠。如果loop次数只有1,那么用pipeline没效果,同样子函数们只调用一次时,用dataflow也没效果。(从图解看是这样的吧)
void compute_kernel(ap_int<256> *inx, ap_int<256> *outx, DTYPE alpha) {
hls::stream<unsigned int>inFifo;
#pragma HLS STREAM variable=inFifo depth=32
hls::stream<unsigned int>outFifo;
#pragma HLS STREAM variable=outFifo depth=32
#pragma HLS DATAFLOW
read_data(inx, inFifo);
// Do computation with the acquired data
compute(inFifo, outFifo, alpha);
write_data(outx, outFifo);
return;
}
函数之间的数据传输是通过stream 这个类,就比如上面func_A、func_B、func_C之间的数据传输以及上面代码中read_data、compute、write_data函数间的数据传输就需要像上面一样使用stream类。但以上代码给出的是应用dataflow优化的标准形式或理想情景(即:要求dataflow优化区域变量类型要么是临时非静态标量、数组或指针,或者是静态的hls::stream变量;要求函数间数据传输必须是前向的;要求数组或stream类型变量只能有一个生成函数一个消费函数;dataflow区域外的函数参数如上面的inx、outx等只能被读/写/读后写,绝对不能写后再读;函数之间传输的数据如上面的inFifo、outFifo等只能写后读)(说实话这很严格如下图说的,现实中算法流程经常一个变量有几个消费函数)
数组设置/切分:vitis编译器会将大数组映射到FPGA PL区域的块RAM内存中,而块RAM是不允许数组数据全部并行访问的(因为块RAM端口总共都只有2个),所以这降低了性能。故我们常常用HLS array_partition通过三种方式(以下是factor=2时三种方式的图解 block/cyclic/complete如下左图所示;但是右图Figure 19图解不对吧,factor=2不应该如左图这样吗?)将大数组分成多个小数组或独立的寄存器:
当遇到多维数组时,可以用dimension控制对哪一维进行partition,dim=1表示第1维,dim=0表示对所有维度都进行相同操作。complete方式会将大数组映射到独立的寄存器中,众所周知寄存器中访问非常快,但是这会消耗PL中大量资源。所以尽量少用complete,为了在性能与资源之间折中,我们可以只将数组某个维度complete,比如在做矩阵乘法运算时A数组的一行数据dim2 complete与B数组的一列数据dim1 complete。那么cyclic与block怎么选呢,书上给了下面的例子:下面的例子中AB都是一维数组,所以始终是dim=1。用cyclic factor=m指定A数组被分成m行,用block factor=n指定B被分成n列.
int A[64 * 64];
int B[64 * 64];
#pragma HLS ARRAY_PARTITION variable=A dim=1 cyclic factor=64
#pragma HLS ARRAY_PARTITION variable=B dim=1 block factor=64
ROW_WISE: for (int i = 0; i < 64; i++) {
COL_WISE : for (int j = 0; j < 64; j++) {
#pragma HLS PIPELINE
int result = 0;
COMPUTE_LOOP: for (int k = 0; k < 64; k++) {
result += A[i * 64 + k] * B[k * 64 + j];
}
C[i* 64 + j] = result;
}
}
对数组的多次访问也会降低性能,解决办法就是不直接访问数组,而是访问数组数据的本地缓存以此来提高性能。如下面这段代码多次直接访问数组mem,这性能就很低:
#include "array_mem_bottleneck.h"
dout_t array_mem_bottleneck(din_t mem[N]) {
dout_t sum=0;
int i;
SUM_LOOP:for(i=2;i<N;++i)
sum += mem[i] + mem[i-1] + mem[i-2];
return sum;
}
当改成下面多使用本地缓存,少直接访问数组数据的方式后,可以提高性能。
#include "array_mem_perform.h"
dout_t array_mem_perform(din_t mem[N]) {
din_t tmp0, tmp1, tmp2;
dout_t sum=0;
int i;
tmp0 = mem[0];
tmp1 = mem[1];
SUM_LOOP:for (i = 2; i < N; i++) {
tmp2 = mem[i];
sum += tmp2 + tmp1 + tmp0;
tmp0 = tmp1;
tmp1 = tmp2;
}
return sum;
}
内联函数:当函数体小或调用次数不多时,尽量在函数体内首行用#pragma HLS INLINE优化这个函数为内联函数。但若函数体大或多次调用时,则别这样,因为消耗很多FPGA资源。
自启动内核:p107 先跳过,暂时不需看。
6,第10章RTL 内核:暂时跳过,先不看。
7,第11章Versal AI 引擎编程(ug1079、ug1076中会详细介绍这部分内容):AI引擎内核由C++编程,使用AI引擎API、很多内联函数,性能比CPU高很多。这需要用到Vitis开发包中的AI引擎编译器aiecompiler,这个编译器会产生AI引擎处理器上能运行的 elf 文件。多个AI引擎内核在ADF图(可调整数据流图adaptive data flow graph)中组合起来,ADF图中包含了很多节点(表示核函数中的计算内容)和端点(表示数据连接)。ADF图在Vitis平台加速流中的C++内核、全局内存以及主机端应用程序之间进行交互。
8,第12章Vitis数据中心加速实践:跳过,先不看。
9,第13章设置Vitis环境,从这章内容开始进入到本书第二大重要内容如下所示:
本书的第一大块内容教会了我们怎么写PS PL端的代码并优化,这第二大块内容就是如何编译并将应用程序跑起来。
本书的开头就教了安装Vitis三大内容:Vitis核心开发包、XRT、加速卡。安装完成后要设置环境变量如下:或者直接将Vitis IDE所在路径加到环境变量中 export PLATFORM_REPO_PATHS=<path to platforms> 注意这里指向的是.xpfm所在路径
#setup XILINX_VITIS and XILINX_VIVADO variables
source <Vitis_install_path>/settings64.sh
#setup XILINX_XRT
source /opt/xilinx/xrt/setup.sh
10,第14章编译目标(build targets翻译成目标、目的、对象好像都有点怪):主要有3个:软件模拟目标se_emu、硬件模拟目标hw_emu、默认系统硬件目标。
软件模拟目标se_emu(用来在X86上或嵌入式平台arm处理器上验证主机和内核函数功能是否齐全、正确性(可用GDB调试器设置断点调试);所以这个是没有性能评估的;这一步主要是用gcc编译C写好的内核,一个内核就是一个独立的线程,如果每个内核有多个计算单元CU,那么每个CU就是一个独立的线程,以此来模仿硬件中的并行执行模式。但这个模式有一个大缺陷就是仿真时所用全局内存不能超过16GB,还有一个缺陷就是不支持AXI4-Stream interfaces without Side-Channels。 );
硬件模拟目标hw_emu(用来评估应用初始性能、资源):每个内核会被编译成硬件模型RTL,硬件仿真期间内核会在vivado逻辑模拟器中运行,然后给出模拟硬件执行下的初始性能和资源评估;
默认系统硬件目标hw(这个是用来产生开发板上能直接加载的FPGA二进制文件 .xclbin)。se_emu、hw_emu都是在Vitis仿真环境中完成,不需真正的加速开发板。但hw需要真正的开发板加速板。
11,第15章编译主机端程序:主机端程序要么用XRT api或OpenCL API 由C/C++编写,然后由g++编译器编译。每个原文件都会编译成 .o 目标文件(g++ ... -c <source_file1> <source_file2> ... <source_fileN>),然后由XRT头文件和动态库下xrt_coreutil链接成能在主机端CPU运行的可执行文件(g++ ... -l <object_file1.o> ... <object_fileN.o>)。 加哪些XRT头文件和动态库取决于工程中需要用到哪些,这和我们VS/eclipse中用某个第三方库很像。主机端编译OpenCL api时照样加上opencl动态库即可。
12,第16章编译设备二进制文件:主要分为两步:第一步是将内核源代码编译成 .xo 文件(有两种方式:Vitis编译器或Vitis HLS IDE);第二步是将 .xo与硬件平台XSA文件编译产生成 .xclbin文件。
使用Vitis 编译器编译内核源码:按以下模板进行,以下命令是将vadd.cpp文件中的内核函数vadd编译成vadd.sw_emu.xo 。-t 是表示编译目标(sw_emu/hw_emu/hw三选一);--platform是指定要编译的加速平台;-c编译内核(只有先编译了才能链接 -l);-k 是指定要编译的内核函数;-o 是指定内核函数编译输出文件名称:
v++ -t sw_emu --platform xilinx_u200_gen3x16_xdma_2_202110_1 -c -k vadd \
-I'./src' -o'vadd.sw_emu.xo' ./src/vadd.cpp
在编译过程中,会产生如<kernel_name.compile_summary>这种文件描述了内核的耗时评估报告、资源消耗报告。
使用Vitis HLS IDE或Script编译内核:虽然这种方式创建、编译、综合、导出都很方便,但不建议用这种,因为最大缺点就是不支持软件模拟以及GDB调试!!p149页。所以我决定舍弃用HLS而是用编译器。
不管是用Vitis编译器还是HLS编译了内核,接下来就是链接内核。在链接内核阶段,所有内核生成的 .xo文件链接到平台从而产生FPGA二进制文件 .xclbin。以下将之前编译好的vadd.xo 链接成 vadd.sw_emu.xclbin(如果是Versal系列的FPGA,则不能用.xclbin,而应该制定.xsa),如果不指定这个输出名称,全部默认为a.xsa 。
v++ -t hw_emu --platform xilinx_u200_gen3x16_xdma_2_202110_1 --link vadd.xo
-o'vadd.sw_emu.xclbin' \
--config ./system.cfg
在链接过程中,也会产生<kernel_name.link_summary>这种文件描述了一些性能与资源的报告。另外链接过程中可以进行分析--profile或调试--debug,如果要在链接期间捕获数据、以及数据在内核与主机间、达到内核时或内核执行时、甚至是不同计算单元CUs上的传输,则分别加上--profile.data、--profile.stall、--profile.exec即可。具体内容ug1076中会详细介绍。
创建内核的多个实例:通常情况下一个内核就一个硬件实例,所以当主机端要多次调用同一个内核时,那么对于这个硬件实例而言只能在加速卡上被串行调用,影响了性能。我们的解决办法就是链接内核期间,将一个内核链接到多个CUs,这样内核就能并行被主机端调用(实际是多个CUs并行执行),这样提升了性能。如下所示是将某个内核vadd链接到2个不同CUs(CUs名称也可不指定):
[connectivity]
#nk=<kernel name>:<number>:<cu_name>.<cu_name>...
nk=vadd:2
v++ --config vadd_config.cfg ...
上面的流程结束我们就可以得到内核vadd的两个硬件实例vadd_1、vadd_2。对于链接完毕的.xclbin文件,我们可以通过命令xclbinutil查看其目录。
链接这个过程也可以理解为将内核的内存端口映射为硬件资源(DDR、HBM或PLRAM),默认情况下内核的所有内存接口即参数是被映射到同一个gmem即全局内存存储体中,这影响了参数读/写速度。所以我们可以在链接期间通过--connectivity.sp手动指定哪些参数去哪些gmem中,这样来提高性能,这部分内容之前有讲过:
void cnn( int *pixel, // Input pixel
int *weights, // Input Weight Matrix
int *out, // Output pixel
... // Other input or Output ports
#pragma HLS INTERFACE m_axi port=pixel offset=slave bundle=gmem
#pragma HLS INTERFACE m_axi port=weights offset=slave bundle=gmem1
#pragma HLS INTERFACE m_axi port=out offset=slave bundle=gmem
做完上面这步后,才能将参数映射到不同的资源(若不知道设备中有哪些资源可通过platforminfo查询),如:注意这一步骤必须在host代码中也要有,具体可以查Assigning DDR Bank in Host Code
[connectivity]
#sp=<compute_unit_name>.<argument>:<bank name>
sp=cnn_1.pixel:DDR[0]
sp=cnn_1.weights:DDR[1]
sp=cnn_1.out:DDR[2]
p154最后一段没明白,是说PCIE是连接主机内存与内核的桥梁,所以要设置主机端与设备上全局内存存储体的对应关系??
HBM设置与使用:因为DDR上的带宽有限,通常77GB/s;但HBM上的带宽却有460GB/s。(关于DDR/HBM区别大家可以查看DDR,GDDR,HBM的进化和区别 - kongchung - 博客园)将内核映射到HBM步骤和到DDR差不多,同样是用--connectivity.sp:
sp=<compute_unit_name>.<argument>:<HBM_PC>
如下是将内核krnl的参数in1、in2分别连接到HBM PC0 、PC1,而参数out连接到HBM PC3~4。注意每个HBM PC有256MB的空间,将内核链接到HBM这部分内容看得有点吃力,先跳过p155~157:
[connectivity]
sp=krnl.in1:HBM[0]
sp=krnl.in2:HBM[1]
sp=krnl.out:HBM[3:4]
PLRAM的设置与使用:Alveo系列的加速卡有HBM、DDR内存资源,但有的卡还有PLRAM(即UltraRAM和块RAM)资源。先跳过不看p158-p159.
指定CUs之间的流接口:不同kernels之间的数据可以直接传输而不通过全局内存,这必须在kernel代码和编译时指定。如下所示通过connectivity.stream_connect指定vadd_1内核产生流端口,然后vadd_2内核消费这个流端口:
[connectivity]
#stream_connect=<cu_name>.<output_port>:<cu_name>.<input_port>:
[<fifo_depth>]
stream_connect=vadd_1.stream_out:vadd_2.stream_in
将CUs指定到SLRs:目前xilinx推出的数据中心加速卡上有超级逻辑区(Super Logic Regions即SLRs)资源。跳过不看p160.
管理内核时钟频率(均以Hz为单位):在内核编译期间可用--hls.clock指定内核的始终频率,那么内核就在指定频率下运行或仿真。在内核编译链接完成后我们也可以将不同的内核通过不同的时钟频率与不同的平台连接起来--kernel_frequency,甚至我们还能为内核的不同实例即之前介绍过的CUs指定不同时钟频率。如下是编译期间指定某个内核以某频率运行,时钟频率范围可通过--clock.default_tolerance查看:
v++ -c -k <krnl_name> --hls.clock freqHz:<krnl_name>
在链接期间,我们也可以直接指定时钟频率或为内核的每个时钟信号指定时钟ID:如下所示:
v++ -l ... --clock.freqHz <freqHz>:kernelName.clk_name
控制平台时钟:上面讲了不同的内核可以有不同的时钟频率,一个平台上可以承载以不同时钟频率运行的多个内核(Vitis HLS上不支持,RTL支持)。平台上主要有两种时钟类型:由XRT控制的可调节时钟与由用户指定的定点时钟。先不看p162~p164.
控制Vivado 综合与输出结果:这部分内容最好先看完ug949后再来学。通常这部分内容Vitis会帮我们自动完成,但是偶尔我们想插手也可以通过以下三种方式(A:使用--vivado或--advanced选项控制Vitis工具;B:使用各种策略降低耗时;C:使用-to_step、-from_step):
A:使用--vivado在Vivado 工具编译产生报告时或者在综合、运行阶段指定一些策略如:先跳过BC不看p165~p172
--vivado.prop run.synth_1.strategy=Flow_AreaOptimized_medium
--vivado.prop run.impl_1.strategy=Performance_ExtraTimingOpt
--vivado.prop run.synth_1.report_strategy=MyCustom_Reports
--vivado.prop run.impl_1.report_strategy={Timing Closure Reports}
控制报告的产生:在硬件模拟阶段,我们可以通过v++ -R <report_level>控制报告上的内容,从而可以时报告产生速度加快。这句命令中report_level有几个选项:-R0:表示最精简的报告相应速度最快;-R1:在R0基础上增加每个内核的设计特点、整体设计优化策略、还保存了最近一次测试的设计关卡DCP;-R2:在R1的基础上增加每次测试的DCPs、每个SLR上的设计特点报告;-Restimate:Vitis HLS产生系统评估报告,这主要是在软件模拟阶段有用sw_emu。
13,接下来就是17章内容:系统打包 --package会产生.xclbin文件(话说这个文件不是在12章内核链接阶段已经产生了吗?怎么这里又用--package产生这个文件?原来两者是不同的看下面的命令就知道了)。打包给嵌入式平台,这是所有Versal平台包括AI引擎平台、嵌入式处理器平台都必须的步骤。对于Zynq UltraScale+ MPSoC、Zynq-7000嵌入式平台,按以下命令打包:这里的input.xclbin就是之前链接内核时产生的文件,然后打包产生的又是.xclbin,但两个虽然后缀一样但却是不同的对吧。
v++ --package -t [sw_emu | hw_emu | hw] --platform <platform> input.xclbin
[ -o output.xclbin ]
对于Versal平台,用下面的命令打包:这里的.xsa就是之前链接内核时产生的文件
v++ --package -t [sw_emu | hw_emu | hw] --platform <platform> input.xsa [ -
o output.xclbin ]
以ZCU104为例在打包阶段可以这样写:其中package.cfg是打包过程中的配置文件
v++ --package -t hw_emu --platform xilinx_zcu104_base_202010_1 --save-temps
\
./input.xclbin ./output.xclbin --config package.cfg
package.cfg的目录结构如下所示:
[package]
out_dir=sd_card
boot_mode=sd
image_format=ext4
rootfs=/tmp/platforms/sw/zynqmp/xilinx-zynqmp-common-v2022.1/rootfs.ext4
sd_file=/tmp/platforms/sw/zynqmp/xilinx-zynqmp-common-v2022.1/Image
sd_file=host.elf
sd_file=output.xclbin
sd_file=xrt.ini
sd_file=launch_app.sh
14,第19章执行模拟(话说第10章也讲过这几种模拟,这里是详细介绍吧):这后面的内容都先不看,暂时跳过。
后面还有8大块内容,哎太多了。
突然看到 下面的中文版,大家直接去看中文版吧在这Documentation Portal:我为什么之前傻傻去看英文的 哎浪费时间。我决定去看中文的。
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)