如何最快地对 int64_t 类型的数组进行乘法运算?

26

我想将两个内存对齐的数组向量化相乘。我没有找到AVX / AVX2中64*64位相乘的方法,所以我只是展开循环并使用AVX2加载/存储。有更快的方法吗?

注意:我不想保存每次乘法的高半部分结果。

void multiply_vex(long *Gi_vec, long q, long *Gj_vec){

    int i;
    __m256i data_j, data_i;
    __uint64_t *ptr_J = (__uint64_t*)&data_j;
    __uint64_t *ptr_I = (__uint64_t*)&data_i;


    for (i=0; i<BASE_VEX_STOP; i+=4) {
        data_i = _mm256_load_si256((__m256i*)&Gi_vec[i]);
        data_j = _mm256_load_si256((__m256i*)&Gj_vec[i]);

        ptr_I[0] -= ptr_J[0] * q;
        ptr_I[1] -= ptr_J[1] * q;
        ptr_I[2] -= ptr_J[2] * q;
        ptr_I[3] -= ptr_J[3] * q;

        _mm256_store_si256((__m256i*)&Gi_vec[i], data_i);
    }


    for (; i<BASE_DIMENSION; i++)
        Gi_vec[i] -= Gj_vec[i] * q;
}

更新: 我正在使用Haswell微架构和ICC/GCC编译器。因此,AVX和AVX2都可以正常使用。 在乘法循环展开后,我用C内置函数_mm256_sub_epi64代替了-=,在这里它获得了一些加速。当前,代码是ptr_J[0] *= q; ...

我使用了__uint64_t但是出现了一个错误。正确的数据类型是__int64_t


2
如果你这样做,就会承受从SIMD寄存器到ALU寄存器的巨大惩罚。这根本不值得。 - user3528438
gcc生成Karatsuba-ish代码,使用3个32x32→64乘法,3个32位移位和两个加法。对于ILP来说似乎相当不错。 - EOF
2
@EOF 这不完全是 Karatsuba 算法。64x64 到低 64 位乘法不需要顶部一半。因此,您根本不需要高 x 高乘法。这就留下了另外三个。 - Mysticial
2
我不清楚您是否需要AVX或AVX2解决方案(或两者都需要)。这是一个很大的区别。 - Z boson
3
@HélderGonçalves:你可以让gcc自动向量化整个程序,或者使用我回答中的内嵌代码来获得更大的加速效果。甚至可以使用没有向量化的标量代码来获得加速效果。你的代码仍然需要从ymm向量中提取和插入数据。(这可能会导致减速。你测试过基准吗?)此外,不要直接使用__int64_t。只需#include <stdint.h>并使用int64_t,这样您的代码就具有可移植性了。幸运的是,64位乘法的低64位结果无论输入是有符号还是无符号都是相同的,因此该代码以任何方式都会给出相同的结果。 - Peter Cordes
显示剩余4条评论
2个回答

24
你的代码似乎假设long是64位,但同时也使用了__uint64_t。在32位系统中,例如x32 ABI和Windows上,long是32位类型。你的标题提到了long long,但是你的代码却忽略了它。我一直在想你的代码是否假设long是32位。
你使用AVX256加载,但是又将指针别名到__m256i上进行标量操作,这样做完全是自讨苦吃。gcc只能放弃并给你提供你所要求的糟糕代码:向量加载,然后一堆extractinsert指令。你的写法意味着为了进行标量的sub操作,两个向量都必须被解包,而不能使用vpsubq
现代的x86 CPU拥有非常快速的L1缓存,每个时钟周期可以处理两个操作(Haswell及以后的CPU:每个时钟周期可以进行两次加载和一次存储操作)。从同一缓存行进行多个标量加载要比进行向量加载和解包更好。(不过,由于不完美的微操作调度,吞吐量会降低到大约84%:详见下文)

gcc 5.3 -O3 -march=haswell (Godbolt compiler explorer) 很好地自动向量化了一个简单的标量实现。 当AVX2不可用时,gcc仍然愚蠢地使用128位向量进行自动向量化:在Haswell上,这实际上将是理想标量64位代码速度的约1/2。 (请参见下面的性能分析,但将每个向量替换为2个元素而不是4个)。

#include <stdint.h>    // why not use this like a normal person?
#define BASE_VEX_STOP 1024
#define BASE_DIMENSION 1028

