news 2026/7/15 15:10:28

CANN/asc-devkit原子操作关键特性

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CANN/asc-devkit原子操作关键特性

关键特性说明

确定性计算是指在相同输入条件下,无论执行次数或执行环境如何变化,总能产生完全一致输出结果的计算过程。确定性计算为系统稳定性和实验可验证性提供保障。

确定性计算概述

为引出原子操作场景下非确定性计算的问题,我们构建如下常见的确定性计算场景:首先,通过单组浮点数数据搬运完成GM的初始化;随后,启动原子累加操作;最后,经由多次数据搬运,在GM上对多组浮点数数据进行累加。具体伪代码如下:

1. 向GM搬运数据data0; // 数据搬运,覆盖GM原有随机值,期望GM数据为data0。 2. SetAtomicAdd(); // 开启原子累加,后续从UB/L0C Buffer/L1 Buffer到GM的搬运均执行原子累加。 3. 向GM搬运data1; // 带随路原子操作的数据搬运,期望GM数据为data0 + data1。 4. 向GM搬运data2; // 带随路原子操作的数据搬运,期望GM数据为data0 + data1 + data2。 5. 向GM搬运data3; // 带随路原子操作的数据搬运,期望GM数据为data0 + data1 + data2 + data3。

如下图1所示,开发者的预期结果:指令发射的顺序能够严格对应实际指令执行顺序,多次执行该段代码,无论执行多少次,最终GM数据均为data0 + data1 + data2 + data3,结果完全一致,实现确定性计算。

