GPU虚拟化和算力隔离探讨

1. 术语介绍

术语

全称

说明

GPU

Graphics Processing Unit

显卡

CUDA

Compute Unified Device Architecture

英伟达2006年推出的计算API

VT/VT-x/VT-d

Intel Virtualization Technology

-x表示x86 CPU,-d表示Device

SVM

AMD Secure Virtual Machine

AMD的等价于Intel VT-x的技术

EPT

Extened Page Table

Intel的CPU虚拟化中的页表虚拟化硬件支持

NPT

Nested Page Table

AMD的等价于Intel EPT的技术

SR-IOV

Single Root I/O Virtualization

PCI-SIG 2007年推出的PCIe虚拟化技术

PF

Physical Function

物理卡

VF

Virtual Function

SR-IOV的虚拟PCIe设备

MMIO

Memory Mapped I/O

设备上的寄存器或存储,CPU以内存读写指令来访问

UMD

User Mode Driver

GPU的用户态驱动程序

KMD

Kernel Mode Driver

GPU的PCIe驱动

GVA

Guest Virtual Address

VM中的CPU虚拟地址

GPA

Guest Physical Address

VM看到的物理地址

HPA

Host Physical Address

Host看到的物理地址

IOVA

I/O Virtual Address

设备发出去的DMA地址

PCIe TLP

PCIe Transaction Layer Packet

BDF

Bus/Device/Function

一个PCIe/PCI功能的ID

MPT

Mediated Pass-Through

受控直通,一种设备虚拟化的实现方式

MDEV

Mediated Device

Linux中的MPT实现

PRM

Programming Rerference Manual

硬件的编程手册

MIG

Multi-Instance GPU

Ampere架构的高端GPU如A100、A30支持的一种硬件partition方案

2. GPU虚拟化的历史和谱系

GPU天然适合向量计算。最常用的情景及API:

场景

API

游戏渲染

OpenGL/OpenGL ES,DirectX,Vulkan,Metal

媒体编解码

VAAPI,VDPAU

深度学习计算

CUDA,OpenCL

此外还有AES加密、哈希等场景,例如近些年来的挖矿。渲染是GPU诞生之初的应用: GPU的"G"就是Graphics —— 图形。

桌面、服务器级别的GPU,长期以来仅有三家厂商:

  1. 英伟达:GPU王者。主要研发力量在美国和印度。

  2. AMD/ATI:ATI被AMD收购。渲染稍逊英伟达,计算的差距更大。

  3. Intel: 长期作为集成显卡存在,2020开始推出独立显卡。


2006这一年,GPU工业界发生了三件大事: ATI被AMD收购;nVidia黄仁勋提出了CUDA计算;Intel宣布要做独立显卡。

如同经常发生的,这些事有成功有失败: Intel很快就放弃了它的独立显卡,直到2018才终于明白过来、自己放弃的到底是什么,开始决心生产独立显卡;AMD整合ATI不太成功,整个公司差点被拖死,危急时股票跌到1.8美元;而当时不被看好的CUDA,则在几年后取得了不可思议的成功。

从2012年开始,人工智能领域的深度学习方法开始崛起,此时CUDA受到青睐,并很快统治了这个领域。

3. 系统虚拟化和OS虚拟化

系统虚拟化演化之路,起初是和GPU的演化完全正交的:

  • 1998年,VMWare公司成立,采用Binary Translation方式,实现了系统虚拟化。

  • 2001年,剑桥大学Xen Source,提出了PV虚拟化(Para-Virtualization),亦即Guest-Host的主动协作来实现虚拟化。

  • 2005年,Intel提出了VT,最初实现是安腾CPU上的VT-i (VT for Itanium),很快就有了x86上的VT-x。

  • 2007年,Intel提出了VT-d (VT for Device),亦即x86上的IOMMU。

  • 2008年,Intel提出了EPT,支持了内存虚拟化。

  • 2010年,Linux中的PV Hypervisor lguest的作者,Rusty Russell(他更著名的作品是iptables/netfilter),提出了VirtIO,一种Guest-Host的PV设备虚拟化方案。


应该可以说,在PV时代和Binary Translation时代,虚拟化是很危险的。只有当VT在硬件层面解决了CPU的隔离、保证了安全性之后,公有云才成为可能。VT-x于2005~2006年出现,亚马逊AWS于2006年就提出云计算,这是非常有远见的。