// restrict lets the compiler know the arrays don't overlap,
// so it doesn't have to generate a scalar fallback case
void multiply_simple(uint64_t *restrict Gi_vec, uint64_t q, const uint64_t *restrict Gj_vec){
    for (intptr_t i=0; i<BASE_DIMENSION; i++)   // gcc doesn't manage to optimize away the sign-extension from 32bit to pointer-size in the scalar epilogue to handle the last less-than-a-vector elements
        Gi_vec[i] -= Gj_vec[i] * q;
}

内部循环:
.L4:
    vmovdqu ymm1, YMMWORD PTR [r9+rax]        # MEM[base: vectp_Gj_vec.22_86, index: ivtmp.32_76, offset: 0B], MEM[base: vectp_Gj_vec.22_86, index: ivtmp.32_76, offset: 0B]
    add     rcx, 1    # ivtmp.30,
    vpsrlq  ymm0, ymm1, 32      # tmp174, MEM[base: vectp_Gj_vec.22_86, index: ivtmp.32_76, offset: 0B],
    vpmuludq        ymm2, ymm1, ymm3        # tmp173, MEM[base: vectp_Gj_vec.22_86, index: ivtmp.32_76, offset: 0B], vect_cst_.25
    vpmuludq        ymm0, ymm0, ymm3        # tmp176, tmp174, vect_cst_.25
    vpmuludq        ymm1, ymm4, ymm1        # tmp177, tmp185, MEM[base: vectp_Gj_vec.22_86, index: ivtmp.32_76, offset: 0B]
    vpaddq  ymm0, ymm0, ymm1    # tmp176, tmp176, tmp177
    vmovdqa ymm1, YMMWORD PTR [r8+rax]        # MEM[base: vectp_Gi_vec.19_81, index: ivtmp.32_76, offset: 0B], MEM[base: vectp_Gi_vec.19_81, index: ivtmp.32_76, offset: 0B]
    vpsllq  ymm0, ymm0, 32      # tmp176, tmp176,
    vpaddq  ymm0, ymm2, ymm0    # vect__13.24, tmp173, tmp176
    vpsubq  ymm0, ymm1, ymm0    # vect__14.26, MEM[base: vectp_Gi_vec.19_81, index: ivtmp.32_76, offset: 0B], vect__13.24
    vmovdqa YMMWORD PTR [r8+rax], ymm0        # MEM[base: vectp_Gi_vec.19_81, index: ivtmp.32_76, offset: 0B], vect__14.26
    add     rax, 32   # ivtmp.32,
    cmp     rcx, r10  # ivtmp.30, bnd.14
    jb      .L4 #,

如果你想的话,可以将其翻译回内在函数,但是让编译器自动向量化会更容易。我没有尝试分析它是否最优。
如果你通常不使用-O3进行编译,你可以在循环之前使用#pragma omp simd(以及-fopenmp)。
当然,与标量收尾相比,可能更快的方法是对Gj_vec的最后32B进行非对齐加载,并存储到Gi_vec的最后32B中,可能与循环中的最后一次存储重叠。(如果数组小于32B,则仍然需要标量回退。)
GCC13和Clang17仍然都使用这种方式进行向量化,使用另外两个`vpmuludq`而不是一个`vpmulld`来完成两个32位的交叉乘积,减少了一些洗牌操作。但它们能够优化掉指针别名的`ptr_I[0] -= ptr_J[0] * q;`中的插入/提取操作,因此编译成与简单标量版本相同的汇编循环。

AVX2的改进向量内在版本

根据我对Z Boson回答的评论。基于Agner Fog的向量类库代码,在2023年进行了各种改进,同时修复了一个错误。(8年来,没有人注意到我将交叉乘积添加到完整结果的底部,而不是与它们的位置值相匹配的上半部分;感谢@Petr实际测试了这一点。)

Agner Fog的版本通过使用phadd + pshufd来节省一条指令,但在洗牌端口上成为瓶颈,而我使用psllq / pand / paddd。在Haswell和后来的Intel上,使用vpmulld将两个交叉乘积打包在一起需要2个uops(与两个单独的vpmuludq指令相同,但输入的洗牌较少),但在Zen 3和后来的AMD上,只需要1个uops。

