☰
并行计算加速比曲线解读:阿姆达尔定律与强/弱扩展实验
2026/11/1 1:11:15 网站建设 项目流程

简介:郑州大学并行计算实验报告面向计算机与人工智能专业学生及并行计算课程学习者,由gyb老师指导,作者在该课程取得90分以上成绩,可用于理解任务分解、负载均衡、通信同步等基础概念,并对照共享内存与分布式内存模型开展实验复现。报告围绕矩阵乘法、FFT、并行排序等算法设计展开,结合图像绘制中的并行渲染、像素操作加速,以及Gprof、Intel VTune等工具的性能瓶颈识别、并行度评估与缓存效率分析,还涉及OpenMP、MPI及CUDA/OpenCL等实验环境。文末总结学习收获、实验结果与后续研究方向,如深度学习与高性能计算。资源以zip压缩包分发,约37.69MB;文件总数与类型明细上游未提供,下载后可见具体报告与配套材料。目前已有702人学习下载,适合需要课程实验参考、代码分析范例和图像绘制实践的中高级学习者查漏补缺。

1. 加速比不是线性的:并行计算实验里最容易被误读的一张图

把一段三重循环用 OpenMP 加一行 pragma,八核机器上跑出来的加速比大概率是 3 到 5,而不是 8。并行计算实验里最容易出问题的地方就在这里:图都画了,曲线却停在 4 附近,没人解释剩下那几个核去哪了。搞懂这件事比多跑几个 benchmark 有用得多——它决定了你翻一份 90 分以上的实验报告时,看的是图好不好看,还是能不能顺着曲线读出串行分数、通信开销、负载不均这三笔账。这套并行计算课程实验材料把绘制图像和代码分析分开写,正好可以对着看:同一份程序,强扩展曲线和弱扩展曲线为什么会给出两个方向相反的结论。

2. 阿姆达尔定律与实测加速比:理论天花板和你的曲线差在哪

很多人把阿姆达尔定律当成一句口号背下来,却从没把它和实测曲线放在同一张坐标系里比对过。一旦比对,你会发现公式的价值不在于预言 8 核能跑到几倍,而在于它能把“差的那几倍”拆成可命名的量。

2.1 串行分数 s 的反推:从一条曲线到一个可验证的数

阿姆达尔定律的标准形式是S(n) = 1 / (s + (1-s)/n),其中s是程序中无法并行的部分占串行总时间的比例,n是并行度。正着用是“已知 s 预测加速比”,反着用才有工程意义:已知实测加速比,把s反解出来。把等式两边取倒数整理一下,得到s = (1/S - 1/n) / (1 - 1/n)。

这一步看起来只是代数变形,但它把一个模糊的“我这程序并行度不高”变成了一个可以在报告里写下来的具体数字。

import numpy as np # n: 线程数或进程数;t: 多次运行取最小值后的墙钟时间(秒) n = np.array([1, 2, 4, 8, 16, 32], dtype=float) t = np.array([12.40, 6.53, 3.49, 2.21, 1.51, 1.29]) speedup = t[0] / t # 相对单线程基线的加速比 serial_fraction = (1.0 / speedup - 1.0 / n) / (1.0 - 1.0 / n) # 反解串行分数 s for ni, si, ei in zip(n.astype(int), speedup, serial_fraction): print(f"n={ni:2d} S={si:6.2f} s={ei:.4f}")

跑完你会得到一列s。如果它是稳定的(比如一直在 0.05 到 0.07 之间),说明这份代码确实符合阿姆达尔模型,剩下的差距来自那 6% 的串行段,报告里就可以直接写“8 核理论天花板约 5.6 倍”。如果它随着n单调上升,结论完全相反:模型不成立了,多出来的损耗不是串行代码,而是通信、同步或者内存带宽这类阿姆达尔公式里没有的项。

这里有个血泪教训:t[0]这个基线必须是同一份源码、同一套编译选项、关掉并行后测出来的。拿-O0编译的串行版本去比-O3 -fopenmp的并行版本,加速比会虚高一大截,整套数据作废。

2.2 Karp-Flatt 度量:把“并行度不足”和“通信开销”分开

上面反解出来的s其实就是 Karp-Flatt 度量,定义式一模一样:e = (1/S - 1/n) / (1 - 1/n)。区别在于判读方式——不看绝对值,看趋势。