系统的三个要素: CPU,内存,设备。CPU虚拟化由VT-x/SVM解决,内存虚拟化由EPT/NPT解决,这些都是非常确定的。但设备虚拟化呢?它的情况要复杂的多,不管是VirtIO,还是VT-d,都不能彻底解决设备虚拟化的问题,这些我们稍后还会谈到。


除了这种支撑完整OS的系统虚拟化,还有一种也往往被称作「虚拟化」的方式: 从OS级别,把一系列的libirary和process捆绑在一个环境中,但所有的环境共享同一个OS Kernel。

严格来说,这种容器技术,和以KVM为代表的系统虚拟化,有着本质的区别。随着容器的流行,「虚拟化」这个术语,也被用来指称这种OS级别的容器技术。因此我们也从众,把它也算作虚拟化的一种 —— 只不过为了区分,称之为OS虚拟化。

这种OS虚拟化最初于2005年,由Sun公司在Solaris 10上实现,名为「Solaris Zone」。Linux在2007~2008开始跟进,接下来有了LXC容器等;到了2013年,Docker横空出世,彻底改变了软件分发的生态,成为事实上的标准。

4. GPU虚拟化的谱系

4.1. 作为PCIe设备的GPU

不考虑嵌入式平台的话,那么,GPU首先是一个PCIe设备。GPU的虚拟化,还是要首先从PCIe设备虚拟化角度来考虑。

那么一个PCIe设备,有什么资源?有什么能力?

2种资源:

  • 配置空间

  • MMIO

  • (有的还有PIO和Option ROM,此略)

2种能力:

  • 中断能力

  • DMA能力


一个典型的GPU设备的工作流程是:

  • 应用层调用GPU支持的某个API,如OpenGL或CUDA

  • OpenGL或CUDA库,通过UMD (User Mode Driver),提交workload到KMD (Kernel Mode Driver)

  • KMD写CSR MMIO,把它提交给GPU硬件

  • GPU硬件开始工作... 完成后,DMA到内存,发出中断给CPU

  • CPU找到中断处理程序 —— KMD此前向OS Kernel注册过的 —— 调用它

  • 中断处理程序找到是哪个workload被执行完毕了,...最终驱动唤醒相关的应用

4.2. PCIe直通

我们首先来到GPU虚拟化的最保守的实现: PCIe设备直通

如前述,一个PCIe设备拥有2种资源、2种能力。你把这2种资源都(直接或间接地)交给VM、针对这2种能力都把设备和VM接通,那么,VM就能完整使用这个PCIe设备,就像在物理机上一样。这种方案,我们称之为PCIe Pass-Through(PCIe直通)。它只能1:1,不支持1:N。其实并不能算真正的虚拟化,也没有超卖的可能性。

VM中,使用的是原生的GPU驱动。它向VM内核分配内存,把GPA填入到GPU的CSR,GPU用它作为IOVA来发起DMA访问,VT-d保证把GPA翻译为正确的HPA,从而DMA到达正确的物理内存。

PCIe协议,在事务层(Transaction Layer),有多种TLP,DMA即是其中的一类: MRd/MWr。在这种TLP中,必须携带发起者的Routing ID,而在IOMMU中,就根据这样的Routing ID,可以使用不同的IOMMU页表进行翻译。


很显然,PCIe直通只能支持1:1的场景,无法满足1:N的需求。

4.3. SR-IOV

那么,业界对1:N的PCIe虚拟化是如何实现的呢?我们首先就会想到SR-IOV。SR-IOV是PCI-SIG在2007年推出的方案,目的就是PCIe设备的虚拟化。SR-IOV的本质是什么?考虑我们说过的2种资源和2种能力,来看看一个VF有什么:

  • 配置空间是虚拟的(特权资源)

  • MMIO是物理的

  • 中断和DMA,因为VF有自己的PCIe协议层的标识(Routing ID,就是BDF),从而拥有独立的地址空间。

那么,什么设备适合实现SR-IOV?其实无非是要满足两点:

  • 硬件资源要能partition好

  • 无状态(至少要接近无状态)

常见PCIe设备中,最适合SR-IOV的就是网卡了: 一或多对TX/RX queue + 一或多个中断,结合上一个Routing ID,就可以封装为一个VF。而且它是近乎无状态的。

