Vivado HLS数组与数据流优化实战:突破BRAM瓶颈提升吞吐量

📅 发布时间:2026/10/21 19:46:22
Vivado HLS数组与数据流优化实战:突破BRAM瓶颈提升吞吐量
做Vivado HLS开发遇到性能瓶颈时十次里有七八次问题都出在数组上。要么BRAM端口不够导致读写排队要么中间结果占资源太凶导致布线爆炸。这篇作为设计总结系列的第十二篇我把过去大半年在数组优化和数据流优化上踩过的坑、验证过的方法一次性写清楚重点解决两个问题数组怎么拆才能让吞吐量上去数据流怎么做才能不踩死锁的雷。适合已经在用HLS写加速核、但对优化指令只停留在“知道有这功能”层面的朋友看完可以直接照着改自己的工程。1. 数组为什么是HLS性能的头号瓶颈1.1 先从硬件的视角看数组很多从软件转过来的开发者拿到HLS第一反应是“把C代码直接变成硬件就好”。但C语言里的数组落到FPGA上通常就是BRAM。BRAM这种东西物理上就是一组存储单元加上有限的读写端口。大多数BRAM是双端口也就是说一个时钟周期里你最多做两次访问操作。如果一个循环里同时要读4个不同的数组元素硬件就只能分两个周期来做或者把数据复制到逻辑资源里去。举个例子图像处理里常见的一个3x3卷积窗口内层循环每次要读9个像素。如果这9个像素都放在同一个BRAM里而BRAM一个周期只能读两个端口那么最简单的处理方式就是把卷积的读取串行化。串行化意味着循环的Initiation IntervalII会变成5甚至更高整体吞吐量直接掉一大截。我在某个边缘检测模块里就遇到过原本以为能跑到300M时钟频率结果综合完发现II8实际有效吞吐只有理论值的八分之一。所以数组优化的本质不是“让数组跑得更快”而是“让数组的物理存储形态匹配你代码里的访问模式”。你代码里一次访问几个不同的地址硬件上就得有对应的并行端口否则性能就卡在存储上。1.2 三种基础优化手段的选择逻辑Vivado HLS里针对数组的优化指令主要有三个ARRAY_PARTITION、ARRAY_RESHAPE和ARRAY_MAP。它们的定位完全不同。ARRAY_PARTITION是把一个大数组拆成多个小数组本质上增加端口数量。比如一个存了128个int的BRAM你想一个周期同时读4个不同位置那就把数组拆成4个子数组每个子数组独立映射到独立的BRAM或寄存器组上这样端口就多了。ARRAY_RESHAPE则是把数组里相邻的元素在“字”的维度上拼接起来比如原来一个BRAM存128个32bit整数每个周期只能读一个元素现在把4个元素拼成一个128bit的字一个周期就能把4个元素全读出来。这种方式不会增加BRAM数量但增加了每次访问的位宽适合连续访问的场景。ARRAY_MAP则是把多个小数组合并成一个大数组目的是减少BRAM碎片化、降低资源占用。这在通信协议处理这种场景里用得比较多因为很多协议字段本身就是几个字节的小数组分开映射浪费BRAM。判断用哪个我的经验是看访问模式。随机访问、不同地址同时读——用PARTITION线性连续访问、一次读一串——用RESHAPE小数组满天飞、BRAM数量爆了——用MAP。三个指令的适用场景重叠不大选错了优化效果会非常差。2. 数组分割Array Partition的实操细节2.1 三种分割方式怎么选ARRAY_PARTITION有三种类型block、cyclic、complete。很多资料讲得比较模糊我用实际效果来描述。block分割是把数组按连续区间拆开。比如有一个长度为8的数组Ablock方式factor2就会变成A1存放A[0:3]A2存放A[4:7]。适合的场景是你的代码里某些循环分支只访问某个固定区间的数据比如查表操作每个分支只关心自己的那一段。cyclic分割是轮询拆分同样长度为8、factor2A1存放A[0]、A2存放A[2]、A4另一个存A[3]、A5等奇数下标。这样拆完之后连续访问整个数组时多个BRAM能同时工作流水线效率大幅提升。complete就是把数组完全拆开每个元素一个独立的存储单元。这通常只适合小数组比如系数表、查找表一般不超过几十个元素。如果对一个上千个元素的数组用complete资源会直接爆炸。选型逻辑也可以反过来推导如果你循环里用到的地址表达式是“固定步长跳跃”的用cyclic是“连续区间偏移”的用block数组本身特别小而且循环要频繁随机访问每一个元素用complete。2.2 分割因子定多少才合理分割因子factor不是拍脑袋定的它取决于内层循环的并行访问需求。一个核心原则是factor要至少大于等于内层循环单次迭代里需要并行访问的数组元素个数。以卷积为例假设内层循环一次迭代要读取9个不同坐标的像素如果图像行缓冲数组用cyclic分割factor取到4往往就够用因为9个像素里包含相邻行、相邻列的重复访存地址有重叠可以部分共用端口。但如果访问是完全独立的9个不同地址factor就得取到9。我通常的做法是先在C代码里数一下内层循环访问了几个不同的数组下标再结合循环展开UNROLL的因子去匹配。开了UNROLL factor4那就得确保数组端口至少支持4个并行访问否则展开后还是要排队。另外有一点容易忽略factor不等于“数组拆成几段”它是“每段包含几个元素”。所以整个数组被拆成的子数组数量其实是数组长度除以factor。factor越小子数组数量越多BRAM/寄存器消耗越大。资源够用的时候factor往小调不够用就调大直到II能收敛到1或者目标值。2.3 分割后资源怎么估算资源估算这事很多人会到综合完看report但我建议在写指令之前就大概算一下免得综合一次好几十分钟然后白等。一个BRAM通常是36Kb如果数组元素是32bit整数理论上大概能存900个。数组长度是1024的时候刚好一个BRAM能放下。如果做了block分割factor4就是4个256长度的子数组仍然是4个BRAM总量没变。但如果是complete分割1024个元素全部变成寄存器/分布式RAM资源占用会从1块BRAM变成上千个寄存器这个差距是巨大的。所以要是你的代码里有个大数组但内层循环又确实需要高并行度可以考虑把数组长度缩短比如从整帧缓存改成行缓冲再用factor较大的cyclic分割这样性能能上去资源也不会爆。我在一个真实项目里把一帧1080p的图像数据缓存数组改成两行缓冲之后BRAM占用从38块直接降到2块性能还因为端口变少、布局变紧凑反而提升了。这就是行缓冲加数组分割的组合拳。3. 数据流Dataflow优化的原理与落地3.1 数据流为什么能提高吞吐普通C代码里函数是严格按顺序执行的——第一个循环跑完第二个循环才开始。但在硬件里只要模块之间没有数据依赖完全可以并行跑。数据流优化就是把一系列函数或循环改造成“生产者-消费者”结构让上一级处理完一小块数据下一级马上接着处理而不是等全部算完。用食堂打饭来类比传统方式是厨师把所有菜炒完再端出来给大家打饭数据流则是炒完一锅菜就端出来厨师继续炒下一锅大家边等边打。整体出餐时间取决于最慢的那口锅而不是所有锅的时间之和。在HLS工具里数据流优化可以得到更小的总延迟和更高的吞吐量但前提是各级之间能够正确通信。如果生产者还没产出数据消费者就得等如果消费者处理得慢生产者的输出缓存满生产者也会被阻塞。这种“互相等待”的机制底层是靠FIFO或者握手信号实现的。3.2 什么场景适合用数据流数据流不是万金油它有一些硬性的适用前提。工具文档里通常会提数据流区内的各个任务之间不能有循环依赖必须是单方向的生产-消费关系。也就是说任务A不能既消费B的数据又给B供数任务之间不能存在跨层次的反馈。另一个经常被忽略的点是数据必须是“顺序流动”的。比如一个函数逐个读取图像行做处理再传给下一级适合做数据流。但如果某个任务访问的是随机位置的全局数组数据流就跑不起来了因为硬件里没法保证随机的读写顺序还能并行。适合数据流的典型场景包括视频处理的多级滤波、通信基带的调制解调链路、AI加速器里的卷积-激活-池化流水线。这些链路的共同点是数据从一个模块流向另一个模块中间不回头每级处理时间比较均衡。3.3 数据流与hls::stream配合的正确姿势在HLS里数据流通常和hls::stream配合使用。hls::stream本质上是一个硬件FIFO它只允许先进先出地读写天然适合生产-消费模式。使用的时候有几个坑。第一个是stream的深度。默认深度是2如果生产者和消费者的速率不匹配FIFO很快就空或者满性能反而下降。我一般会在工程里根据模块间数据量的波动情况手动指定深度比如深度设成4或8能显著减少阻塞。第二个坑是stream的初始化。hls::stream不能像数组那样在函数入口初始化它是靠写入数据“激活”的。如果代码里有个条件分支某些路径没有往stream里写数据但另一头在等数据那就会卡死。写完之后我建议用C仿真加interface报错信息来验证一下stream的读写闭合是否完整。第三个坑是stream在调试视图里的观察方式。综合完之后数据流图表里能看到每个任务的执行状态如果发现某个任务一直在等待多半是FIFO深度不够或者是前级输出没有满足后级的突发读取需求。我会先改FIFO深度再改任务粒度。4. 一个完整的图像流水线优化案例4.1 从纯C到第一版HLS为了讲清楚数组优化和数据流是怎么配合的我拿一个简化的边检检测链路来演示。输入是一帧320x240的灰度图先做3x3高斯滤波再做3x3的Sobel边缘检测然后输出结果。初期版本我直接按C语言的思路写把整帧输入存在一个全局数组里高斯滤波结果存在一个同样大小的temp数组然后Sobel模块读temp写输出。这样写非常简单但综合出来的资源很难看。两个320x240的unsigned char数组每个大约76.8KB映射下来要占20多个BRAM。再加上内层循环对数组的大量重复访问端口冲突导致II无法收敛最终性能差到没法看。4.2 数组优化后的第二版第二版我把注意力放在两个大数组上。第一步是数据类型从int改成unsigned char这个减少了一半以上的存储空间。第二步也是最关键的一步——把整帧temp数组改成行缓冲。高斯滤波是3x3算子处理第y行的时候只需要第y-1行、第y行、第y1行的数据。所以不需要缓存整帧只需要维护一个三行滑动窗口。Sobel同理。改完之后temp数组从320x240变成3x320资源占用立刻降到2块BRAM以内。但这还没完。改完行缓冲之后我发现Sobel模块内部读3x3窗口时行缓冲数组的访问还是存在端口瓶颈。我加了一个ARRAY_PARTITION把行缓冲按cyclic方式分成两部分factor取2这样同一周期能并行读两个相邻像素。综合后II终于从8降到了2。#pragma HLS ARRAY_PARTITION variableline_buf typecyclic factor2 dim14.3 数据流改造后的第三版第二版性能上来了但结构上还是两级串行高斯模块要把整帧跑完Sobel才开始。这在实际视频处理里是不能接受的因为等第一帧处理完第二帧已经来了系统会丢帧。第三版把两级处理拆成独立函数中间用hls::stream连接再给顶层函数加DATAFLOW指令。这里有个细节数据流不要求两个模块严格同时开始只要它们之间有深度足够的流缓冲工具就能自动让它们重叠执行。void gauss_3x3(hls::streamunsigned char in, hls::streamunsigned char out) { // 行滑动窗口逻辑 } void sobel_3x3(hls::streamunsigned char in, hls::streamunsigned char out) { // 行滑动窗口逻辑 } void pipeline(hls::streamunsigned char input, hls::streamunsigned char output) { #pragma HLS DATAFLOW hls::streamunsigned char tmp_stream; #pragma HLS STREAM variabletmp_stream depth8 gauss_3x3(input, tmp_stream); sobel_3x3(tmp_stream, output); }这里我把stream深度设成8让高斯的突发输出有缓冲余地Sobel不会因为偶尔的时序波动而空等。综合后两个模块的执行在时序图里重叠了端到端吞吐从原来的二倍串行时间逼近单模块的最大处理时间。4.4 资源与性能的最终对比三个阶段改完之后的效果如下版本BRAM占用内层循环II端到端吞吐第一版整帧缓存约42块8基准第二版行缓冲数组分割约5块2约2.4倍第三版数据流stream约7块1约4.1倍第三版BRAM比第二版多一点是因为加了几组FIFO但换来的是接近4倍的吞吐提升这个取舍非常划算。注意这里的吞吐变化是基于仿真和综合报告做的相对评估不同工具版本、不同FPGA型号会有差异但数量级和趋势是明确的。5. 常见问题排查与经验技巧5.1 几个高频报错的应对数据流优化时报错或者得不到预期的性能提升是群里和论坛里问得最多的问题。我挑三个高频的展开讲讲。第一个“unable to schedule dataflow region”。这个报错基本是数据流里的某个任务访问了非stream的全局数组或者数组访问存在依赖。解决办法是把共享数组改成hls::stream或者把数组访问改造成逐元素顺序读写。如果改造代价大可以考虑用ARRAY_PARTITION把数组访问也并行化让工具能判断依赖关系。第二个是“deadlock detected in dataflow region”。这个几乎都是因为某个stream没有数据可读但消费者一直在等。常见原因是生产者内部有分支条件某些条件下不会写stream。我建议在写生产者的最后强制在退出分支上补一个“空写”或者调整逻辑结构确保出口闭合。第三个是综合出来了但性能始终上不去看报告发现II还是很大。这种情况多半是数组端口没有真正扩出来。我都会先把数组在综合后的schedule视图里勾出来看一遍确认访问是否已经并行。如果发现数组还是单端口那就检查ARRAY_PARTITION的维度是不是填错了——注意dim参数要写数组的哪个维度。5.2 查看报告时的几个关键指标HLS综合报告页里信息很密但新手容易盯错地方。我的习惯是优先看三个东西第一个是Loop II和Trip Count。II是循环启动间隔表示每多少个周期启动一次新迭代。II越小流水线越好。如果II是5但循环体看似很简短那说明里面有访问冲突或者资源依赖。第二个是数据流视图里的任务重叠情况。综合完之后打开数据流图能看到每个任务的起始时间和结束时间。如果任务A结束之后任务B才启动说明数据流没有生效回去查stream深度和依赖。第三个是BRAMURAM的端口利用率。报告会告诉你存储单元的端口被用掉了多少如果有一项是100%那就是你数组并行访问设计的瓶颈。5.3 最后分享几个实战经验第一个经验优化之前一定要先做C仿真。很多数据流的读写顺序问题、分支遗漏问题在纯C函数里就能发现。不要偷懒等综合完了才发现死锁来回一次可能就是两三个小时。第二个经验能用寄存器数组就用寄存器数组不要滥用BRAM。对于比较小的数组几十个元素之内如果不加任何限制工具默认可能放BRAM导致访问延迟更大。显式加上ARRAY_PARTITION complete强制用寄存器实现数据通路延迟更短频率往往更高。第三个经验数组分割和循环展开要一起考虑。单独加大循环UNROLL因子但数组端口没跟上结果就是资源浪费单独加大数组分割但循环没有展开的并行度结果端口用不上。两者是配套的改之前先画一张“循环每次迭代同时访问哪些元素”的表格对着表格定因子思路一下就清楚了。第四个经验流深度不是越大越好。FIFO深度太大会增加资源消耗和延迟深度太小又容易阻塞。实践下来如果生产者和消费者的吞吐比较均衡深度取4到8就够了。如果你的数据粒度本身很大比如每次传一整行那深度取1或2也没问题关键是让工具的握手不成为瓶颈。我自己调优化的流程基本是先跑综合看报告定位瓶颈是循环还是存储再用数组分割解决存储并行度然后看是否需要改行缓冲降低资源最后才考虑用数据流把多个模块串成流水线。每改一步都跑一次C仿真和综合对比不要一次改太多。这条路看起来慢实际是最省时间的。写到这里这一篇的核心内容就差不多了。数组优化和数据流优化单独拿一个出来都有很多文章但实际做项目的时候它们永远是一起出现的。希望这些实操细节能帮你少走点弯路如果后面在具体项目里遇到不一样的现象很欢迎一起交流。下一篇总结里我打算把AXI接口和DMA传输的优化细节再梳理一遍那种和系统集成强相关的问题坑更多。