由于您的操作数之一是常量,请确保将 set1(q) 作为 b 而不是 a 传递,这样 "b_swap" 重排操作就可以被提升。
// replace hadd -> shuffle (4 uops) with shift/and/add (3 uops with less shuffle-port pressure)
// The constant takes 2 insns to generate outside a loop.
__m256i mul64_avx2 (__m256i a, __m256i b)
{
    // There is no vpmullq until AVX-512. Split into 32-bit multiplies
    // Given a and b composed of high<<32 | low  32-bit halves
    // a*b = a_low*(u64)b_low  + (u64)(a_high*b_low + a_low*b_high)<<32;  // same for signed or unsigned a,b since we aren't widening to 128
    // the a_high * b_high product isn't needed for non-widening; its place value is entirely outside the low 64 bits.

    __m256i b_swap  = _mm256_shuffle_epi32(b, _MM_SHUFFLE(2,3, 0,1));   // swap H<->L
    __m256i crossprod  = _mm256_mullo_epi32(a, b_swap);                 // 32-bit L*H and H*L cross-products

    __m256i prodlh = _mm256_slli_epi64(crossprod, 32);          // bring the low half up to the top of each 64-bit chunk 
    __m256i prodhl = _mm256_and_si256(crossprod, _mm256_set1_epi64x(0xFFFFFFFF00000000)); // isolate the other, also into the high half were it needs to eventually be
    __m256i sumcross = _mm256_add_epi32(prodlh, prodhl);       // the sum of the cross products, with the low half of each u64 being 0.

    __m256i prodll  = _mm256_mul_epu32(a,b);                  // widening 32x32 => 64-bit  low x low products
    __m256i prod    = _mm256_add_epi32(prodll, sumcross);     // add the cross products into the high half of the result
    return  prod;
}

这已经经过测试并且现在可以正常工作,包括对于具有非零高位和低位的值。在Godbolt上查看
请注意,这不包括最后一个问题的减法,只有乘法。
这个版本在Haswell上的表现应该比gcc的自动向量化版本要好一些。(可能是每4个周期一个向量,而不是每5个周期一个向量,瓶颈在于端口0的吞吐量。用一个向量常数替换vpsllq(srli_epi64),或者用4个vpslldq并在加法后进行掩码操作,可以将端口0的瓶颈降低到3个周期。)无论哪种方式,在后来的CPU上,如Skylake和Rocket Lake,https://uica.uops.info/根据执行端口压力估计,仅对乘法(不包括循环或减法)的吞吐瓶颈为2.67,具有完美调度。我选择了移位版本,这样就不需要额外的向量常数,并且让移位/与操作并行运行,以便在这是依赖链的一部分时获得更好的关键路径延迟,与问题中每个元素在加载和存储之间没有太多其他操作不同,因此乱序执行不需要过多努力在迭代之间重叠。
我们可以完全避免使用向量常量,通过执行sumcross = ((crossprod >> 32) + crossprod) << 32。这仍然需要3个指令,但AND操作变成了移位操作,因此它是另一个与乘法竞争的微操作(在Intel P-cores上)。它的关键路径延迟更差。但它避免了常量,并且使用了更少的临时寄存器(由于相同的原因,它的ILP更差且延迟更高),因此在作为较大循环体的一部分进行乘法运算时可能会有用,否则可能会用完寄存器。
AVX1或SSE4.1版本(每个向量两个元素)不会比64位标量imul r64, [mem]更快。除非您已经将数据转换为向量,并且希望结果为向量(与提取到标量然后返回相比可能更好)。或者在具有较慢的imul r64, r64的CPU上,例如Silvermont系列。在这种情况下,_mm_add_epi32_mm_add_epi64更快。

add_epi64 在任何 AVX2 CPU 上都不会比较慢或者占用更多的代码空间,但是在一些早期的 CPU 上,SSE 版本会比较慢。我在可能的情况下使用 add_epi32,因为它不会更慢,并且可能能够节省一些功耗,或者在一些假设的未来 CPU 上具有更低的延迟。根据 https://uops.info/,自 Haswell 以来的 Intel 和自 Zen 1 以来的 AMD 在 Alder Lake E-cores 上运行 vpadddvpaddq 完全相同。


GCC自动向量化代码的性能分析(非内部版本)

背景:请参阅Agner Fog的指令表和微架构指南,以及标签维基中的其他链接。

