HPC -- 基础理论

本文是https://en.algorithmica.org/hpc/https://weedge.github.io/perf-book-cn/zh/的阅读笔记。

现代hpc硬件算法

流水冲突 Pipeline Hazard

Pipeline hazard指的是方案指令并行的因素。
image.png
Hazard分为三类:
structural hazard:当两个或多个指令需要CPU的同一部分(例如,执行单元)时,就会发生structural hazard。
data hazard:当必须等待从前一步计算操作数时,就会发生data hazard。
control hazard:当CPU无法判断下一步需要执行哪条指令时,就会发生control hazard。

分支的成本 Cost of Branch

for (int i = 0; i < N; i++)
    a[i] = rand() % 100;
volatile int s;

for (int i = 0; i < N; i++)
    if (a[i] < 50)
        s += a[i];

产生的汇编代码如下所示:

    mov  rcx, -4000000
    jmp  body
counter:
    add  rcx, 4
    jz   finished   ; "jump if rcx became zero"
body:
    mov  edx, dword ptr [rcx + a + 4000000]
    cmp  edx, 49
    jg   counter
    add  dword ptr [rsp + 12], edx
    jmp  counter

这里产生的cost有三个:
第一个是每两个cycles一个control hazards,每个cycle 等于19 cpu cycles
第二个是mov edx, dword ptr [rcx + a + 4000000]访存,5 cpu cycles
第三个是命中的时候4 cpu cycles 放到volatile主存

换成变量P:

for (int i = 0; i < N; i++)
    if (a[i] < P)
        s += a[i];

这个时候P和每循环周期数的关系如下:
image.png
当P = 50的时候,分支预测失败代价很高,就跟上面分析的一样,14个cycles;当 85-90的时候,分支预测失败带来的消耗小于指令执行的消耗,因此有一个低峰。

for (int i = 0; i < N; i++)
    a[i] = rand() % 100;

std::sort(a, a + n);
volatile int s;

for (int i = 0; i < N; i++)
    if (a[i] < 50)
        s += a[i];

分支预测器可以检测到比“总是左,然后总是右”或“左-右-左-右”更复杂的模式。如果我们只是将数组的大小N减小到1000(不进行排序),那么分支预测器会记住整个比较序列,基准测试再次在大约4个周期进行测量——事实上,甚至比排序数组的情况略少,因为在前一种情况下,分支预测器需要花费一些时间在“总是是”和“总是否”状态之间切换。

使用谓词也就是cmov替换分支是这里的优化方法:

for (int i = 0; i < N; i++)
    s += (a[i] < 50 ? a[i] : 0);

Perf book

CPU架构

image.png

BPU

一旦程序达到稳定状态,分支预测单元(BPU)就会引导CPU前端的工作。这就是从BPU到指令缓存的箭头的原因。BPU预测所有分支指令的方向,并根据这个预测引导下一个指令提取。
BPU的核心是一个包含12K条目的分支目标缓冲区(BTB),其中包含有关分支及其目标的信息。这些信息被预测算法使用。每个周期,BPU生成下一个提取地址,并将其传递给CPU前端。

DSB

前端的一个主要性能提升特性是解码流缓冲区(DSB)或_μ_op缓存。其动机是在与L1 I-cache并行工作的单独结构中缓存宏操作到_μ_ops的转换。当BPU生成一个新地址进行提取时,也会检查DSB,以查看_μ_ops的转换是否已经在DSB中可用。频繁发生的宏操作会命中DSB,流水线将避免为32字节的捆绑重复执行昂贵的预解码和解码操作。DSB每个周期可以提供八个_μ_ops,并且最多可以容纳4K个条目。

ROB

CPU后端的核心是512条目的重排序缓冲区(ROB)。
该单元在图表中被称为"分配/重命名(Allocate / Rename)"。它有几个作用。首先,它提供寄存器重命名。只有16个通用整数寄存器和32个向量/SIMD体系结构寄存器,但是物理寄存器的数量要多得多。物理寄存器位于称为物理寄存器文件(PRF)的结构中。从体系结构可见寄存器到物理寄存器的映射保存在寄存器别名表(RAT)中。
其次,ROB分配执行资源。当一条指令进入ROB时,将分配一个新条目,并为其分配资源,主要是一个执行单元和目标物理寄存器。ROB每个周期可以分配多达6个_μ_ops。
第三,ROB跟踪推测执行。当一条指令完成其执行时,其状态会更新,并且会保留在那里,直到前面的指令也完成。之所以这样做,是因为指令总是按程序顺序退役。一旦一条指令退役,其ROB条目将被释放,并且指令的结果变得可见。退役阶段比分配阶段更宽:ROB每个周期可以退役8条指令。