试考虑NVMe设备,它的资源也很容易partition,但是它有存储数据,因此在实现SR-IOV方面,就会有更多的顾虑。


回到GPU虚拟化: 为什么2007年就出现SR-IOV规范、直到2015业界才出现第一个「表面上的」SR-IOV-capable GPU【1】?这是因为,虽然GPU基本也是无状态的,但是它的硬件复杂度极高,远远超出NIC、NVMe这些,导致硬件资源的partition很难实现。


注释
【1】 AMD S7150 GPU。腾讯云GA2机型使用。 表面上它支持SR-IOV,但事实上硬件只是做了VF在PCIe层的抽象。Host上还需要一个Virtualization-Aware的pGPU驱动,负责VF的调度。

4.4. API转发

因此,在业界长期缺乏SR-IOV-capable GPU、又有强烈的1:N需求的情形下,就有更high-level的方案出现了。我们首先回到GPU应用的场景:

  1. 渲染(OpenGL、DirectX,etc.)

  2. 计算(CUDA,OpenCL)

  3. 媒体编解码(VAAPI...)

业界就从这些API入手,在软件层面实现了「GPU虚拟化」。以AWS Elastic GPU为例:

  • VM中看不到真的或假的GPU,但可以调用OpenGL API进行渲染

  • 在OpenGL API层,软件捕捉到该调用,转发给Host

  • Host请求GPU进行渲染

  • Host把渲染的结果,转发给VM


API层的GPU虚拟化是目前业界应用最广泛的GPU虚拟化方案。它的好处是:

  • 灵活。1:N的N,想定为多少,软件可自行决定;哪个VM的优先级高,哪个VM的优先级低,同理。

  • 不依赖于GPU硬件厂商。微软、VMWare、Citrix、华为……都可以实现。这些API总归是公开的。

  • 不限于系统虚拟化环境。容器也好,普通的物理机也好,都可以API转发到远端。

缺点呢?

  • 复杂度极高。同一功能有多套API(如渲染的DirectX和OpenGL),同一套API还有不同版本(如DirectX 9和DirectX 11),兼容性就复杂的要命。以业界著名某厂商为例,其VDI团队就200多人的规模。

  • 功能不完整。计算渲染媒体都支持的API转发方案,还没听说过。并且,编解码甚至还不存在业界公用的API!【1】


注释
【1】 Vulkan的编解码支持,spec刚刚添加,有望被所有GPU厂商支持。见下「未来展望」部分。

4.5. MPT/MDEV/vGPU

鉴于这些困难,业界就出现了SR-IOV、API转发之外的第三种方案。我们称之为MPT(Mediated Pass-Through,受控的直通)。 MPT本质上是一种通用的PCIe设备虚拟化方案,甚至也可以用于PCIe之外的设备。它的基本思路是:

  • 敏感资源如配置空间,是虚拟的

  • 关键资源如MMIO(CSR部分),是虚拟的,以便trap-and-emulate

  • 性能关键资源如MMIO(GPU显存、NVMe的CMB等),硬件partition后直接赋给VM

  • Host上必须存在一个Virtualization-Aware的驱动程序,以负责模拟和调度,它实际上是vGPU的device-model

这样,VM中就能看到一个「看似」完整的GPU PCIe设备,它可以attach原生的GPU驱动。以渲染为例,vGPU的工作流程是:

  • VM中的GPU驱动,准备好一块内存,保存的是渲染workload

  • VM中的GPU驱动,把这块内存的物理地址(GPA),写入到MMIO CSR中

  • Host/Hypervisor/驱动: 捕捉到这次的MMIO CSR写操作,拿到了GPA

  • Host/Hypervisor/驱动: 把GPA转换成HPA,并pin住相应的内存页

  • Host/Hypervisor/驱动: 把HPA(而不是GPA),写入到pGPU的真实的MMIO CSR中

  • pGPU工作,完成这个渲染workload,并发送中断给驱动

  • 驱动找到该中断对应哪个workload —— 当初我是为哪个vGPU提交的这个workload? —— 并注入一个虚拟的中断到相应的VM中

  • VM中的GPU驱动,收到中断,知道该workload已经完成,结果已在内存中


