虽然 FPGA 可使用 Verilog 或 VHDL 等低层次硬件描述语言 (HDL) 来编程,但现在已有多种高层次综合 (HLS) 工具可以采用以 C/C++ 之类的更高层次的语言编写的算法描述,并将其转换为 Verilog 或 VHDL 等低层次的硬件描述语言。随后,下游工具即可对转换后的语言进行处理,以便对 FPGA 器件进行编程。此类流程的主要优势在于,您可使用诸如 C/C++ 等编程语言来编写高效代码,而后将代码转换为硬件,但这类编程语言的优势仍能得以完整保留。此外,写好代码乃是软件设计师的专长,比学习新的硬件描述语言更简单。
以 C/C++ 编写的程序本质上是专为冯诺依曼样式的架构编写的,此类架构中用户程序内的每条指令都是按顺序执行的。为了实现高性能,HLS 工具必须推断顺序代码中的并行性,并利用它来实现更高的性能。要解决这个问题可并不简单。此外,优秀的软件程序员按明确定义的规则和实践来编写程序,例如,RTTI、递归和动态存储器分配。其中诸多技巧在硬件中都无法找到直接等效的对象,故而给 HLS 工具带来了诸多挑战。这也意味着任意现成软件都无法高效转换为硬件。最低限度,需检验此类软件中是否存在不可综合的构造,并需要重构代码,使其可综合。
现如今,即使软件程序可自动转换(或综合)为硬件,但要实现可接受的结果质量 (QoR),仍需要额外工作(例如,重写软件)以帮助 HLS 工具实现期望的性能目标。为此,您需要了解正确编写软件的最佳实践,以确保在 FPGA 器件上正常执行软件。在接下来的几个章节内,将着重探讨如何首先识别部分宏观级别架构最优化以明确程序结构,然后聚焦更细化的微观级别架构最优化来实现性能目标。
生产者使用者范例
请考虑软件设计师编写多线程程序的方式,通常有一个主线程用于执行某些初始化步骤,随后分叉为多个子线程用于执行某些并行计算,当所有并行计算都完成后,主线程会整理结果并写入输出。程序员必须理清哪些部分可以分叉以供并行计算,哪些部分需要按顺序执行。这种分叉/连接类型的并行化操作不仅适用于 CPU,也适用于 FPGA,但 FPGA 上的吞吐量的关键模式之一是生产者使用者范例。您需要将生产者使用者范例应用于顺序程序,并将其转换为可并行执行的抽取功能以便提升性能。
您可借助一条简单的问题语句的帮助来更好地理解这个分解进程。假定您有一份数据手册,可供我们将其中的项导入列表。随后,您将对列表中的每个项进行处理。处理完每个项需耗时约 2 秒。处理完后,您将把结果写入另一份数据手册,此操作将耗时约每项各 1 秒。因此,如果输入 Excel 工作表中有总计 100 个项,那么将耗时总计 300 秒来生成输出。这样做的目的是对此问题进行分解,以便您识别能够并行执行的任务,从而提升系统吞吐量。
图 1. 程序工作流程
第一步是了解程序工作流程,识别独立的任务或函数。整个工作流程分 4 个步骤,类似上图所示的程序工作流程(无重叠)。在此示例中,“写入输出”(步骤 3)任务独立于“处理数据”(步骤 2)处理任务。虽然步骤 3 取决于步骤 2 的输出,但当步骤 2 中的任意项完成处理后,您即可立即将其写入输出文件。您无需等待所有数据都完成处理后再开始将数据写入输出文件。此类型的交织/重叠任务执行方式是非常常见的原则。如上图所示(例如:含重叠的程序工作流程)。如图所示,含重叠的工作流程比不含重叠时更快完成。现在,您可将步骤 2 视作为生产者,将步骤 3 视作为使用者。生产者使用者模式对于 CPU 性能的影响有限。您可交织执行每个线程的步骤,但这需要谨慎分析,以便充分利用底层多线程和 L1 高速缓存架构,因此较为耗时。但在 FPGA 上,由于可采用定制架构,生产者和使用者线程可以同时执行,开销极低甚至没有,因此能够显著提升吞吐量。
首先考量的最简单案例是单一生产者和单一使用者通过大小有限的缓冲器来进行通信。如果缓冲器已满,那么生产者可以选择阻塞/停滞或者丢弃数据。当使用者从缓冲器中移除某个项后,它会通知生产者,生产者随后开始再次填充缓冲器。如果使用者发现缓冲器已空,则可以同样方式停滞。当生产者将数据置入缓冲器后,它会唤醒休眠中的使用者。该解决方案可通过进程间通信(通常使用监控器或信号量)来实现。不充分的解决方案可能导致死锁,即两个进程都停滞并等待唤醒。但在单一生产者和使用者的情况下,通信模式与先入先出 (FIFO) 或乒乓缓冲器 (PIPO) 实现之间存在强映射关系。这种类型的通道无需依赖信号量、互斥体或监控器来进行数据传输,即可提供高效的数据通信。使用此类锁定原语对于性能可能开销较大,并且难以使用和调试。PIPO 和 FIFO 是常用的选择,因为可以避免端到端的原子同步需求。
在此类宏观级别架构最优化中,可通过缓冲器封装通信,使程序员可免于担心存储器模型和其它非确定性行为(如争用条件等)。在此类设计中可达成的网络类型为纯粹的“数据流网络”,可在输入侧接受串流数据,对此数据串流执行一些基本处理,然后将其作为数据串流发出。并行程序的复杂性完全被抽离。请注意,“导入数据”(步骤 1)和“导出数据”(步骤 4)同样在最大程度提升可用的并行性方面扮演着相应的角色。为了使计算能够与 I/O 成功重叠,重要的是第一步对输入的读取结果进行封装,最后一步则是写入输出。这样即可实现 I/O 与计算的最大程度重叠。在计算步骤中间读取或写入输入/输出端口将会限制设计的可用并发性。这同样是在对设计工作流程进行设计时需要牢记的。
最后,此类“数据流网络”的性能依赖于设计师能够持续向网络馈送数据,使数据能够在系统中保持串流。数据流中出现中断可能导致性能下降。视频串流应用就是一个很好的例子,比如在线游戏中,实时高清 (HD) 视频持续流经系统,帧处理率受到持续监控,以确保满足期望的结果质量。游戏玩家可以在屏幕上立即观察到帧处理率下降。假想一下,这能为一大群游戏玩家提供持续性帧率支持,同时功耗相比传统 CPU 或 GPU 架构显著降低 - 这就是硬件加速的魅力。使数据在生产者与使用者之间持续保持流动至关重要。下一步,您将深入了解本节中介绍的这种串流范例。
串流数据范例
串流是一种重要的抽象:它表示无限制的连续更新数据集,其中“无限制”表示“大小未知或者大小无限”。串流可以是一连串数据(标量或缓冲器)在源(生产者)进程与目标(使用者)进程之间单向流动。串流范例会强制您根据数据访问模式(或序列)来思考。在软件中,随机存储器对数据的访问几乎是免费的(忽略高速缓存成本),但在硬件中,执行顺序访问实际上是很有利的,此类访问可转换为串流。将算法分解为生产者使用者关系并通过网络串流数据来进行通信具有如下所述几大优势。它允许程序员以顺序方式定义算法,并通过其它方式来提取并行度(例如,通过编译器)。诸如任务间同步等复杂性会被抽离。它允许生产者和使用者任务同时处理数据,这是提升吞吐量的关键。另一个优势是代码更清洁且更简单。
如前文所述,对于生产者和使用者范例,数据传输模式与 FIFO 或 PIPO 缓冲器实现之间存在强映射关系。FIFO 缓冲器只是预定义大小/深度的队列,其中插入队列的首个元素也会成为可从队列跳出的首个元素。使用 FIFO 缓冲器的主要优势在于,只要生产者将数据插入缓冲器,使用者进程就可以立即在 FIFO 缓冲器内部开始访问数据。使用 FIFO 缓冲器的唯一问题在于,由于生产者与使用者之间的生产/使用速率不同,可能导致 FIFO 缓冲器大小错误,从而导致死锁。在具有多个生产者和使用者的设计中,这种情况较为常见。乒乓缓冲器属于用于加速进程的双缓冲器,可将 I/O 操作与数据处理操作重叠。其中一个缓冲器用于保存数据块,以便使用者进程能够看到完整(旧)版本的数据,而另一个缓冲器中,生产者进程则正在创建新(部分)版本的数据。当新的数据块完成并有效时,使用者和生产者进程将交换对两个缓冲器的访问。由此导致,使用乒乓缓冲器会增加器件的整体吞吐量,并帮助防止出现最终瓶颈。PIPO 的主要优势在于,工具能够将生产速率与使用速率自动匹配,并创建高性能且无死锁的通信通道。此处值得注意的是,无论使用的是 FIFO 还是 PIPO,关键特性是相同的:生产者将数据块发送或者串流至使用者。数据块可以是单个值,也可以是一组 N 个值。块越大,所需存储器资源越多。
以下是简单的求和应用,用于展示经典的串流网络/数据流网络。在此例中,该应用的目标是成对添加随机数值串流,然后打印这些数值。前两个任务(任务 1 和 2)提供了随机数值串流以供添加。这些数值通过 FIFO 通道发送到求和任务(任务 3),任务 3 会从 FIFO 通道读取值。随后,求和任务将输出发送到打印任务(任务 4),以发布结果。FIFO 通道可在这些独立的执行线程之间提供异步缓冲。
图 2. 串流网络/数据流网络
连接每项“任务”的串流通常是作为 FIFO 队列来实现的。FIFO 能够抽离程序员的并行行为,使其专注于推理任务活动(调度)的“快照”时间。FIFO 能够使并行化更易于实现。这主要得益于它减少了程序员实现并行化框架或容错解决方案时,必须应付的可变空间。独立内核之间的 FIFO 展现出经典的排队行为。对于纯串流系统,可使用排队或网络流模型来对此行为进行建模。这种数据流类型网络和串流最优化的另一个主要优势在于它可按不同粒度级别来应用。程序员可以在每项任务内部设计此类网络,也可以为任务或内核系统设计此类网络。实际上,您可以通过串流网络来以分层方式例化并连接多个串流网络或任务。支持更细粒度的并行化的另一项最优化措施是流水打拍。
流水线范例
流水线是您日常生活中常用的概念。造车厂生产线就是一个典型的例子,其中每一项具体任务通常都是由一个独立且唯一的工作站来完成的,如安装引擎、安装车门和安装车轮。各工作站各自对一辆不同的车并行执行自己的任务。当某一辆车执行完某一项任务后,它就会移至下一个工作站。完成各项任务的时间差可通过“缓冲”(将一辆或多辆车暂存在各工作站之间的空间内)和/或“停滞”(暂时中止上游工作站的操作)来加以调整,直至下一个工作站变为可用为止。
假设组装一辆车需要执行 3 项任务 A、B 和 C,这 3 项任务分别需要 20、10 和 30 分钟。那么,如果全部 3 项任务均由单个工作站来执行,工厂每 60 分钟才能输出一辆车。通过使用 3 个工作站组成的流水线,该工厂 60 分钟即可输出第一辆车,随后每 30 分钟再输出一辆新车。正如此示例所示,流水线并不会降低时延,即单个项穿越整个系统的总时间。但它会增加系统吞吐量,即,第一个项完成后处理新的项的速率。
由于流水线的吞吐量不可能优于其最慢的元素,因此程序员应尝试在各阶段间拆分工作和资源,以使各阶段耗用相同时间来完成自己的任务。在上述车辆组装线示例中,如果 3 项任务 A、B 和 C 各自耗时 20 分钟,而不是分别耗时 20、10 和 30 分钟,那么时延将仍为 60 分钟,但每隔 20 分钟(而不是 30 分钟)即可完成一辆新车。下图显示了承担制造 3 辆车任务的假想生产线示例。假定任务 A、B 和 C 各耗时 20 分钟,那么顺序生产线将需要 180 分钟才能生产 3 辆车。而流水线式生产线只需 100 分钟即可生产 3 辆车。
生产第一辆车所耗费的时间为 60 分钟,称为流水线的迭代时延。生产完第一辆车后,后两辆车各自只需 20 分钟,这称为流水线的启动时间间隔 (II)。生产三辆车所耗费的总时间为 100 分钟,称为流水线的总时延,即,总时延 = 迭代时延 + II * (项数 - 1)。因此,改善 II 即可改善总时延,但不影响迭代时延。从程序员视角来看,流水线范例可适用于设计中的函数和循环。确定初始建立时间成本后,要实现理想吞吐量目标,II 应为 1,即初始建立时间延迟过后,在流水线的每个周期都将有输出可用。在以上示例中,初始建立时间延迟 60 分钟过后,每隔 20 分钟就有一辆车可用。
图 3. 流水打拍
流水打拍是经典的微观级别架构最优化,可应用于多个抽象层。在前文中,我们通过生产者使用者范例解释了任务级别流水打拍。这一概念同样适用于指令级别。这实际上是使生产者使用者流水线(和串流)保持填满并繁忙的关键。仅当每个任务都高速率生产/使用数据,故而需要指令级流水打拍 (ILP) 时,生产者使用者流水线才能保持高效。
由于流水打拍长期使用相同资源来执行相同功能,因此需要有关每项任务时延的完整信息,故而被视作为静态最优化。有鉴于此,低级指令流水打拍方法无法应用于数据流类型的网络,因为在此类网络中任务的时延是输入数据的函数,故而可能未知。下一章节中将详解如何利用介绍的这三种基本范例来对不同类型的任务并行度进行建模。
组合三种范例
用户程序中,大部分最优化的主要焦点是函数和循环。现如今的最优化工具通常在函数/过程级别工作。每个函数都能转换为特定硬件组件。每个此类硬件组件都与类定义相似,该组件的许多对象(或实例)均可在最终硬件设计中创建和例化。每个硬件组件都将由许多更小的预定义组件组成,这些预定义组件通常用于实现基本函数(例如,加法、减法和乘法)。虽然不支持递归,但是函数可以调用其它函数。较小且调用次数较少的函数通常还可以内联到其调用方函数中,正如软件函数内联方式一样。在此情况下,实现函数所需的资源将汇总到调用方函数的组件中,这样能更好地共享公用资源。将设计构造为一组通信函数有助于在执行这些函数时提升推断并行度。
循环是程序中最重要的构造之一。由于循环主体多次迭代,因此可轻松利用该属性来提升并行度。可通过多种方式来对循环和循环嵌套进行变换(例如,流水打拍和展开),从而提升并行执行的效率。这些变换使存储器系统最优化以及映射到多核和 SIMD 执行资源成为可能。科学与工程应用中的许多程序都表现为对大型数据结构进行各种运算。这些运算包括对阵列或矩阵进行简单的逐元素运算,或者也可能是具有循环进位依赖关系的更复杂的循环嵌套运算,如跨循环迭代的数据依赖关系。此类数据依赖关系会影响循环内可达成的并行度。在诸多此类情况下,必须对代码进行重构,才能在现代化的并行平台上有效执行并行循环迭代。
下图显示了 4 个连续任务(即,C/C++ 函数)A、B、C 和 D 的不同重叠执行的简单示例,其中,A 在 2 个不同阵列内分别为 B 和 C 生成数据,D 则使用来自 B 和 C 所生成的 2 个不同阵列的数据。假定这种“菱形”通信模式将运行 2 次,且 2 次运行彼此独立。
void diamond(data_t vecIn[N], data_t vecOut[N]) { data_t c1[N], c2[N], c3[N], c4[N]; #pragma HLS dataflow A(vecIn, c1, c2); B(c1, c3); C(c2, c4); D(c3, c4, vecOut); }
以上代码示例所示 C/C++ 源代码片段显示了这些函数的调用方式。请注意,任务 B 和 C 相互之间不存在数据依赖关系。下图对应完全顺序执行方式,其中黑色圆圈表示用于实现串行的某种同步形式。
图 4. 顺序执行 - 运行 2 轮
在菱形示例中,B 与 C 完全彼此独立。两者既不相互通信也不访问任何共享存储器资源,因此如果无需共享计算资源,那么两者可并行执行。由此可得结果如下图所示,一轮运行内形成了某种形式的分叉式连接并行运行。当 A 任务结束后,B 和 C 并行执行,而 D 则等待 B 和 C,但下一轮仍按顺序连续执行。
图 5. 一轮运行中的任务并行
此类执行方式可总结为 (A; (B || C); D); (A; (B || C); D),其中“;”表示串行连续,“||”则表示完全并行。这种形式的嵌套分叉连接并行运行对应于一种从属任务子类,称为串并行任务图。一般来说,任何从属任务的有向无环图 (DAG) 均可通过独立的分叉并连接类型同步来实现。此外同样值得注意的是,这正是在具有多个线程并使用共享存储器的 CPU 上运行多线程程序的方式。
在 FPGA 上,您可以探索其它可用的并行形式。先前的执行模式利用的是在单次调用内执行任务级别并行操作。那么重叠连续运行又会如何呢?如果每次运行之间真正彼此独立,但每个函数(即,A、B、C 或 D)复用前一轮的相同计算硬件,那么我们仍可能想要将 A 的第二次调用与 B 和 C 的第一次调用并行执行。这是一种跨调用的任务级流水线形式,由此可得结果如下图所示。现在,由于吞吐量受到所有任务间的最大时延的限制,而非所有任务时延总和的限制,因而吞吐量明显改善。虽然每轮运行的时延不变,但多轮运行的总时延得以缩短。
图 6. 利用流水打拍实现任务并行
但现在,当 B 首次运行从存储器中执行读取时,A 则已得到其首轮运行的结果,A 的第二轮操作可能已在相同存储器内执行写入。为避免在使用数据之前写入数据,您可以依靠某种形式的存储器扩展(即所谓的双重缓冲或 PIPO)来达成此交织操作。这种交织操作以任务间的黑色圆圈来表示。
有一种有效的方法可用于提升吞吐量和复用计算资源,即对运算符、循环和/或函数进行流水打拍。如果每个任务现在都能与自身重叠,您即可在一轮运行内实现任务并行操作,同时跨多轮运行实现任务流水打拍,这两者都属于宏观级别并行度的例证。多任务内流水打拍则是微观级别并行度的例证。现在,由于每一轮运行依赖于任务间的最小吞吐量而不是任务的最大吞吐量,因此每一轮的总体吞吐量得以进一步提升。最后,根据通信数据的同步方式,仅当生成全部数据 (PIPO) 或者以逐元素方式 (FIFO) 生成全部数据后,才有可能在一轮运行内出现某种程度的额外重叠。例如,在下图中,B 和 C 都比 A 更早启动并以流水打拍方式执行,而 D 则假定仍必须等待 B 和 C 完成。这是最后一种类型的单轮运行内重叠,当 A 通过 FIFO 串流访问(表现为不含圆圈的直线)来与 B 和 C 通信时,才能实现这种方式的重叠。同样,D 也能与 B 和 C 重叠,前提是采用的通道为 FIFO 而不是 PIPO。但不同于前几种执行模式,使用 FIFO 可能导致死锁,因此需要正确设置这些串流 FIFO 的大小。
图 7. 单轮运行内的任务并行和流水打拍、多轮运行的流水打拍以及单一任务内的流水打拍
总之,本节中所演示的三种范例为您展示了如何在避免多线程和/或并行编程语言的复杂操作的同时下,仍能在设计中实现并行操作。生产者使用者范例搭配串流通道即可轻松组合小型系统与大型系统在内的各种系统。如上文所述,串流接口支持轻松耦合并行任务,亦或是分层数据流网络也不在话下。这其中部分原因是由于编程语言 (C/C++) 能够灵活支持此类规范,并且还有各种工具可用于在当今 FPGA 器件上可用的异构计算平台上实现这些规范。
结论 - 性能良方
本文档中所演示的设计概念具有一个主要的核心原则,即并行计算模型倾向于通过状态封装和模块化单元或任务内的顺序执行来为并行编程提供更简单的编程模型。随后,任务将与串流相连(以执行同步和通信)。每个串流均可包含不同类型的通道,例如,FIFO 或 PIPO。您不妨思考下为什么这种范例未能推而广之。现如今的变化在于,每个人手中所使用的并行硬件数量发生了巨大变化。即使手机都是多核的,并拥有异构加速器(GPU 等)。利用大部分现有编程工具对这些器件进行编程都无疑是一场梦魇。将 OpenCL 、C、Java 和/或 C++ 的位相结合来打造一致的系统极为耗时。而基于串流的处理方式则可为此提供一种解决方案。状态/逻辑区隔化使工具(例如编译器和调度器)能够更加轻松地理清何时何地需要运行应用的哪些部分。基于串流的处理方式日渐流行的另一个原因是,它打破了传统多线程“分叉/连接”模型基于并行执行的观点。通过启用任务级流水打拍和指令级流水打拍,运行时间可执行的并发操作数量远远超过了分叉/连接模型现今所能执行的操作数量。这种附加的并行性对于充分利用现今 FPGA 器件上可用的硬件而言至关重要。与启用流水线并行性如出一辙的是,串流同样支持设计师构建并行应用,而无需担心锁定、争用条件等可能导致并行编程无从着手的问题。
最后,建议您依据以下高层次操作检查表为标准,在可重配置 FPGA 平台上实现所期望的性能。
专为 CPU 编写的软件与专为 FPGA 编写的软件有着本质上的不同。要想编写可在 CPU 平台与 FPGA 平台之间进行移植的代码,性能牺牲是不可避免的。因此,FPGA 软件与 CPU 软件的编写方式差异巨大,与其抗拒这个事实,不如坦然接受。
从工程伊始,就应该建立起能够对源代码更改进行功能性验证的流程。基于参考模型或者使用黄金矢量来测试软件是极为常用的实践。
首先专注于设计的宏架构。并考虑使用生产者使用者范例来对解决方案进行建模。
明确设计的宏架构后,即可绘制出期望的活动时间线,其中以横轴表示时间,并显示您期望在多次迭代(或调用)过程中执行每一项功能相对于其它功能的时间。这样您将能够对设计中期望的并行性了然于胸,后续即可用于与最终达成的结果进行比较。HLS GUI 通常可用于直观显示所达成的并行度。
仅当您已掌握宏架构并确立活动时间线之后,再开始进行程序编码或重构。
一般,HLS 编译器仅根据函数调用来推断任务级别并行度。因此,需要在硬件中并发运行的顺序代码块(例如循环)应置于专用函数内。
将原始算法分解/分区为较小的组件,这些组件可通过串流来彼此进行通信。这样您就能在一定程度上掌握数据在设计中流动的方式。
较小的模块化组件的优势在于,可以按需进行赋值,从而提升并行性。
请避免通信通道的位宽过宽。将此类宽通道分解为较窄些的通道,这样有助于在 FPGA 器件上实现。
大型函数(手写或通过内联较小的函数生成)可能包含重要路径,工具可能难以处理此类路径。具有更简单的控制路径的小型函数有助于 FPGA 器件上的实现。
目标是在每个函数内包含单个循环嵌套(可采用 HLS 工具可推断的固定循环边界,或者手动向 HLS 工具提供循环次数信息)。这样可以显著促进吞吐量的测量和最优化。此方法虽然可能不适用于所有设计,但对于大部分案例都很有效。
吞吐量 - 纵观全局,掌握设计每个阶段期间所需的处理速率,这一点至关重要。这将影响您为 FPGA 编写应用的方式。
思考设计中的关键路径(即,关键任务级别路径,如,ABD 或 ACD),调查此关键路径中哪个部分可能成为瓶颈。通过设计仿真,观察哪几个任务采用流水打拍,以及任一路径的不同分支是否在吞吐量方面存在不一致。随后,可使用 HLS GUI 工具和/或仿真波形查看器来直观显示此类吞吐量问题。
基于串流的通信允许使用者在生产者开始生产时立即开始处理,由此即可支持重叠执行(从而提升并行性和吞吐量)。
为了使生产者和使用者任务保持持续不间断运行,请使用流水打拍和调整串流的相应大小等方法来对每项任务的执行进行最优化,使其能尽快运行。
请思考串流通道的同步粒度(和开销)。您可使用 PIPO 通道来重叠任务执行,而无需担心死锁,显式手动串流 FIFO 通道支持您(比 PIPO)更快开始重叠执行,但请谨慎处理 FIFO 大小调整,以避免死锁。
了解有关可综合的 C/C++ 编码样式的更多信息.
使用 HLS 编译器生成的报告作为指导来完成最优化进程。
请将上述检查表放在附近,以便随时参考。它总结了构建能满足您的性能目标的设计所需的整个设计活动。
设计的另一个需要考量的重要方面在于您的加速函数或内核。内核连接外部的接口是最终系统设计的重要要素。您的内核可能需要插入更大的设计或者与更大的内核系统中的其它内核进行通信,或者与系统外部的存储器或器件进行通信。设计高效内核 提供了另一份检查表,以供您在设计加速内核的外部接口时考量其中所列出的各项内容。
审核编辑:汤梓红
评论
查看更多