n实测 Se判读
11.00—基线
21.900.053正常
43.550.042正常
85.600.061正常
168.200.063正常
329.600.075开销开始抬头

e在前 16 个 worker 内基本平稳,说明这段时间里限制加速比的就是那固定的一小段串行代码。到 32 个 worker 时e往上翘,意味着新增的 worker 带来的是开销而不是有效算力——节点间通信、集合操作的对数步数、内存带宽争抢都算在里面。

这个区分对实验结果分析很关键。e平稳,你该去优化串行段(I/O、初始化、结果合并);e上升,你该去优化通信,比如把多次MPI_Reduce合并成一次、把 halo 交换改成非阻塞、把线程数降到缓存能承受的规模。两种病因,两种药。

2.3 强扩展与弱扩展:两条曲线回答的根本不是同一个问题

强扩展(strong scaling)固定问题规模,增加并行度,看时间怎么降;它回答的是“同样这活,能不能干得更快”。弱扩展(weak scaling)让问题规模随并行度同步增长,看每单位工作量所需时间是否保持恒定;它回答的是“更大的活,能不能在同样时间里干完”。

强扩展的天花板由阿姆达尔定律给出,效率E = S/n会随着n增大单调下滑。弱扩展的天花板由 Gustafson 定律给出:S_scaled = n - s(n-1),理论上可以接近线性,只要串行段不随规模增长。

这两条曲线在实验报告里必须同时出现,否则结论极易被误读。只画强扩展,一个设计良好的程序在 64 核上效率掉到 0.4,看起来像写得烂;实际上它的弱扩展效率可能是 0.95,说明它本来就是为大规模问题设计的,只是你给它的题太小。反过来,只画弱扩展也有陷阱:规模一大,缓存命中率、内存带宽、通信拓扑全变了,曲线漂亮不代表小规模场景下也快。

常见的实现思路是给两条曲线配同一张横轴(worker 数,log2 刻度),纵轴一个放加速比、一个放效率,让人一眼看出“这个程序在小规模上受串行段限制,在大规模上受通信限制”这个转折点在哪。

3. 三种并行模型的边界:共享内存、消息传递、SIMT 各自管什么

同一道题用 OpenMP、MPI、CUDA 写三遍,是并行计算课程实验的经典配置。三份代码跑出来的加速比不同,但真正值得写进报告的不是数字,而是为什么不同——三种模型对“共享”这件事的处理方式根本不一样。

3.1 OpenMP 的 fork-join:隐式屏障和调度策略决定了多少开销

OpenMP 的编程模型是 fork-join:主线程遇到#pragma omp parallel时派生出线程组,区域结束时所有线程在隐式屏障处汇合,然后只有主线程继续。这个“隐式屏障”是绝大多数性能意外的来源。

#include <omp.h> double sum = 0.0; /* reduction 让每个线程持有 sum 的私有副本,退出并行区时再合并 */ #pragma omp parallel for schedule(static) reduction(+:sum) for (int i = 0; i < n; ++i) { sum += work(a[i]); /* 循环体内没有锁,也没有伪共享 */ } /* 这里存在隐式屏障:所有线程都结束循环,主线程才会往下走 */

reduction子句解决了两件事:一是避免多个线程同时对sum做读改写造成的竞态,二是避免了用#pragma omp atomic时那条缓存行在核间来回弹跳。如果不写reduction而改用critical,循环体每次迭代都要进出临界区,实测经常比单线程还慢。

schedule的选择直接对应急速变化的负载分布。static按块静态切分,切分发生在循环开始前,开销近乎为零,适合每次迭代耗时均匀的场合;dynamic让线程干完一块再来领一块,适合迭代耗时方差大的场合(比如按行处理图像,某些行全是背景、某些行全是细节),代价是每次领任务都要访问一次共享计数器,块大小设得太小会把这个开销放大成主要成本。guided是两者的折中,块大小从大到小递减。

隐式屏障还带来一个容易被忽略的效应:如果循环体里有 I/O 或者某个线程被操作系统调度走了,所有线程都得等它。这就是为什么“线程数开到核数以上”经常不升反降——超订带来的上下文切换全部计入屏障等待时间。

3.2 MPI 的通信语义:阻塞 Send 为什么能把程序挂死