直到AVX512(见下文),这可能只比标量64位代码稍微快一点:在Intel CPU上,imul r64, m64的吞吐量为每个时钟周期一次(但在AMD Bulldozer系列上为每4个时钟周期一次)。在Intel CPU上,load/imul/sub-with-memory-dest是4个融合域微操作(使用可以微融合的寻址模式,但gcc未能使用)。流水线宽度为每个时钟周期4个融合域微操作,因此即使大规模展开也无法使其每个时钟周期发出一次。通过足够的展开,我们将在加载/存储吞吐量上受到瓶颈限制。在Haswell上,每个时钟周期可能有2次加载和1次存储,但存储地址微操作窃取加载端口将使吞吐量降低到约81/96 = 84%(根据Intel手册)。
所以也许Haswell的最佳方式是使用标量进行加载和乘法(2个uops),然后使用vmovq / pinsrq / vinserti128进行减法操作(使用vpsubq)。这样一来,加载和乘法操作需要8个uops来加载和乘法4个标量,使用7个洗牌uops将数据放入__m256i寄存器(2个movq + 4个pinsrq(2个uops)+ 1个vinserti128),然后再使用3个uops进行向量加载/ vpsubq / 向量存储。因此,每4个乘法操作需要18个融合域uops(发射需要4.5个周期),但需要7个洗牌uops(执行需要7个周期)。所以,与纯标量相比,这个方法不好。
自动向量化的代码对于每个包含四个值的向量使用了8个向量ALU指令。在Haswell架构上,其中5个uops(乘法和移位)只能在端口0上运行,因此无论如何展开这个算法,最多每5个周期就能实现一个向量(即每5/4个周期进行一次乘法)。
移位操作可以用pshufb(端口5)来替代,以移动数据并在其中填充零。 (其他洗牌操作不支持用零替换而不是从输入中复制一个字节,并且输入中没有已知的零可以复制。) paddq / psubq可以在Haswell上的端口1/5或Skylake上的p015上运行。
Skylake在p01上运行pmuludq和立即计数向量移位,因此理论上可以每个max(5/2, 8/3, 11/4) = 11/4 = 2.75个周期处理一个向量。因此,它在总融合域uop吞吐量(包括2个向量加载和1个向量存储)上存在瓶颈。所以一点循环展开会有所帮助。可能由于不完美的调度而导致资源冲突,将其瓶颈限制在每个时钟周期略低于4个融合域uop。循环开销希望能在端口6上运行,该端口只能处理一些标量操作,包括add和compare-and-branch,将端口0/1/5留给向量ALU操作,因为它们接近饱和(8/3 = 2.666个时钟周期)。然而,加载/存储端口远未饱和。
所以,Skylake理论上可以在2.75个周期内处理一个向量(加上循环开销),或者在约0.7个周期内进行一次乘法,使用GCC自动向量化。相比之下,Haswell的最佳选择是理论上每1.2个周期进行一次标量操作,或者理论上每1.25个周期进行一次向量操作。然而,每1.2个周期的标量操作可能需要手动调优的汇编循环,因为编译器不知道如何使用单寄存器寻址模式进行存储,以及使用双寄存器寻址模式进行加载(dst + (src-dst) 并递增 dst)。
另外,如果你的数据不在L1缓存中,通过使用更少的指令完成任务可以让前端在执行单元之前提前进行加载,并在数据被需要之前开始加载。硬件预取不会跨越页面边界,因此对于大型数组,向量循环可能在实践中胜过标量循环,甚至对于较小的数组也可能如此。
AVX-512DQ引入了一个64bx64b->64b的向量乘法。
GCC可以使用它进行自动向量化,只需添加-mavx512dq选项。(实际上,使用-march=x86-64-v4,或者-march=native,或者-march=skylake-avx512等选项来启用其他功能并设置调优选项。)
.L4:
    vmovdqu64       zmm0, ZMMWORD PTR [r8+rax]    # vect__11.23, MEM[base: vectp_Gj_vec.22_86, index: ivtmp.32_76, offset: 0B]
    add     rcx, 1    # ivtmp.30,
    vpmullq zmm1, zmm0, zmm2  # vect__13.24, vect__11.23, vect_cst_.25
    vmovdqa64       zmm0, ZMMWORD PTR [r9+rax]    # MEM[base: vectp_Gi_vec.19_81, index: ivtmp.32_76, offset: 0B], MEM[base: vectp_Gi_vec.19_81, index: ivtmp.32_76, offset: 0B]
    vpsubq  zmm0, zmm0, zmm1    # vect__14.26, MEM[base: vectp_Gi_vec.19_81, index: ivtmp.32_76, offset: 0B], vect__13.24
    vmovdqa64       ZMMWORD PTR [r9+rax], zmm0    # MEM[base: vectp_Gi_vec.19_81, index: ivtmp.32_76, offset: 0B], vect__14.26
    add     rax, 64   # ivtmp.32,
    cmp     rcx, r10  # ivtmp.30, bnd.14
    jb      .L4 #,