RS

“调度器/保留站(Scheduler / Reservation Station)”(RS)是跟踪给定_μ_op的所有资源可用性并在准备就绪时将_μ_op分派到分配端口的结构。当一条指令进入RS时,调度器开始跟踪其数据依赖关系。一旦所有源操作数可用,RS尝试将_μ_op分派到空闲的执行端口。RS的条目比ROB少。它每个周期最多可以分派6个_μ_ops。

内存

优化内存访问

  1. 当数组较小的时候,使用顺序查找替代二分查找
  2. 使用OAS替代SOA
struct S {
  int a;
  int b;
  int c;
  // other fields
};
S s[N];    // AOS

<=>

struct S { // SOA
  int a[N];
  int b[N];
  int c[N];
  // other fields  
};

使用Eytzinger优化二分查找

image.png

优化结构体大小

使用枚举值
struct S {
  unsigned a;
  unsigned b;
  unsigned c;
}; // S is `sizeof(unsigned int) * 3` bytes

struct S {
  unsigned a:4;
  unsigned b:2;
  unsigned c:2;
}; // S is only 1 byte a占用4位,b和c分别占用2位
使用对齐和填充
// Make an aligned array
alignas(16) int16_t a[N];

// Objects of struct S are aligned at cache line boundaries
#define CACHELINE_ALIGN alignas(64) 
struct CACHELINE_ALIGN S {
  //...
};
避免伪共享
struct S {
  int a; // written by thread A
  int b; // written by thread B
};

#define CACHELINE_ALIGN alignas(64) 
struct S {
  int a; // written by thread A
  CACHELINE_ALIGN int b; // written by thread B
};
调整代码适应缓存大小

显式内存预取

如果请求的内存位置不在缓存中,我们将无论如何遭受缓存未命中,因为我们必须访问 DRAM 并提取数据。但是,如果我们在程序需要数据时将该内存位置引入缓存,那么我们实际上可以将缓存未命中惩罚降为零。
现代 CPU 有两种解决这个问题的机制:硬件预取和 OOO 执行。硬件预取器通过对重复内存访问模式发起预取请求来帮助隐藏内存访问延迟。而 OOO 引擎则向前看 N 条指令,并提前发出加载指令,以允许平滑执行未来将需要此数据的指令。
使用显式并且消除依赖的内存预取:

size_t idx = random_distribution(generator);
for (int i = 0; i < N; ++i) {
  int x = arr[idx]; 
  idx = random_distribution(generator);
  // prefetch the element for the next iteration
  __builtin_prefetch(&arr[idx]);
  doSomeExtensiveComputation(x);
}

image.png

使用EHP和THP

Linux 用户可以使用三种不同的模式使用大页面:

  • 显式大页面 (EHP)
  • 系统范围透明大页面 (THP)
  • 每个进程透明大页面 (THP)

让我们比较一下这些选项。首先,EHP 预先保留在虚拟内存中,而 THP 则没有。这使得使用 EHP 的软件包更难以交付,因为它们依赖于机器管理员所做的特定配置设置。此外,EHP 静态驻留在内存中,占用宝贵的 DRAM 空间,即使它们未使用。
其次,系统范围的透明大页面非常适合快速实验。无需更改用户代码即可测试在您的应用程序中使用大页面的好处。但是,将软件包运送给客户并要求他们启用系统范围的 THP 是不明智的,因为这可能会对该系统上运行的其他程序产生负面影响。通常,开发人员会识别代码中可以从大页面中受益的分配,并在这些位置使用 madvise 提示(每个进程模式)。
每个进程的 THP 没有上述任何一个缺点,但它还有一个缺点。之前我们讨论过,内核分配 THP 对用户来说是透明的。分配过程可能涉及多个内核进程,这些进程负责在虚拟内存中腾出空间,这可能包括将内存换出到磁盘、碎片化或提升页面。透明大页面的后台维护会产生内核在管理不可避免的碎片和交换问题时产生的非确定性延迟开销。EHP 不受内存碎片化影响,也不能换出到磁盘,因此延迟开销要小得多。
总而言之,THP 更易于使用,但会产生更大的分配延迟开销。这正是 THP 在高频交易和其他超低延迟行业不受欢迎的原因,他们更喜欢使用 EHP。另一方面,虚拟机提供商和数据库往往倾向于使用每个进程的 THP,因为要求额外的系统配置会给他们的用户带来负担。