这就是nVidia GRID vGPU、Intel GVT(KVMGT、XenGT)的基本实现思路。一般认为graphics stack是OS中最复杂的,加上虚拟化之后复杂度更是暴增,随便什么地方出现BUG,调试起来都是无比痛苦。但只要稳定下来,这种MPT方案,就能兼顾1:N灵活性、渲染计算媒体的完整性、高性能...是不是很完美?

其实也不是。

该方案最大的缺陷,是必须有一个pGPU驱动,负责vGPU的模拟和调度工作。逻辑上它相当于一个实现在内核态的device-model。而且,由于GPU硬件通常并不公开其PRM,所以事实上就只有GPU厂商才有能力提供这样的Virtualization-Aware pGPU驱动。以nVidia GRID vGPU方案为例,GPU硬件卖给我们时nVidia收了一次钱,GRID vGPU方案在腾讯云上部署后,客户使用vGPU时,nVidia还要再收一次钱!

4.6. SR-IOV: revisited

我们重新回到GPU的SR-IOV。AMD从S7150开始、英伟达从Turing架构开始,数据中心GPU都支持了SR-IOV。但是again,它不是NIC那样的SR-IOV,它需要Host上存在一个vGPU的device-model,来模拟从VM来的VF访问。

所以事实上,到目前为止,GPU的SR-IOV仅仅是封装了PCIe TLP层的VF路由标识、从而规避了runtime时的软件DMA翻译,除此之外,和基于MDEV的MPT方案并无本质的不同。

4.7. 谱系表

在介绍完了上述的这些方案后,我们重新看下CUDA计算、OpenGL渲染两种场景的软件栈,看看能发现什么:

CUDA计算stack:

OpenGL渲染Stack:

可以看出,从API library开始,直到GPU硬件,Stack中的每一个阶段,都有被截获、转发的可能性。甚至,一概称之为「API转发」是不合适的 —— 以GRID vGPU、GVT-g为例的DEV转发,事实上就是MPT,和任何API都没有关系。

5. 容器GPU虚拟化

首先,我们这里谈到的,都是nVidia生产的GPU、都只考虑CUDA计算场景。其次,这里的虚拟化是OS虚拟化的容器技术,不适用于KATA这样的基于系统虚拟化的安全容器。

5.1. CUDA的生态

CUDA开发者使用的,通常是CUDA Runtime API,它是high-level的;而CUDA Driver API则是low-level的,它对程序和GPU硬件有更精细的控制。Runtime API是对Driver API的封装。

CUDA Driver即是UMD,它直接和KMD打交道。两者都属于NVIDIA Driver package,它们之间的ABI,是NVIDIA Driver package内部的,不对外公开。

英伟达软件生态封闭:

  • 无论是nvidia.ko,还是libcuda.so,还是libcudart,都是被剥离了符号表的

  • 大多数函数名是加密替换了的

  • 函数调用不直接进行,而是大量使用了函数指针,进行变换和跳转

  • 代码中随处检查栈深度,防止插入函数调用


以nvidia.ko为例,为了兼容不同版本的Linux内核API,它提供了相当丰富的兼容层,于是也就开源了部分代码:

这个26M大小的、被剥离了符号表的nv-kernel.o_binary,就是GPU驱动的核心代码,所有的GPU硬件细节都藏在其中。

5.2. vCUDA和阿里云cGPU

vCUDA架构图:

cGPU架构图:

不同厂商的隔离行对比

5.3. GPU池化和算力隔离

GPU池化的谱系:

以CUDA API level的转发池化方案、趋动科技公司的Orion为例,它到了GPU所在的后端机器上,由于一个GPU卡可能运行多个GPU任务,这些任务之间,依然需要有算力隔离。Orion为了实现这一点,在后端默认启用了nVidia MPS —— 也就是故障隔离最差的方案。这会导致什么?一个VM里的CUDA程序越界访问了显存,一堆风马牛不相及的VM里的CUDA应用就会被杀死。

所以很显然,GPU池化也必须以同时满足故障隔离和算力隔离的方案作为基础。

5.4. 算力隔离的本质

从对阿里云cGPU的介绍上、从Orion池化方案的后端缺陷上,都可以看出:算力隔离是GPU虚拟化、GPU池化的关键。如果没有算力隔离,不管虚拟化损失有多低,都会导致其方案价值变低(例如: cGPU);而Orion,宁肯牺牲实例间的故障隔离,也要用MPS实现算力隔离 —— 这是一种自毁口碑的行为,但至少,对尚未意识到MPS致命缺陷的小白客户来说,还能够看到算力隔离的存在。