MPI 是分布式内存模型,进程之间没有共享地址空间,一切靠显式消息。它的性能特征由通信原语的语义决定,而不是由语法决定。

#include <mpi.h> int main(int argc, char **argv) { MPI_Init(&argc, &argv); int rank, size; MPI_Comm_rank(MPI_COMM_WORLD, &rank); MPI_Comm_size(MPI_COMM_WORLD, &size); double local = compute_chunk(rank, size); double total = 0.0; /* 集合通信内部用树形归约,步数是 log2(size),不是 size */ MPI_Reduce(&local, &total, 1, MPI_DOUBLE, MPI_SUM, 0, MPI_COMM_WORLD); if (rank == 0) printf("total = %f\n", total); MPI_Finalize(); return 0; }

MPI_Reduce之所以比“每个进程MPI_Send给 0 号、0 号循环MPI_Recv”快一个量级,是因为前者内部是递归倍增:第 1 步相邻两两归约,第 2 步四个一组归约……log2(p)步就完成,而线性收拢需要p-1次串行接收,0 号进程成了瓶颈。

真正容易翻车的是点对点通信的阻塞语义。标准MPI_Send的语义是“缓冲区可以被安全复用”,但没说它到底会不会立刻返回:消息小的时候 MPI 实现通常直接拷进内部缓冲就返回,消息大的时候会一直阻塞到对端开始接收。

/* 危险写法:所有进程同时先 Send 再 Recv,大消息下极易互相等死 */ MPI_Send(buf, n, MPI_DOUBLE, (rank + 1) % size, 0, MPI_COMM_WORLD); MPI_Recv(buf, n, MPI_DOUBLE, (rank + size - 1) % size, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE);

这段代码在小规模测试数据上可能跑得好好的,一换大数组就挂。修法有两条:环形交换用MPI_Sendrecv(把发送和接收合成一个原子操作,内部由实现负责避免死锁),或者用MPI_Isend+MPI_Waitall先把发送挂起、再做接收。这是 MPI 编程里最经典的踩坑点,不是玄学,是语义。

3.3 CUDA 的执行模型:线程束、内存层次与虚高的占用率

GPU 走的是 SIMT 路线:以 32 个线程组成的 warp 为调度单位,同一个 warp 内的线程执行同一条指令、操作不同的数据。这个模型决定了三件事——分支发散、内存合并访问、共享内存的 bank 冲突。

__global__ void block_reduce(const float *in, float *out, int n) { extern __shared__ float sdata[]; int tid = threadIdx.x; int i = blockIdx.x * blockDim.x + threadIdx.x; sdata[tid] = (i < n) ? in[i] : 0.0f; __syncthreads(); /* 块内屏障,缺了会读到未写完的共享内存 */ for (int s = blockDim.x / 2; s > 0; s >>= 1) { if (tid < s) sdata[tid] += sdata[tid + s]; __syncthreads(); /* 每一轮都要同步,否则读写交叉 */ } if (tid == 0) out[blockIdx.x] = sdata[0]; }

这个块内归约每一轮活跃线程减半,总步数是log2(blockDim.x)。第一轮结束之后,tid >= s的那一半线程进入空闲状态,但它们仍然占着 warp 的槽位——如果s不是 32 的整数倍,一个 warp 里就会同时存在活跃和不活跃的线程,这叫分支发散,硬件要串行化两条路径。实践中通常让前 32 个线程把剩余元素“预归约”掉,再进循环,规避掉最后几轮的发散。

__syncthreads()是块级屏障,只能在块内所有线程都能到达的位置调用。写在if (tid < s)里面就会挂住——部分线程永远到不了屏障,硬件等不到,内核超时。这个错误编译器不一定报,运行时表现为结果错误或者内核启动失败。

还有个更隐蔽的误判:占用率(occupancy)高不等于快。占用率只反映每个 SM 上能同时驻留多少 warp,用来掩盖访存延迟。如果一个内核的瓶颈是全局内存带宽,把占用率从 50% 拉到 100% 也不会变快,反而因为缓存被挤占而变慢。报告里如果只贴一张占用率截图就下结论,很容易被问住。

4. 先测准再跑快:计时口径、伪共享与曲线绘制

并行程序的性能数据比串行程序脆弱得多:一次测量里的噪声可能比你要观察的效应还大。这一章讲的是“怎么让测出来的数可信”,顺序上要排在优化之前。