优化计算

critical path关注延迟,非critical path关注吞吐量。

破除数据流依赖

image.png
使用并行的代码破解数据流依赖
优化前:

struct Particle {                                    │
  float x; float y; float velocity;                  │
};                                                   │
                                                     │
class XorShift32 {                                   │
  uint32_t val;                                      │
public:                                              │
  XorShift32 (uint32_t seed) : val(seed) {}          │
  uint32_t gen() {                                   │
    val ^= (val << 13);                              │
    val ^= (val >> 17);                              │
    val ^= (val << 5);                               │
    return val;                                      │ .loop:
  }                                                  │   eor    w0, w0, w0, lsl #13
};                                                   │   eor    w0, w0, w0, lsr #17
                                                     │   eor    w0, w0, w0, lsl #5
static float sine(float x) {                         │   ucvtf  s1, w0
  const float B = 4 / PI_F;                          │   fmov   s2, w9
  const float C = -4 / ( PI_F * PI_F);               │   fmul   s2, s1, s2
  return B * x + C * x * std::abs(x);                │   fmov   s3, w10
}                                                    │   fadd   s3, s2, s3
static float cosine(float x) {                       │   fmov   s4, w11
  return sine(x + (PI_F / 2));                       │   fmul   s5, s3, s3
}                                                    │   fmov   s6, w12
                                                     │   fmul   s5, s5, s6