英伟达GPU提供了丰富的硬件特性,支持硬件partition,支持Time Sharing。

1. Hardware Partition,亦即: 空分

Ampere架构的A100、A30 GPU所支持的MIG,即是一种Hardware Partition。其资源隔离、故障隔离都是硬件实现的 —— 这是无可争议的隔离性最好的方案。它的问题是不灵活: 只有高端GPU支持;只支持CUDA计算;A100只支持7个MIG实例。

2. nVidia MPS

除了MIG,算力隔离表现最优秀的,是MPS —— 它通过将多个进程的CUDA Context,合并到一个CUDA Context中,省去了Context Switch的开销,也在Context内部实现了算力隔离。如前所述,MPS的致命缺陷,是把许多进程的CUDA Context合并成一个,从而导致了故障传播。所以尽管它的算力隔离效果极好,但长期以来工业界使用不多,多租户场景尤其如此。

3. Time Sharing,亦即: 时分

基于Engine的Context Switch。不管是哪一代的GPU,其Engine都是支持多任务调度的。一个OS中同时运行多个CUDA任务,这些任务就是在以Time Sharing的方式共享GPU。

鉴于MIG的高成本和不灵活、MPS故障隔离方面的致命缺陷,事实上就只剩下一种可能:Time Sharing。唯一的问题是,如何在原厂不支持的情况下,利用Time Sharing支持好算力隔离、以保证QoS。这也是学术界、工业界面临的最大难题。

5.4.1. GPU microarchitecture和chip

真正决定GPU硬件以何种方式工作的,是chip型号。不管是GRID Driver还是Tesla Driver,要指挥GPU硬件工作,就要首先判断GPU属于哪种chip,从而决定用什么样的软硬件接口来驱动它。

5.4.2. PFIFO: GPU Scheduling Internals

PFIFO架构:

概念解释:

PFIFO: GPU的调度硬件,整体上叫PFIFO。

Engine: 执行某种类型任务的GPU硬件单元。常见engine有:

  • PGRAPH CUDA/Graphics

  • PCOPY Copy Engine

  • PVENC Video Encoding

  • PVDEC Video Decoding

  • ...

最重要的就是PGRAPH Engine,它是CUDA和渲染的硬件执行单元。

Channel: GPU暴露给软件的,对Engine的抽象。一个app可以对应一或多个channels,执行时由GPU硬件把一个一个的channel,放在一个一个的engine上执行。channel是软件层的让GPU去执行的最小调度单位。

TSG: Timeslice Group。

由一组(一或多个)channel(s)组成。一个TSG共享一个context,且作为一个调度单位被GPU执行。

runlist: GPU调度的最大单位。调度时,GPU通常是从当前runlist的头部摘取TSG或channel
来运行。因此,切换runlist也意味着切换active TSG/channel。

PBDMA: pushbuffer DMA
GPU上的硬件,用于从Memory中获取pushbuffer。

Host

GPU上和SYSMEM打交道的部分(通过PCIe系统)。PBDMA是Host的一部分。
注意,Host是Engine和SYSMEM之间的唯一桥梁。

Instance Block

每个Channel对应一个Instance Block,它包含各个Engine的状态,用于Context Switch时的Save/Restore;包含GMMU pagetable;包含RAMFC —— 其中包括UMD控制的USERD。

5.4.3 runlist/TSG/channel的关系

1. Tesla驱动为每个GPU,维护一或多个runlist,runlist或位于VIDMEM,或位于SYSMEM

2. runlist中有很多的entry,每个entry是一个TSG或一个channel

  • 一个TSG是multi-channel或single-channel的

  • 一个channel必定隶属于某个TSG

3. 硬件执行TSG或channel,当遇到以下情景之一时,进行Context Switch:

  • 执行完毕

  • timeslice到了 —— Tesla Driver默认2ms

  • 发生了preemption

5.4.4 pending channel notification

pending channel notification是USERD中的机制。UMD可以利用它通知硬件: 某个channel有了新的任务了【1】。这样,GPU硬件在当前channel被切换后(执行完毕、或timeslice到了),就会执行相应的channel。


注释
【1】 不同chip,实现有所不同。

5.4.5 从硬件调度看GRID vGPU