4.1 计时器选错,整份数据作废

clock()返回的是进程消耗的 CPU 时间,多线程下它会把所有线程的时间累加起来。用它去算并行加速比,会得到一个“并行版耗时约等于串行版耗时乘以线程数”的荒谬结果,然后你把串行时间除以这个数,加速比接近 1 甚至小于 1。反过来,如果先算 CPU 时间再除以线程数,又会得到一条“完美线性”的假曲线。两种都是翻车。

正确的口径是墙钟时间(wall time),并且要取同一个时基:

#include <omp.h> #include <stdio.h> int main(void) { double best = 1e30; for (int rep = 0; rep < 5; ++rep) { double t0 = omp_get_wtime(); /* 墙钟时间,所有线程共享同一时基 */ run_kernel(); double t1 = omp_get_wtime(); if (t1 - t0 < best) best = t1 - t0; /* 取最小值,规避调度抖动 */ } printf("best wall time = %.6f s\n", best); return 0; }

omp_get_wtime()和MPI_Wtime()都保证是墙钟语义,精度在微秒级,够用。跨进程统一计时要用MPI_Wtime(),不要各进程各用clock()。

取最小值还是中位数,取决于你要观察的对象。做扩展性曲线时取最小值:调度抖动只会让某次运行变慢,不会让它变快,最小值最接近“无干扰时的真实耗时”。做稳定性分析或者要和别人对比环境时取中位数,抗异常值。两次方案都别只跑一遍就下结论,至少 5 次。

还有一个常被忽略的准备工作:warmup。第一次运行包含页表建立、动态库加载、GPU 上下文创建、JIT 编译,这些一次性开销在小规模问题上能占到总时间的一半。跑一遍热身、丢弃这次结果,是标准做法。

4.2 伪共享:缓存行是并行程序的隐形税

两个线程写两个不同的变量,逻辑上毫无关系,性能却掉一个数量级——这是伪共享最典型的表现。原因是缓存一致性协议按缓存行(x86 上通常 64 字节)而不是按变量来维持一致性:两个变量落在同一行,任何一个核写它,都会让其他核上这一行的副本失效,然后来回搬运。

#include <omp.h> #include <stdio.h> #define N (1 << 24) #define PAD 16 static int a[N]; static long long partial[64][PAD]; /* 每个线程占一行以上,避免同行争用 */ int main(void) { long long total = 0; #pragma omp parallel { int tid = omp_get_thread_num(); long long local = 0; #pragma omp for schedule(static) for (long long i = 0; i < N; ++i) local += a[i]; partial[tid][0] = local; /* 全程只写这一次,不参与循环 */ } for (int i = 0; i < omp_get_max_threads(); ++i) total += partial[i][0]; printf("%lld\n", total); return 0; }

关键在partial[tid][0] = local这一句被搬到了循环外面。如果把partial[tid]直接当累加器写进循环体,那么每次迭代都会产生一次缓存行失效,8 个线程时实测慢 3 到 8 倍不等,具体倍数取决于核间互联的拓扑。加PAD是因为一行 64 字节能装 8 个long long,不填充的话相邻线程的变量还是会挤在同一行。

reduction子句在实现层面就是帮你做了这件事:编译器为每个线程生成私有副本,通常还做了对齐填充,最后按确定顺序合并。所以能手写reduction就别手写共享累加器。

4.3 归约的两种写法:临界区与树形规约差一个数量级

归约是把 N 个值合成一个值的操作,求和、求最大值、计数都属于这一类。它的并行写法直接决定了同步开销的量级。

/* 写法 A:每次迭代进临界区,O(N) 次同步 */ #pragma omp parallel for for (int i = 0; i < n; ++i) { #pragma omp critical sum += work(a[i]); } /* 写法 B:每线程局部累加,最后 O(p) 次合并 */ double local = 0.0; #pragma omp parallel for reduction(+:local) for (int i = 0; i < n; ++i) local += work(a[i]); sum = local;

写法 A 在 8 线程、N 取一千万时,光是临界区的串行化就能把并行收益吃干净,实测经常比单线程慢。写法 B 把同步次数从 N 降到 p,循环体里全是纯计算,缓存行也不会来回弹。