所以AVX512DQ(预计将成为Skylake多插槽Xeon(Purley)的一部分,大约在2017年)将通过更宽的向量提供比2倍更大的加速(如果这些指令每个时钟周期都能够进行流水线处理)。
更新:Skylake-AVX512(又称SKL-X或SKL-SP)以每1.5个周期运行VPMULLQ,适用于xmm、ymm或zmm向量。它是3个微操作,延迟为15个周期。(如果这不是AIDA结果中的测量故障,则zmm版本可能会额外增加1个周期的延迟。)

vpmullq比你用32位块构建的任何东西都要快,所以即使当前的CPU没有64位元素的向量乘法硬件,拥有这样的指令也是非常值得的。(可能它们使用FMA单元中的尾数乘法器。)

Zen 4将vpmullq作为单个微操作运行在两个端口中的任意一个,因此对于256位向量,每个时钟周期可以处理2个,对于512位向量,每个时钟周期可以处理1个。后来的英特尔CPU(如Alder Lake / Sapphire Rapids)仍然将其作为3个微操作运行在端口0/1上,因此每个时钟周期可以处理1.5个。https://uops.info/


1
@Matsmath:不用担心。已更新链接到Agner Fog的指南和x86标签wiki页面。我是通过阅读Agner Fog的指南和阅读http://realworldtech.com/论坛和微架构文章来学习这些知识的。(只是阅读而已,我只在那里发过一两次帖子)。如果您不知道简单流水线CPU的工作原理,请阅读有关RISC流水线的文章。 - Peter Cordes
2
@Zboson Skylake Purley的一些延迟数字已经出来了。正如我所预料的那样,vpmullq不是1个uop。它似乎是3个,延迟约为15个周期(3 x 5个周期的乘法)。我实际上早就预测过可能是3个。 - Mysticial
1
@PeterCordes 这是一个基准测试,显示ES 6核心具有全吞吐量AVX512。但我还没有找到零售的6和8核心是否具有半吞吐量或全吞吐量。我刚刚完成了一个7900X系统的组装,它现在放在我的厨房柜台上运行更新。所以我还没有对其进行任何适当的测试。但这并不能回答6和8核心是否具有全吞吐量AVX512的问题。 - Mysticial
1
@Zboson 如果你阅读了我和彼得之间的聊天记录,你会注意到我观察到一种类似于“AVX512节流”的现象,当超过TDP时就会发生。我强烈怀疑这涉及关闭第5个专用FMA端口。我没有足够的证据来证实这个理论,但到目前为止,我看到的一切都支持它。 - Mysticial
1
@Zboson:每1.5个周期一个指令的吞吐量比我们的AVX2 32位块实现要好得多,而且可能延迟也更低。3个微操作并不差。vpmulld在Haswell及以后的处理器上仍然是2个微操作(SnB/IvB上为1个微操作)。 - Peter Cordes
显示剩余35条评论