GRID vGPU支持3种scheduler:

1. Best Effort: 所有vGPU的任务随意提交,GPU尽力执行。

【现象】 如果启动了N个vGPU,它们的负载足够高,那么结果就是均分算力。
【原理】所有的vGPU使用同一个runlist。runlist内,还是按照channel为粒度进行调度。如同在native机器上运行多个CUDA任务一样。


2. Equal Share: 所有vGPU严格拥有同样的GPU配额

【现象】如果启动了N个vGPU,它们严格拥有相同的算力,不管是否需要这么多。
【原理】为每个vGPU维护一个runlist。当它的timeslice过了,GRID Host Driver会写GPU寄存器,触发当前runlist被抢占、下一个runlist被调度。


3. Fixed Share: 每个vGPU有自己固定的GPU配额

【现象】每个vGPU严格按照创建时的规格来分配算力。
【原理】Ditto.

5.5 腾讯云qGPU简介

qGPU == QoS GPU。它是业界唯一真正实现了故障隔离、显存隔离、算力隔离、且不入侵生态的容器GPU共享的技术。

5.5.1 qGPU基本架构

qGPU基本架构和友商cGPU类似:

5.5.2 qGPU QoS效果

注释

  1. 测试数据来自T4(chip: TU104)。

  1. 两个PoD的配比为2:1,横坐标为batch值,纵坐标为实际运行时两个PoD的算力比例。可以看到,batch较小时,负载较小,无法反映算力比例; 随着batch的增大,MPS和qGPU都趋近理论值2,vCUDA也偏离不远,但缺乏QoS的cGPU则逐渐趋近1 —— 两个PoD相等。

6. GPU虚拟化: 未来展望

2021以来,GPU工业界发生了一些变化,e.g.:

英伟达QoS被攻破

英伟达在CUDA计算领域占据压倒性的优势,因此它对提升GPU利用率并无兴趣: MPS只是玩票性质的;MIG昂贵而又不灵活;即使是GRID vGPU,也在灵活性上远远没有做到它本应做到的。

长期以来,学术界和工业界付出了大量的努力,尝试在英伟达不支持QoS的前提下,实现某种程度的算力隔离。遗憾的是,这些努力,要么集中在CUDA API层,能够做到一定的算力隔离,但同样会带来副作用;要么尝试在low-level层面突破 —— 但不幸全都失败了。

qGPU是十几年来在英伟达GPU上实现QoS的最大突破。基于它:

  • 腾讯云TKE的多容器共享GPU,将无悬念地领先整个工业界

  • 在线推理 + 离线训练的混布,成为可能

  • GPU池化的后端实现,不管采用哪种方案,都有了坚实的基础

  • Linux/Android场景的渲染,目前需求小众,但技术基础已经有了

Vulkan Spec支持了video encode/decode

很可能,编解码API不统一的乱象即将终结,这对API转发方案有很大的意义。不远的将来,或许某种API方案的vGPU会成为主流。Google在社区的一些活动表明,很可能它就有这样的计划。

相关推荐

  1. GpuMall的GPU资源池技术有何优势?

    2024-05-04 21:08:04       14 阅读
  2. mysql隔离级别串行

    2024-05-04 21:08:04       39 阅读

最近更新

  1. TCP协议是安全的吗?

    2024-05-04 21:08:04       17 阅读
  2. 阿里云服务器执行yum,一直下载docker-ce-stable失败

    2024-05-04 21:08:04       16 阅读
  3. 【Python教程】压缩PDF文件大小

    2024-05-04 21:08:04       15 阅读
  4. 通过文章id递归查询所有评论(xml)

    2024-05-04 21:08:04       18 阅读

热门阅读

  1. 流量卡是骗人的吗?

    2024-05-04 21:08:04       9 阅读
  2. STL——map/unordered_map

    2024-05-04 21:08:04       10 阅读
  3. 车载开发-Android Automotive平台

    2024-05-04 21:08:04       10 阅读
  4. git解决冲突问题

    2024-05-04 21:08:04       9 阅读
  5. 修改ETCD返回数据限额

    2024-05-04 21:08:04       7 阅读
  6. 2024/5/3 C++五一

    2024-05-04 21:08:04       11 阅读
  7. PPT基础

    PPT基础

    2024-05-04 21:08:04      11 阅读