如果合并阶段本身也需要并行(比如 p 很大,或者要归约的对象是矩阵而不是标量),就上树形规约:第 1 轮相邻两两合并,第 2 轮四个一组,共log2(p)轮。MPI 的MPI_Reduce和 CUDA 的块内归约用的都是这个结构,只是硬件层面的 barrier 换成了__syncthreads()或者网络消息。

需要注意的是浮点求和的结合律不成立,不同归约顺序会得到末位不同的结果。做数值敏感的实验(比如迭代求解收敛判定)时,把归约顺序固定下来,否则你会看到“同一份代码两次跑出两个答案”,误以为是数据竞争。

4.4 把数据画成能说服人的图:四联图脚本

一张只画了加速比的折线图信息量太低。把加速比、效率、Karp-Flatt 和原始墙钟时间放在同一张四联图里,读图的人能自己完成归因。

import numpy as np import matplotlib.pyplot as plt # strong_scaling.csv 表头:n,time_sec,time_sec 为 5 次运行的最小值 data = np.loadtxt("strong_scaling.csv", delimiter=",", skiprows=1) n, t = data[:, 0], data[:, 1] S = t[0] / t # 加速比 E = S / n # 并行效率 e = (1.0 / S - 1.0 / n) / (1.0 - 1.0 / n) # Karp-Flatt 串行分数 fig, ax = plt.subplots(2, 2, figsize=(10, 7.5)) ax[0][0].plot(n, S, "o-", label="measured") ax[0][0].plot(n, n, "--", color="gray", label="linear ideal") ax[0][0].set_xscale("log", base=2) ax[0][0].set_xlabel("workers n"); ax[0][0].set_ylabel("speedup S(n)") ax[0][0].legend() ax[0][1].plot(n, E, "s-") ax[0][1].set_xscale("log", base=2); ax[0][1].set_ylim(0, 1.05) ax[0][1].set_xlabel("workers n"); ax[0][1].set_ylabel("efficiency S/n") ax[1][0].plot(n, e, "^-") ax[1][0].set_xscale("log", base=2) ax[1][0].set_xlabel("workers n"); ax[1][0].set_ylabel("Karp-Flatt e") ax[1][1].bar([str(int(v)) for v in n], t) ax[1][1].set_xlabel("workers n"); ax[1][1].set_ylabel("wall time (s)") fig.tight_layout() fig.savefig("scaling_strong.png", dpi=200)

横轴用log2刻度是必须的,因为并行度通常按 2 的幂增长,线性横轴会把 1 到 8 这一段压成一条竖线,看不出拐点。参考线画y = n,让读者一眼判断偏离理想值多少。

如果做弱扩展,把time_sec换成“归一化时间”(每个 worker 的时间除以单 worker 处理基准规模的时间),理想值是 1 附近的一条水平线,这种画法比原始时间更直观。

图里还应该带误差棒。同一个配置跑 5 次,最大值和最小值之间的差距超过 5% 就说明环境不稳定,这时候把均值画上去没有意义,得先解决绑核、频率调节或者后台进程干扰的问题。

5. 排查清单:五条能把进度拖三天的坑

实验做不下去的时候,问题往往不在算法,在这几个地方。

5.1 加速比超过核数:超线性从哪来

现象:8 核跑出 9.3 倍加速,第一反应是自己写对了,第二反应应该是数据有问题。

原因通常是基线不公平,或者缓存效应。基线如果用了-O0或者没开向量化,而并行版本开了-O3,差距根本不是并行带来的。另一种情况是真实存在的超线性:问题规模刚好卡在单核缓存装不下、多核分摊后每核都能装进 L2 的边界上,总访存次数大幅下降。后者是物理效应,但要能说清楚才写进报告。

解决:用同一套编译选项、同一份源码、只改并行开关来生成基线;在报告里明确写出基线的编译命令和运行环境;如果确实是缓存效应,补一组不同问题规模的对比数据来佐证。

5.2 线程开得越多反而越慢

现象:4 线程比 8 线程快,16 线程直接慢一倍。

原因有三类:线程数超过物理核数导致上下文切换;共享累加器落在同一缓存行造成伪共享;循环体太短,线程创建和屏障同步的开销超过计算本身。

解决:先用OMP_NUM_THREADS扫一遍 1 到物理核数,画出耗时曲线找拐点;把共享写操作改成reduction或线程私有变量再合并;对小循环加if (n > threshold)子句,规模不够时直接走串行分支。绑定核心(OMP_PROC_BIND)能显著减少迁移带来的抖动。