图 1确定性计算场景,GM上数据变化过程
![](https://raw.gitcode.com/cann/asc-devkit/raw/42d23e82c367bacb6ae30e63c95e2264458a29d6/docs/api/figures/deterministic_computation_gm_data_change_process.png "Deterministic computation scenario, GM data change process"?utm_source=gitcode_repo_files)

但实际情况是,若开发者不做干预,程序每次运行时这些指令的执行顺序都可能发生变化,最终导致GM数据与预期结果不一致。下面列举两种可能的指令执行顺序及其对应的执行流程。

非确定性计算,结果1

图 2非确定性计算场景1,GM上数据变化过程
![](https://raw.gitcode.com/cann/asc-devkit/raw/42d23e82c367bacb6ae30e63c95e2264458a29d6/docs/api/figures/non_deterministic_computation_1_gm_data_change_process.png "Non-deterministic computation scenario 1, GM data change process"?utm_source=gitcode_repo_files)

如图2所示,该场景中指令执行流程如下:

  1. 初始状态,GM数据为:随机值;
  2. 向GM搬运data0,GM数据被初始化为:data0;
  3. 执行SetAtomicAdd,为后续搬运指令开启原子累加,GM数据为:data0;
  4. 三次带随路原子操作的搬运指令乱序,实际执行顺序为"搬出data2→搬出data3→搬出data1",最终GM上数据为:data0 + data2 + data3 + data1。

非确定性计算的产生原因1:

带随路原子操作的搬运指令乱序,由于浮点数加法不满足结合律,即(a+b)+c!=a+(b+c),使得最终GM数据data0 + data2 + data3 + data1与预期的data0 + data1 + data2 + data3存在偏差。

带随路原子操作的搬运指令乱序,导致最终结果产生偏差的前提条件有以下三条:

  • 原子操作类型为原子累加(最大值、最小值运算满足结合律)
  • 原子操作数据类型为浮点数(整数加法满足结合律)
  • 带随路原子操作的搬运指令达到3条及以上(浮点数加法满足交换律,但是不满足结合律)。

非确定性计算,结果2

图 3非确定性计算场景2,GM上数据变化过程
![](https://raw.gitcode.com/cann/asc-devkit/raw/42d23e82c367bacb6ae30e63c95e2264458a29d6/docs/api/figures/non_deterministic_computation_2_gm_data_change_process.png "Non-deterministic computation scenario 2, GM data change process"?utm_source=gitcode_repo_files)

如图3所示,该场景中指令执行流程如下:

  1. 初始状态,GM数据为:随机值;
  2. 执行SetAtomicAdd,为后续搬运指令开启原子累加,GM数据为:随机值;
  3. 先后执行两次带随路原子操作的搬运指令,执行顺序为“搬出data1→搬出data2”,GM数据为:随机值 + data1 + data2;
  4. 向GM搬运data0,GM上累加的结果被data0覆盖,GM数据为:data0;
  5. 最后执行data3的搬运,最终GM上数据为:data0 + data3。

非确定性计算的产生原因2:

开启原子累加前的普通搬运指令与开启原子操作的搬运指令之间发生乱序,会导致GM上已完成原子操作的数据被data0错误覆盖,进而产生非确定性计算结果。

此类乱序导致结果偏差无需满足任何前提条件,开发者无需再区分原子操作类型、原子操作数据类型,也无需考虑带随路原子操作的搬运指令数量是否达到3条及以上。

确定性计算实现方案

根据导致非确定性计算的两个根因,下面也从解决这两个方面描述确定性计算实现的方案。核心思想是在指令之间插入适当的同步,使每次程序运行时相关指令都按照预期确定的顺序执行,最终保证每次执行程序输出的结果都相同。具体来说包含以下两个方面:

  • 开启原子累加前的搬运指令与开启原子操作的指令之间插入同步

    如下伪代码所示,在指令1与2之间插入同步,能够确保开始原子操作前GM的初始值符合预期。

  • 开启原子累加操作后,多条搬运指令之间的同步

    指令3与4、4与5之间插入同步,能够确保浮点数累加的顺序符合预期。

    [!CAUTION]注意 开启原子操作的指令与后续搬运指令之间不需要开发者插入同步。

// 整个原子累加在同一个核内执行,控制5个指令的执行顺序为"1→2→3→4→5"。 1. 向GM搬运数据data0; // 数据搬运,覆盖GM原有随机值,期望GM数据为data0。 核内同步 2. SetAtomicAdd(); // 开启原子累加,后续从UB/L0C Buffer/L1 Buffer到GM的搬运均执行原子累加。 // 指令2与3间无需插入同步。 3. 向GM搬运data1; // 开启原子累加后的数据搬运,期望GM数据为data0 + data1。 核内同步 4. 向GM搬运data2; // 开启原子累加后的数据搬运,期望GM数据为data0 + data1 + data2。 核内同步 5. 向GM搬运data3; // 开启原子累加后的数据搬运,期望GM数据为data0 + data1 + data2 + data3。

如下伪代码所示,当上述指令都在不同核中执行时,需要将上述的核内同步替换为核间同步。

// 整个原子累加在4个不同核中执行,控制4个核执行的顺序为“核0→核1→核2→核3”。 if (GetBlockIdx == 0) { 向GM搬运数据data0; 核间同步 } else if (GetBlockIdx == 1) { 核间同步 SetAtomicAdd(); 向GM搬运data1; 核间同步 } else if (GetBlockIdx == 2) { 核间同步 SetAtomicAdd(); 向GM搬运data2; 核间同步 } else if (GetBlockIdx == 3) { 核间同步 SetAtomicAdd(); 向GM搬运data3; }

下面介绍如何基于硬件同步指令实现核内同步,以及如何基于软件同步方案实现核间同步。

核内同步

搬运指令和开启原子操作的指令流水类型如下表所示,当上述指令在同一个核内执行时,开发者按需插入单流水同步或者多流水同步,可参考样例中scenarioNum=1的分支。

表 1原子操作确定性计算相关指令的流水类型

指令名称流水类型
DataCopyPIPE_MTE3
FixpipePIPE_FIX
SetAtomicAdd/SetAtomicMax/SetAtomicMinPIPE_S

核间同步

由于当前未提供用于控制不同核之间执行顺序的硬件同步接口,因此确定性计算场景下的核间同步需通过软件仿真实现。针对纯Vector算子、纯Cube算子及Mix(同时包含Vector与Cube计算)算子三种场景,所采用的软件同步方案存在差异,具体如下表所示。

表 2核间同步软件实现方案

算子类型软件同步方案说明
纯Vector算子方案1:通过多对IBSet和IBWait的接口组合,可实现多个AIV间的同步,可参考样例中scenarioNum=2的分支。方案1支持指定部分AIV参与同步,并可控制各AIV的执行顺序。
纯Vector算子方案2:通过InitDetermineComputeWorkspace、NotifyNextBlock和WaitPreBlock三个接口配合使用,确保所有AIV核按照blockIdx升序执行。方案2要求所有AIV必须参与同步,且执行顺序固定为blockIdx升序。
纯Cube算子通过GM中的信号量实现核间同步,先建立一对AIC核之间的同步,进而扩展至多个AIC核之间的同步,可参考样例中scenarioNum=3的分支。通过Scalar单元访问GM时,需考虑多核间数据一致性问题。
Mix算子通过GM中的信号量实现核间同步,先建立一对核之间的同步,进而扩展至多个核之间的同步。通过Scalar单元访问GM时,需考虑多核间数据一致性问题。此处的“核”可以是AIV或者AIC。

下图展示了两个核之间如何通过GM中的信号量进行核间同步:

  • 上一个核完成数据搬运或开启原子操作后,会通过Scalar单元向核间共享的GM中的信号量写入值1,表示自己的任务已完成。上一个核中也需要插入核内同步:

    • 当上一个核内有多条搬运指令时,它们之间需要插入核内同步1。
    • Scalar单元向GM写数据之前必须保证前序所有搬运指令都已经执行完成,因此它们之间也需要插入核内同步2。
  • 当前核在执行搬运任务前,会通过Scalar单元不断读取该信号量的值。如果信号量不等于1,当前核会进入阻塞等待状态;当检测到信号量等于1时,当前核会解除阻塞,开始执行自己的数据搬运或原子操作。为确保信号量等于1之前,当前核不会执行搬运指令,需要在搬运指令之前插入核内同步3。

图 4一对核之间软件同步方案流程图
![](https://raw.gitcode.com/cann/asc-devkit/raw/42d23e82c367bacb6ae30e63c95e2264458a29d6/docs/api/figures/software_synchronization_between_two_cores_flowchart.png "Software synchronization flowchart between two cores"?utm_source=gitcode_repo_files)

Scalar单元访问GM上的信号量,存在两种访问方式:

  • 经过DCache访问

    使用GlobalTensor的成员函数GetValue和SetValue进行操作。这种情况下,开发者需要手动调用DataCacheCleanAndInvalid接口,以确保多核间数据的一致性。

  • 不通过DCache访问

    使用WriteGmByPassDCache和ReadGmByPassDCache。这种方式无需额外操作即可保证多核间数据的一致性。

两种方案在性能上的差异:不经过DCache,性能会较差,但是如果读写GM数据较少,可以考虑使用不经过DCache的方法。

如图4所示,核间同步方案中也需要与核内同步配合使用,现将三处核内同步作用说明如下:

  • 核内同步1(可选):核内存在多条数据搬运指令时,通过该同步保证各搬运操作严格按顺序执行。
  • 核内同步2(必备):等待前一个核全部任务执行完毕后,才允许Scalar单元向全局内存信号量写入1。
  • 核内同步3(必备):等待Scalar单元检测到信号量更新为1后,当前核再启动后续任务执行。

调用示例

  • 核内同步确定性计算(对应scenarioNum=1):所有指令在同一个AIV内执行

    // 在原子累加之前,以src0Local初始化GM。 AscendC::DataCopy(dstGlobal, src0Local, DATA_LENGTH); // 确保在原子累加之前,gm的初始化完成。 AscendC::PipeBarrier<PIPE_MTE3>(); // 开启原子累加。 // 预期累加执行的顺序是:src2Local + src3Local + src1Local。 AscendC::SetAtomicAdd<float>(); AscendC::DataCopy(dstGlobal, src2Local, DATA_LENGTH); // 两条搬运指令之间需要调用PipeBarrier<PIPE_MTE3>(),确保src2Local和src3Local的搬运顺序正确。 AscendC::PipeBarrier<PIPE_MTE3>(); AscendC::DataCopy(dstGlobal, src3Local, DATA_LENGTH); AscendC::PipeBarrier<PIPE_MTE3>(); AscendC::DataCopy(dstGlobal, src1Local, DATA_LENGTH); AscendC::DisableDmaAtomic(); // GM结果:data0 + data1 + data2 + data3。
  • 纯Vector算子核间同步(对应scenarioNum=2):四次搬运分别在四个不同的AIV内执行

    // 预期的4个核执行的顺序是核0-->核2-->核3-->核1。 // 核0负责在原子累加之前,以src0Local初始化GM;核2、3、1负责按顺序做原子累加。 if (blockIdx == 0) { AscendC::LocalTensor<float> src0Local = inQueueSrc0.AllocTensor<float>(); AscendC::DataCopy(src0Local, src0Global, DATA_LENGTH); AscendC::SetFlag<AscendC::HardEvent::MTE2_MTE3>(0); AscendC::WaitFlag<AscendC::HardEvent::MTE2_MTE3>(0); // 在原子累加之前,以src0Local初始化GM。 AscendC::DataCopy(dstGlobal, src0Local, DATA_LENGTH); // 核0通知核2,核0的src0Local初始化已完成。 auto sync_buf = vecIn.AllocTensor<int32_t>(); AscendC::IBSet(src4Global, sync_buf, 0, 0); // 最后两个参数:int32_t blockIdx, int32_t eventID。 inQueueSrc0.FreeTensor(src0Local); vecIn.FreeTensor(sync_buf); AscendC::printf(”核的下标%d\n“, blockIdx); } else if (blockIdx == 1) { AscendC::LocalTensor<float> src1Local = inQueueSrc1.AllocTensor<float>(); // 核1等待核3,直到核3数据已完成累加。 auto sync_buf = vecIn.AllocTensor<int32_t>(); AscendC::IBWait(src4Global, sync_buf, 3, 0); // 最后两个参数:int32_t blockIdx, int32_t eventID。 AscendC::DataCopy(src1Local, src1Global, DATA_LENGTH); AscendC::SetFlag<AscendC::HardEvent::MTE2_MTE3>(0); AscendC::WaitFlag<AscendC::HardEvent::MTE2_MTE3>(0); AscendC::DisableDmaAtomic(); AscendC::SetAtomicAdd<float>(); AscendC::DataCopy(dstGlobal, src1Local, DATA_LENGTH); AscendC::DisableDmaAtomic(); vecIn.FreeTensor(sync_buf); inQueueSrc1.FreeTensor(src1Local); AscendC::printf(”核的下标%d\n“, blockIdx); } else if (blockIdx == 2) { AscendC::LocalTensor<float> src2Local = inQueueSrc2.AllocTensor<float>(); // 核2等待核0,直到核0数据已完成累加。 auto sync_buf = vecIn.AllocTensor<int32_t>(); AscendC::IBWait(src4Global, sync_buf, 0, 0); // 最后两个参数:int32_t blockIdx, int32_t eventID。 AscendC::DataCopy(src2Local, src2Global, DATA_LENGTH); AscendC::SetFlag<AscendC::HardEvent::MTE2_MTE3>(0); AscendC::WaitFlag<AscendC::HardEvent::MTE2_MTE3>(0); AscendC::DisableDmaAtomic(); AscendC::SetAtomicAdd<float>(); AscendC::DataCopy(dstGlobal, src2Local, DATA_LENGTH); AscendC::DisableDmaAtomic(); // 核2通知核3,核2数据已经累加完成。 AscendC::IBSet(src4Global, sync_buf, 2, 0); vecIn.FreeTensor(sync_buf); inQueueSrc2.FreeTensor(src2Local); AscendC::printf(”核的下标%d\n“, blockIdx); } else if (blockIdx == 3) { // 核3等待核2,直到核2数据已完成累加。 auto sync_buf = vecIn.AllocTensor<int32_t>(); AscendC::IBWait(src4Global, sync_buf, 2, 0); // 最后两个参数:int32_t blockIdx, int32_t eventID。 AscendC::LocalTensor<float> src3Local = inQueueSrc3.AllocTensor<float>(); AscendC::DataCopy(src3Local, src3Global, DATA_LENGTH); // AscendC::DumpTensor(src3Local,0,8); AscendC::SetFlag<AscendC::HardEvent::MTE2_MTE3>(0); AscendC::WaitFlag<AscendC::HardEvent::MTE2_MTE3>(0); AscendC::DisableDmaAtomic(); AscendC::SetAtomicAdd<float>(); AscendC::DataCopy(dstGlobal, src3Local, DATA_LENGTH); AscendC::DisableDmaAtomic(); // 核3通知核1,核3数据已经累加完成。 AscendC::IBSet(src4Global, sync_buf, 3, 0); vecIn.FreeTensor(sync_buf); inQueueSrc3.FreeTensor(src3Local); AscendC::printf(”核的下标%d\n“, blockIdx); } // GM结果:data0 + data1 + data2 + data3。
  • 纯Cube核间同步(对应scenarioNum=3):四次搬运分别在四个不同的AIC内执行

    // 预期的4个核执行的顺序是核0-->核2-->核3-->核1。 // 核0负责在原子累加之前,以src0Local初始化GM;核2、3、1负责按顺序做原子累加。 if (blockIdx == 0) { AscendC::DataCopy(src0Local, src0Global, DATA_LENGTH); AscendC::SetFlag<AscendC::HardEvent::MTE2_MTE3>(0); AscendC::WaitFlag<AscendC::HardEvent::MTE2_MTE3>(0); // 在原子累加之前,以src0Local初始化GM。 AscendC::DataCopy(dstGlobal, src0Local, DATA_LENGTH); // 核0通知核2,核0的src0Local初始化已完成。 // 在通知下一个核之前,等待当前核的任务完成。 AscendC::SetFlag<AscendC::HardEvent::MTE3_S>(0); AscendC::WaitFlag<AscendC::HardEvent::MTE3_S>(0); AscendC::WriteGmByPassDCache<int32_t>(addr + blockIdx * 32, 1); } else if (blockIdx == 1) { int32_t preblockIdx = 3; // 核1等待核3,直到核3数据已完成累加。 while (true) { int32_t value = AscendC::ReadGmByPassDCache<int32_t>(addr + preblockIdx * 32); if (value == 1) { AscendC::WriteGmByPassDCache<int32_t>(addr + preblockIdx * 32, 0); break; } } // 开始当前核的任务之前,等待上一个核的通知。 AscendC::SetFlag<AscendC::HardEvent::S_MTE2>(0); AscendC::WaitFlag<AscendC::HardEvent::S_MTE2>(0); AscendC::DataCopy(src1Local, src1Global, DATA_LENGTH); AscendC::SetFlag<AscendC::HardEvent::MTE2_MTE3>(0); AscendC::WaitFlag<AscendC::HardEvent::MTE2_MTE3>(0); AscendC::DisableDmaAtomic(); AscendC::SetAtomicAdd<float>(); AscendC::DataCopy(dstGlobal, src1Local, DATA_LENGTH); AscendC::DisableDmaAtomic(); } else if (blockIdx == 2) { int32_t preblockIdx = 0; // 核2等待核0,直到核0数据已完成累加。 while (true) { int32_t value = AscendC::ReadGmByPassDCache<int32_t>(addr + preblockIdx * 32); if (value == 1) { AscendC::WriteGmByPassDCache<int32_t>(addr + preblockIdx * 32, 0); break; } } // 开始当前核的任务之前,等待上一个核的通知。 AscendC::SetFlag<AscendC::HardEvent::S_MTE2>(0); AscendC::WaitFlag<AscendC::HardEvent::S_MTE2>(0); AscendC::DataCopy(src2Local, src2Global, DATA_LENGTH); AscendC::SetFlag<AscendC::HardEvent::MTE2_MTE3>(0); AscendC::WaitFlag<AscendC::HardEvent::MTE2_MTE3>(0); AscendC::DisableDmaAtomic(); AscendC::SetAtomicAdd<float>(); AscendC::DataCopy(dstGlobal, src2Local, DATA_LENGTH); AscendC::DisableDmaAtomic(); // 在通知下一个核之前,等待当前核的任务完成。 AscendC::SetFlag<AscendC::HardEvent::MTE3_S>(0); AscendC::WaitFlag<AscendC::HardEvent::MTE3_S>(0); AscendC::WriteGmByPassDCache<int32_t>(addr + blockIdx * 32, 1); } else if (blockIdx == 3) { int32_t preblockIdx = 2; // 核3等待核2,直到核2数据已完成累加。 while (true) { int32_t value = AscendC::ReadGmByPassDCache<int32_t>(addr + preblockIdx * 32); if (value == 1) { AscendC::WriteGmByPassDCache<int32_t>(addr + preblockIdx * 32, 0); break; } } // 开始当前核的任务之前,等待上一个核的通知。 AscendC::SetFlag<AscendC::HardEvent::S_MTE2>(0); AscendC::WaitFlag<AscendC::HardEvent::S_MTE2>(0); AscendC::DataCopy(src3Local, src3Global, DATA_LENGTH); AscendC::SetFlag<AscendC::HardEvent::MTE2_MTE3>(0); AscendC::WaitFlag<AscendC::HardEvent::MTE2_MTE3>(0); AscendC::DisableDmaAtomic(); AscendC::SetAtomicAdd<float>(); AscendC::DataCopy(dstGlobal, src3Local, DATA_LENGTH); AscendC::DisableDmaAtomic(); // 在通知下一个核之前,等待当前核的任务完成。 AscendC::SetFlag<AscendC::HardEvent::MTE3_S>(0); AscendC::WaitFlag<AscendC::HardEvent::MTE3_S>(0); AscendC::WriteGmByPassDCache<int32_t>(addr + blockIdx * 32, 1); } // GM结果:data0 + data1 + data2 + data3。

完整样例请参考:数据搬运随路原子操作做确定性计算的样例。

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/15 15:10:14

ncmdumpGUI:如何有效解决网易云音乐NCM格式的跨平台播放难题

ncmdumpGUI&#xff1a;如何有效解决网易云音乐NCM格式的跨平台播放难题 【免费下载链接】ncmdumpGUI C#版本网易云音乐ncm文件格式转换&#xff0c;Windows图形界面版本 项目地址: https://gitcode.com/gh_mirrors/nc/ncmdumpGUI 网易云音乐的NCM加密格式在保护数字音…

作者头像 李华
网站建设 2026/7/15 15:07:43

GetQzonehistory技术方案:构建QQ空间历史数据本地化归档系统

GetQzonehistory技术方案&#xff1a;构建QQ空间历史数据本地化归档系统 【免费下载链接】GetQzonehistory 获取QQ空间发布的历史说说 项目地址: https://gitcode.com/GitHub_Trending/ge/GetQzonehistory 在社交媒体数据管理日益重要的今天&#xff0c;个人数字资产的安…

作者头像 李华
网站建设 2026/7/15 15:07:29

机器视觉全自动检测核心优势

一、检测精度更高&#xff0c;微小缺陷不漏检1. 搭配工业相机、远心镜头、专业光源&#xff0c;可识别微米级瑕疵&#xff08;划痕、微裂纹、麻点、磕碰&#xff09;&#xff0c;远超肉眼分辨极限&#xff1b;2. 多角度、360环绕成像&#xff0c;无视觉死角&#xff0c;齿轮齿根…

作者头像 李华
网站建设 2026/7/15 15:07:21

齿轮360°环绕式全自动视觉检测系统介绍

一、系统简介 齿轮360环绕视觉全自动检测系统&#xff0c;针对齿轮锻件、车削半成品、滚齿/磨齿半成品、装配成品齿轮开发&#xff0c;采用多相机环形环绕成像伺服分度旋转机构深度学习AI视觉算法&#xff0c;一次性完成齿轮端面、外圆、全部齿面、齿根、倒角、内孔、键槽全方…

作者头像 李华
网站建设 2026/7/15 15:06:02

昇腾C GatherMask函数文档

GatherMask 产品支持情况 产品 是否支持 Ascend 950PR/Ascend 950DT √ Atlas A3 训练系列产品/Atlas A3 推理系列产品√ Atlas A2 训练系列产品/Atlas A2 推理系列产品√ Atlas 200I/500 A2 推理产品√ Atlas 推理系列产品AI Core √ Atlas 推理系列产品Vector Core x Atlas …

作者头像 李华
网站建设 2026/7/15 15:05:56

android-性能-Android Profiler 内存分析教程及案例

以下是 Android Profiler 内存分析的操作教程及典型案例分析&#xff0c;结合权威资料整理&#xff1a; &#x1f4ca; 一、Memory Profiler 核心操作教程 启动方式 方式1&#xff08;工具栏&#xff09;&#xff1a;连接设备后&#xff0c;点击 Android Studio 右侧工具栏 Pr…

作者头像 李华