5
如果你对SIMD 64bx64b到64b(较低)操作感兴趣,这里有来自Agner Fog的Vector Class Library的AVX和AVX2解决方案。我建议使用数组测试它们,并查看它与GCC在类似Peter Cordes' answer中的通用循环相比较的结果。
AVX(使用SSE - 仍然可以使用-mavx编译以获取vex编码)。
// vector operator * : multiply element by element
static inline Vec2q operator * (Vec2q const & a, Vec2q const & b) {
#if INSTRSET >= 5   // SSE4.1 supported
    // instruction does not exist. Split into 32-bit multiplies
    __m128i bswap   = _mm_shuffle_epi32(b,0xB1);           // b0H,b0L,b1H,b1L (swap H<->L)
    __m128i prodlh  = _mm_mullo_epi32(a,bswap);            // a0Lb0H,a0Hb0L,a1Lb1H,a1Hb1L, 32 bit L*H products
    __m128i zero    = _mm_setzero_si128();                 // 0
    __m128i prodlh2 = _mm_hadd_epi32(prodlh,zero);         // a0Lb0H+a0Hb0L,a1Lb1H+a1Hb1L,0,0
    __m128i prodlh3 = _mm_shuffle_epi32(prodlh2,0x73);     // 0, a0Lb0H+a0Hb0L, 0, a1Lb1H+a1Hb1L
    __m128i prodll  = _mm_mul_epu32(a,b);                  // a0Lb0L,a1Lb1L, 64 bit unsigned products
    __m128i prod    = _mm_add_epi64(prodll,prodlh3);       // a0Lb0L+(a0Lb0H+a0Hb0L)<<32, a1Lb1L+(a1Lb1H+a1Hb1L)<<32
    return  prod;
#else               // SSE2
    int64_t aa[2], bb[2];
    a.store(aa);                                           // split into elements
    b.store(bb);
    return Vec2q(aa[0]*bb[0], aa[1]*bb[1]);                // multiply elements separetely
#endif
}

AVX2

// vector operator * : multiply element by element
static inline Vec4q operator * (Vec4q const & a, Vec4q const & b) {
    // instruction does not exist. Split into 32-bit multiplies
    __m256i bswap   = _mm256_shuffle_epi32(b,0xB1);           // swap H<->L
    __m256i prodlh  = _mm256_mullo_epi32(a,bswap);            // 32 bit L*H products
    __m256i zero    = _mm256_setzero_si256();                 // 0
    __m256i prodlh2 = _mm256_hadd_epi32(prodlh,zero);         // a0Lb0H+a0Hb0L,a1Lb1H+a1Hb1L,0,0
    __m256i prodlh3 = _mm256_shuffle_epi32(prodlh2,0x73);     // 0, a0Lb0H+a0Hb0L, 0, a1Lb1H+a1Hb1L
    __m256i prodll  = _mm256_mul_epu32(a,b);                  // a0Lb0L,a1Lb1L, 64 bit unsigned products
    __m256i prod    = _mm256_add_epi64(prodll,prodlh3);       // a0Lb0L+(a0Lb0H+a0Hb0L)<<32, a1Lb1L+(a1Lb1H+a1Hb1L)<<32
    return  prod;
}

这些函数适用于有符号和无符号的64位整数。在您的情况下,由于q在循环内是常量,因此您不需要在每次迭代中重新计算某些内容,但您的编译器可能会自动优化。

这些是Haswell/SKL上的9个融合域uops(其中[v]pmulld为2个uops,而SnB上为1个)。在未融合的域中:4个洗牌uops(p5),3个乘法uops(p0)和2个加法uops(一个仅限于p1,一个为p15)。因此,通过良好的调度,在Haswell和Skylake上每4个周期运行一次。(与Haswell上每5个周期或Skylake上每2.5个周期相比,每4个周期运行一次。) - Peter Cordes
能不能用其他东西替换hadd/pshufd?是的,我们可以使用shift/add/and。1个shuffle uop(p5),3个mul+1个shift(p0),2个ADD(p15),1个AND(p015)。我们可以用pshufb替换shift以减少p0压力。现在生成常量需要2个指令(因此编译器将选择从内存中加载它),但我没有计算它或xor,因为它可以在内联后提升。 - Peter Cordes
使用b作为每次操作数相同的操作数,它可以提升bswap洗牌。有趣的一点。其他所有内容都取决于a*b,所以只有这些。确保您使用b=q而不是a=q - Peter Cordes
我编辑了我的答案。感谢您建议另一种可以更好地优化的算法。我没有花时间考虑除gcc自动向量化输出之外的任何事情。 - Peter Cordes
@PeterCordes,很棒你改进了Agner的解决方案。我建议你给Agner发送一封电子邮件,介绍你的建议。如果他喜欢,可能会将其纳入下一个版本的向量类库中。 - Z boson
2
谢谢。我会将这个放在我的向量类库的下一个更新中。 - A Fog

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接