5.3 MPI 程序卡死在第 0 号进程

现象:程序不报错也不退出,top看到所有进程都在S状态,日志停在某个集合通信之前。

原因几乎总是通信配对错误:环形交换用了阻塞Send+Recv;不同分支的进程调用了不同的集合操作(有的进MPI_Reduce、有的没进);或者某个进程在if (rank == 0)里面调了集合通信,其他进程在外面等。

解决:环形交换一律用MPI_Sendrecv;检查每个集合通信调用是否所有进程都到达;调试时给每个通信点加带 rank 的打印,看谁先停住。MPI_Barrier前面加打印是定位这类问题最快的办法。

5.4 同一份代码两次跑出两个答案

现象:浮点结果末几位每次都不同,偶尔差得更多。

原因分两种。一种是数据竞争,#pragma omp parallel for里多个线程写了同一个变量,或者忘了加reduction;另一种是浮点求和顺序不确定,归约树的结构随线程数变化,结合律不成立导致末位差异。

解决:先用单线程版和双线程版对比,差异巨大就是竞态,差异只在末位就是顺序问题。竞态用 ThreadSanitizer 或者-fsanitize=thread编一遍能直接定位到行。顺序问题则固定归约结构,或者在容差范围内比较结果,并在报告里说明判定标准。

5.5 曲线很漂亮,但换个机器就复现不了

现象:在自己笔记本上跑出的效率 0.9,换到实验室服务器上掉到 0.5。

原因:核数、NUMA 拓扑、缓存层级、内存带宽、频率调节策略全都变了。跨 NUMA 节点的访存延迟是节点内的两倍以上,如果线程和内存没有绑在同一个节点上,性能直接腰斩。

解决:报告里必须写清硬件配置(核数、缓存大小、是否多路)、编译器和版本、编译选项、线程绑定方式、问题规模。把参数抽成配置文件或者命令行参数,别硬编码在源码里。用numactl --localalloc或等价手段保证本地内存分配,再对比一次数据。

6. 设计取舍:为什么“跑得更快”不是报告要证明的东西

一套并行程序写下来,最容易掉进的思维定式是:加速比越高越好。实际做下来会发现问题规模、通信粒度、并行度这三者互相牵制,任何一次参数选择都是在拿某一维换另一维。

粒度是最典型的取舍。分块越小,负载越均衡,但通信次数按平方增长;分块越大,通信变少,但尾部的慢任务拖住整体完成时间。图像处理那类实验里,按行切分的静态调度在均匀数据上开销最低,一旦背景区和细节区的计算量差出三倍,动态调度的收益就盖过它自身的计数器争用。没有普适最优,只有“针对这批数据、这个核数、这个缓存大小”的最优。

同步策略也是取舍。reduction快,但只能表达可交换的运算;临界区通用,但把并行度掐死在同步点上。树形归约在 p 大的时候几乎是唯一选择,p 小的时候它的额外轮次反而是负担。

测量口径同样值得权衡。取最小值反映硬件上限,取中位数反映真实体验,两个数在报告里应该同时给出,并且明确标注哪个用来下结论。

取舍维度偏向一端偏向另一端判断依据
分块粒度小块均衡、通信多大块通信少、尾部重迭代耗时方差 / 通信延迟
调度策略static 零开销dynamic 抗倾斜数据分布是否均匀
归约方式临界区简单通用树形规约 log p 步p 的大小与同步频率
计时口径最小值代表上限中位数代表常态结论要说明什么
扩展性验证强扩展看极限弱扩展看可伸缩目标场景的问题规模

我自己的习惯是:每写完一版并行代码,先把串行分数反解出来,看它是不是稳定;稳定就按阿姆达尔的天花板判断还有没有优化空间,不稳定就先修测量方法而不是改算法。这套顺序是从一次翻车里学来的——当时花了两天优化 MPI 通信,最后发现瓶颈是基线用了-O0,所有加速比都是假的。从那以后我每次动并行代码之前,都强制先跑一遍串行基线、确认编译选项一致、再开始测。希望帮到你。

这份课程实验材料里,绘制图像的脚本、各模型的代码分析、以及每一组数据背后的归因过程都是分开留档的,对着它把自己那份曲线重画一遍,比只看结论有用得多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询