/* Map degrees [0;UINT32_MAX) to radians [0;2*pi)*/  │   fmadd  s3, s3, s4, s5
float DEGREE_TO_RADIAN = (2 * PI_D) / UINT32_MAX;    │   ldp    s6, s4, [x1, #0x4]
                                                     │   ldr    s5, [x1]
void particleMotion(vector<Particle> &particles,     │   fmadd  s3, s3, s4, s5
                    uint32_t seed) {                 │   fmov   s5, w13
 XorShift32 rng(seed);                               │   fmul   s5, s1, s5
 for (int i = 0; i < STEPS; i++)                     │   fmul   s2, s5, s2
  for (auto &p : particles) {                        │   fmadd  s1, s1, s0, s2
   uint32_t angle = rng.gen();                       │   fmadd  s1, s1, s4, s6
   float angle_rad = angle * DEGREE_TO_RADIAN;       │   stp    s3, s1, [x1], #0xc
   p.x += cosine(angle_rad) * p.velocity;            │   cmp    x1, x16
   p.y += sine(angle_rad) * p.velocity;              │   b.ne   .loop
  }                                                  │
}                                                    │

优化后:

void particleMotion(vector<Particle> &particles, 
                    uint32_t seed1, uint32_t seed2) {
  XorShift32 rng1(seed1);
  XorShift32 rng2(seed2);
  for (int i = 0; i < STEPS; i++) {
    for (int j = 0; j + 1 < particles.size(); j += 2) {
      uint32_t angle1 = rng1.gen();
      float angle_rad1 = angle1 * DEGREE_TO_RADIAN;
      particles[j].x += cosine(angle_rad1) * particles[j].velocity;
      particles[j].y += sine(angle_rad1)   * particles[j].velocity;
      uint32_t angle2 = rng2.gen();
      float angle_rad2 = angle2 * DEGREE_TO_RADIAN;
      particles[j+1].x += cosine(angle_rad2) * particles[j+1].velocity;
      particles[j+1].y += sine(angle_rad2)   * particles[j+1].velocity;
    }
    // remainder (not shown)
  }
}

提示总是内联

对于 GCC 和 Clang 编译器,可以使用 C++11 的 [[gnu::always_inline]] 属性作为内联 foo 的提示,如下面的代码示例所示。对于较早的 C++ 标准,可以使用 attribute((always_inline))。对于 MSVC 编译器,可以使用 __forceinline 关键字。

循环优化

循环不变代码移动(Loop Invariant Code Motion,LICM)

大多数编译器可以自动进行。

for (int i = 0; i < N; ++i)             for (int i = 0; i < N; ++i) {
  for (int j = 0; j < N; ++j)    =>       auto temp = c[i];
    a[j] = b[j] * c[i];                   for (int j = 0; j < N; ++j)
                                            a[j] = b[j] * temp;
                                        }
循环展开

一定要注意循环展开不能引入依赖!!!

for (int i = 0; i < N; ++i)             for (int i = 0; i+1 < N; i+=2) {
  a[i] = b[i] * c[i];          =>         a[i]   = b[i]   * c[i];
                                          a[i+1] = b[i+1] * c[i+1];
                                        }

循环展开是一种众所周知的优化;尽管如此,许多人对此感到困惑,并尝试手动展开循环。我建议没有开发者应该手动展开任何循环。
首先,编译器在这方面做得很好,通常会非常优化地进行循环展开。第二个原因是,由于它们的乱序推测执行引擎(见[@sec:uarch]),处理器具有“嵌入式展开器”。当处理器等待第一次迭代中的长延迟指令完成时(例如加载、除法、微码指令、长依赖链),它会推测性地开始执行第二次迭代的指令,并且只等待循环携带的依赖项。这跨越了多个迭代,有效地在指令重排序缓冲区(ROB)中展开了循环。

循环强度降低

拿gcc测了一下,两个是一样的

for (int i = 0; i < N; ++i)             int j = 0;
  a[i] = b[i * 10] * c[i];      =>      for (int i = 0; i < N; ++i) {
                                          a[i] = b[j] * c[i];
                                          j += 10;
                                        }
循环取消开关

这个编译器优化不了
image.png

循环优化的高级主题

还有一类循环转换会改变循环的结构,通常会影响到多个嵌套循环。我们将探讨循环交换(Loop Interchange)、循环阻塞(Loop Blocking,也称为平铺或Tiling)、以及循环融合和分布(Loop Fusion and Distribution,也称为Fission)。这些转换的目的是改善内存访问,消除内存带宽和内存延迟的瓶颈。从编译器的角度来看,证明这类转换的合法性并证明它们的性能优势是非常困难的。在这个意义上,开发者处于一个更好的位置,因为他们只需要关心他们特定代码片段中转换的合法性,而不需要关心可能发生的每一种情况。不幸的是,这也意味着我们通常需要手动进行这样的转换。

循环交换

交换嵌套循环的顺序的过程。内循环中使用的累加变量切换到外循环,反之亦然。@lst:Interchange展示了交换i和j嵌套循环的例子。循环交换的主要目的是对多维数组的元素进行顺序内存访问。通过遵循元素在内存中布局的顺序,我们可以提高内存访问的空间局部性,使我们的代码更加友好于缓存。

循环分块

这种转换的思想是将多维执行范围分割成更小的块(块或平铺),以便每个块都能适应CPU缓存。如果一个算法处理大型多维数组并对其元素进行跨步访问,那么缓存利用率很可能很低。每一次这样的访问可能会将未来访问请求的数据推出缓存(缓存驱逐)。通过将算法分割成更小的多维块,我们确保循环中使用的数据在被重用之前一直留在缓存中。
循环阻塞是优化通用矩阵乘法(GEMM)算法的广泛已知方法。它增强了内存访问的缓存重用,并改善了算法的内存带宽和内存延迟。

// linear traversal                     // traverse in 8*8 blocks
for (int i = 0; i < N; i++)             for (int ii = 0; ii < N; ii+=8)
  for (int j = 0; j < N; j++)    =>      for (int jj = 0; jj < N; jj+=8)
    a[i][j] += b[j][i];                   for (int i = ii; i < ii+8; i++)
                                           for (int j = jj; j < jj+8; j++)
                                            a[i][j] += b[j][i];
循环展开和融合
for (int i = 0; i < N; i++)           for (int i = 0; i+1 < N; i+=2)
  for (int j = 0; j < M; j++)           for (int j = 0; j < M; j++) {
    diffs += a[i][j] - b[i][j];   =>      diffs1 += a[i][j]   - b[i][j];
                                          diffs2 += a[i+1][j] - b[i+1][j];
                                        }
                                      diffs = diffs1 + diffs2;

循环展开和融合可以在没有跨迭代依赖的情况下执行外循环,换句话说,内循环的两次迭代可以并行执行。此外,如果内循环的内存访问在外部循环索引(本例中的i)上是跨步的,那么这种转换也是有意义的,否则其他转换可能更适用。当内循环的迭代次数较低时,例如小于4,展开和融合特别有用。通过进行转换,我们将更多的独立操作打包到内循环中,从而增加了ILP。
展开和融合转换有时对于外循环向量化非常有用,而在编写本文时,编译器无法自动执行。在内循环的迭代次数对编译器不可见的情况下,它仍然可以向量化原始内循环,希望它执行足够多的迭代以触发向量化代码(下一节将详细介绍向量化)。但如果迭代次数较低,程序将使用慢速的标量版本循环。一旦我们进行展开和融合,我们允许编译器以不同的方式向量化代码:现在将内循环中的独立指令“粘合”在一起(也称为SLP向量化)。

使用polly

向量化

向量化的基础(https://www.yuque.com/treblez/qksu6c/nqe8ip59cwegl6rk?singleDoc#)不再赘述,我们来看不能向量化的情况。

向量化是非法的

https://weedge.github.io/perf-book-cn/zh/chapters/9-Optimizing-Computations/9-4_Vectorization_cn.html#VectIllegal
读后写依赖

void vectorDependence(int *A, int n) {
  for (int i = 1; i < n; i++)
    A[i] = A[i-1] * 2;
}

浮点数运算顺序问题(使用-ffast-math)

// a.cpp 无法向量化,因为((a + b) + c) != (a + (b + c))
float calcSum(float* a, unsigned N) {
  float sum = 0.0f;
  for (unsigned i = 0; i < N; i++) {
    sum += a[i];
  }
  return sum;
}

优化分支预测

使用查找表替换分支

image.png

用算数替换分支

用谓词替换分支

https://www.yuque.com/treblez/qksu6c/gihyheqszownoouf

机器代码布局优化

使用likely和unlikely改变默认布局

比如说异常处理,应该用unlikely改变布局达到更好的局部性

基本块对齐

https://weedge.github.io/perf-book-cn/zh/chapters/11-Machine-Code-Layout-Optimizations/11-4_Basic_Block_Alignment_cn.html#Loop_better

函数拆分

分割冷热代码
image.png

优化ITLB未命中

汇总

image.png

低延时优化

避免页错误

主要是缓存预热:

char *mem = malloc(size);
int pageSize = sysconf(_SC_PAGESIZE)
for (int i = 0; i < size; i += pageSize)
mem[i] = 0;

以及mlock:

#include <malloc.h>
#include <sys/mman.h>
mallopt(M_MMAP_MAX, 0);
mallopt(M_TRIM_THRESHOLD, -1);
mallopt(M_ARENA_MAX, 1);
mlockall(MCL_CURRENT | MCL_FUTURE);
char *mem = malloc(size);
for (int i = 0; i < size; i += sysconf(_SC_PAGESIZE))
mem[i] = 0;
//...
free(mem);

避免TLB驱逐

与基于 MESI 的协议和每个内核的 CPU 缓存(即 L1、L2 和 LLC)不同,硬件本身无法维护核心到核心之间的 TLB 一致性。因此,这项任务必须由内核通过软件来完成。内核通过一种特定的处理器间中断 (IPI) 来实现这个角色,叫做 TLB 驱逐,在 x86 平台上通过 INVLPG 汇编指令实现。
TLB 驱逐是实现多线程应用程序低延迟时最容易忽视的陷阱之一。为什么?因为在多线程应用程序中,进程线程共享虚拟地址空间。因此,内核必须在参与线程运行的内核的 TLB 之间通信特定类型的对该共享地址空间的更新。例如,常用的系统调用,如 munmap(可以禁用 glibc 分配器使用,参见 Section 12.2.1)、mprotect 和 madvise,会影响内核必须在进程的组成线程之间通信的地址空间更改类型。
如何检测多线程应用程序中的 TLB 驱逐?一种简单的方法是检查 /proc/interrupts 中的 TLB 行。一种检测运行时连续 TLB 中断的有用方法是在查看此文件时使用 watch 命令。例如,您可以运行 watch -n5 -d ‘grep TLB/proc/interrupts’, 其中 -n 5 选项每 5 秒刷新视图,而 -d 则突出显示每次刷新输出之间的差异。
image.png

缓慢的浮点运算

最后一级缓存敏感性

https://weedge.github.io/perf-book-cn/zh/chapters/12-Other-Tuning-Areas/12-7_Case_Study_-_LLC_sensitivity_cn.html

量化RAM和Cache设计

定向访问

image.png

  1. L2性能下降约15%,因为瓶颈不是缓存总线
  2. RAM性能下降100%,瓶颈在于RAM的读写模式切换
  3. L3和RAM的同时读写和只写性能比只读差,是因为写包含了隐式的读取到L1,相当于两倍读的消耗

Bypass cache

image.png
bypass cache对于必须经过RAM的读写有加速作用

NUMA的影响

在以下架构中
image.png
4–5核有很大的提升,因为单独的L3能更好地跑满RAM。
image.png

缓存关联性

2的幂次方的访问会带来极大的性能降低
image.png
缓存索引:
offset占6位,index占12位,tag占46位
image.png

全相联映射:主存中的块在cache中随意放(就是想放cache中的哪一行就放哪一行)
直接映射:主存块号%cache有多少行,通过这样计算出来的结果,就是主存块在cache中存放的位置。
组相连映射:主存块号%cache中有多少组,通过这样计算出来的结果,就是主存块在cache中存放的组,至于在组中可以随意放。
如果是2的幂次方的访问,导致只能用到固定的组,引起性能的下降。

内存大页

1780f7251e9576a95b0d23a7d38ff8e5.png

  • 10
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
经导师精心指导并认可、获 98 分的毕业设计项目!【项目资源】:微信小程序。【项目说明】:聚焦计算机相关专业毕设及实战操练,可作课程设计与期末大作业,含全部源码,能直用于毕设,经严格调试,运行有保障!【项目服务】:有任何使用上的问题,欢迎随时与博主沟通,博主会及时解答。 经导师精心指导并认可、获 98 分的毕业设计项目!【项目资源】:微信小程序。【项目说明】:聚焦计算机相关专业毕设及实战操练,可作课程设计与期末大作业,含全部源码,能直用于毕设,经严格调试,运行有保障!【项目服务】:有任何使用上的问题,欢迎随时与博主沟通,博主会及时解答。 经导师精心指导并认可、获 98 分的毕业设计项目!【项目资源】:微信小程序。【项目说明】:聚焦计算机相关专业毕设及实战操练,可作课程设计与期末大作业,含全部源码,能直用于毕设,经严格调试,运行有保障!【项目服务】:有任何使用上的问题,欢迎随时与博主沟通,博主会及时解答。 经导师精心指导并认可、获 98 分的毕业设计项目!【项目资源】:微信小程序。【项目说明】:聚焦计算机相关专业毕设及实战操练,可作课程设计与期末大作业,含全部源码,能直用于毕设,经严格调试,运行有保障!【项目服务】:有任何使用上的问题,欢迎随时与博主沟通,博主会及时解答。
经导师精心指导并认可、获 98 分的毕业设计项目!【项目资源】:微信小程序。【项目说明】:聚焦计算机相关专业毕设及实战操练,可作课程设计与期末大作业,含全部源码,能直用于毕设,经严格调试,运行有保障!【项目服务】:有任何使用上的问题,欢迎随时与博主沟通,博主会及时解答。 经导师精心指导并认可、获 98 分的毕业设计项目!【项目资源】:微信小程序。【项目说明】:聚焦计算机相关专业毕设及实战操练,可作课程设计与期末大作业,含全部源码,能直用于毕设,经严格调试,运行有保障!【项目服务】:有任何使用上的问题,欢迎随时与博主沟通,博主会及时解答。 经导师精心指导并认可、获 98 分的毕业设计项目!【项目资源】:微信小程序。【项目说明】:聚焦计算机相关专业毕设及实战操练,可作课程设计与期末大作业,含全部源码,能直用于毕设,经严格调试,运行有保障!【项目服务】:有任何使用上的问题,欢迎随时与博主沟通,博主会及时解答。 经导师精心指导并认可、获 98 分的毕业设计项目!【项目资源】:微信小程序。【项目说明】:聚焦计算机相关专业毕设及实战操练,可作课程设计与期末大作业,含全部源码,能直用于毕设,经严格调试,运行有保障!【项目服务】:有任何使用上的问题,欢迎随时与博主沟通,博主会及时解答。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值