做过多机多卡训练优化的兄弟们应该都绕不开 NCCL。不管是 PyTorch DDP 还是自研训练框架,AllReduce 都是最基础也最高频的集合通信原语,默认路径下它走的是 Ring 算法,小消息自动切到 LL(Low Latency)模式。这套组合在八卡以上、跨节点场景里表现确实稳,但我在实际调优中发现:单机多卡、GPU 通过 PCIe 交换机互联的节点上,NCCL Ring LL 反而有点杀鸡用牛刀,它的流水线同步、flag 机制、代理线程这些设计都会变成纯开销。于是我们自己做了一个专门跑在 PCIe 拓扑上的 AllReduce 原语,核心思路是用 CUDA IPC 把多进程的显存互相映射起来,在 kernel 里一次读完所有 rank 的数据并完成归约,直接跳过传统 Ring 的多步流水。这篇文章就把从 NCCL Ring LL 到 PCIe IPC AllReduce 的完整设计过程、工程细节和踩坑记录展开讲清楚,写给正在做单机多卡通信优化的朋友参考。
1. 为什么在 PCIe 拓扑下重新审视 AllReduce
1.1 Ring 算法在多机场景是最优解,在单机内未必
先回忆一下 NCCL 的 Ring AllReduce 为什么被大规模采用。机器间走 InfiniBand 或 RoCE 网络时,链路带宽宝贵、延迟高,任何一步"全部数据发给某一个节点再广播回来"的做法都会把通信量放大 N 倍,网络根本扛不住。Ring 的精髓是把总数据 M 均分成 N 份(N 为 GPU 数量),每个 GPU 只向相邻 GPU 传递一份大小为 M/N 的数据块,经过 reduce-scatter 和 all-gather 两个阶段完成全局归约。每个 GPU 的实际跨节点通信量为 2(N-1)M/N,随着 N 增大,这个值趋向于 2M,也就是每条链路只跑两遍数据,这是带宽最优的经典结论。
但是单机多卡节点的情况完全不同。GPU 之间走的是 PCIe switch 甚至 NVLink,链路的延迟比网络小一个数量级,带宽却不一定比网络高多少。PCIe Gen4 x16 的单向带宽大约 2GB/s 到 25GB/s 取决于 lane 配置,这个数字换算成消息延迟,1MB 数据理论传输也就几十微秒。Ring 在这种环境下依然要执行 2(N-1) 步串行依赖的流水操作,每一轮都有 kernel 启动、flag 等待、显存 fence 的开销。N=4 时,明明一次读、一次写就能完成通信量的计算,Ring 非要拆成 6 步小传输,每步之间的同步延迟累加起来,小消息场景下甚至可能占掉总延迟的一半以上。
这就引出一个核心问题:通信原语的设计应该跟着拓扑走,而不是抱着一个通用算法打天下。Ring 是网络拓扑下最稳的选择,但在 PCIe 单机拓扑里,"多对多直读直写"是更自然的模型。
1.2 Ring LL 模式到底把开销藏在哪里
NCCL 的 LL 模式全称 Low Latency,设计目标是小消息场景。它把数据切成非常小的 slice(通常按 4 字节数据加 4 字节 flag 的方式组织),每个 SM 处理细粒度的数据块,用 flag 字段控制生产者和消费者之间的顺序,这样 reduce-scatter 和 all-gather 的每一步都不需要等待整个 chunk 的数据到齐,而是边收边算边发,把流水线的气泡压到最小。
听起来很优雅,但代价也很实在。每个 slice 只有几 KB,处理 1MB 消息要切几百个 slice,每一个 slice 之间的依赖都要靠 flag 字段在显存里循环等待。这个 flag 是跨 GPU 的,通常是写一个 volatile 变量,消费方 SM 原地自旋读取。在 PCIe 拓扑上,跨 GPU 的 flag 轮询延迟比 NVLink 高不少,而且自旋会占用 SM 的 issue 槽位。更麻烦的是 NCCL 为了在多进程间建立通信通道,还要经历初始化握手、创建代理线程、构建拓扑图这些一次性开销,消息越短,这些固定成本的占比就越刺眼。
我做了一组粗略计算:4 卡 PCIe 节点,1MB 消息,Ring LL 理论通信量每卡 1.5MB,按 20GB/s 算只需要 75 微秒的纯传输时间。但实际跑 NCCL 的 benchmark,延迟经常到 130 微秒甚至更高,多出来的 50 微秒基本就是流水线同步和框架开销。如果消息降到 64KB,理论传输时间只有 4.8 微秒,NCCL 的实际延迟却很难低于 35 微秒。这个差距就是优化空间。
2. NCCL Ring LL 的工程原理拆解
2.1 Reduce-Scatter 和 All-Gather 的数学本质
AllReduce 可以拆成两个阶段来看。Reduce-Scatter 阶段,每个 GPU 把自己手上的数据切成 N 份,然后沿着环把第 i 份发给邻居,同时接收邻居传来的数据块,累加到本地对应位置。经过 N-1 轮,每个 GPU 拥有完整数据中某一分片的全局归约结果。接着是 All-Gather 阶段,每个 GPU 把自己已经算好的分片继续沿着环传递,N-1 轮之后每个 GPU 都拿到全部 N 个分片的归约结果。
总通信量用公式表达就是每个 GPU 发送 2(N-1)M/N 字节。这是理论下界,任何 AllReduce 算法,每条链路至少有这么多数据要过。Ring 在这一点上做到了最优。但要注意这个结论的前提是链路的带宽对称、延迟均匀,而且每个 GPU 在每轮只能和邻居通信。PCIe switch 拓扑下,GPU 之间其实是全连接的,任意两个 GPU 都能直接 P2P 访问,Ring 把这种全连接能力人为降成了环,反而放弃了并行度。
所以我的判断是:Ring 的最优性是"链路约束下的最优",不是"拓扑约束下的最优"。当拓扑允许任意对等直连时,完全可以设计更激进的通信模式。
2.2 LL 模式通过减小同步粒度降低延迟
LL 模式和普通模式最大的区别在于数据切分粒度。普通模式一次传输一个较大的 chunk,GPU 必须等整个 chunk 收完才能开始做归约运算,传输大块数据的尾部时,对应的计算单元可能已经空闲。LL 模式把 chunk 继续细分成多个 slice,每个 slice 携带一个 flag,发送方写完 slice 后置 flag,接收方检测到 flag 后立刻对该 slice 做累加,累加完继续往下传。这样流水线里始终有多个 slice 在飞行,等待时间被计算时间掩盖。
NCCL 的 LL 实现里还有一个关键细节:它把 4 字节数据和一个 4 字节的 flag 打包成 8 字节传输,这样可以保证数据在显存里的写入顺序和 flag 的置位顺序满足内存模型要求,避免接收方看到 flag 为真但数据还没写完整。工程上牺牲一半带宽换内存序安全,这是典型的延迟和带宽的折中。对于小消息,延迟收益远大于带宽损失。
不过这个设计在 NVLink 上如鱼得水,在 PCIe 上就有点吃亏。PCIe 的原子操作和内存序保证比 NVLink 弱,P2P 读写的往返延迟也高,SM 自旋等 flag 的代价显著增大。实际性能曲线经常出现小消息还不错、中等消息反而还不如普通模式的情况,因为切分太细导致每个片段的固定开销占比太大。
2.3 单节点 PCIe 拓扑的瓶颈定位
要把 PCIe 拓扑下的 AllReduce 优化做明白,先得看懂硬件拓扑。常见单机配置是两颗 CPU,每颗 CPU 挂一个 PCIe switch,GPU 分布在不同 switch 下,跨 switch 的通信要走 QPI/UPI 互联。同一 switch 下的 GPU 之间 P2P 走 switch 内部交换,延迟低;跨 switch 延迟高一截,带宽也可能受限。
这也是 NCCL 会做拓扑探测的原因,它通过查询 P2P 可达性和链路速度构建一张带宽矩阵,再根据矩阵决定是否拆分通信域。但在默认配置下,小消息的 Ring LL 并不总能感知到"哪些 GPU 在同一个 switch 下",容易把流量引到跨 switch 路径上。
PCIe 还有一个特性是双向带宽不等于单向带宽乘二。实际压测中,同时从多个 GPU 发起对同一目标的读写,目标侧会成为拥塞点。这也是 AllReduce 设计里必须考虑的问题:每个 GPU 既要当生产者又要当消费者,流量模式需要精确测算。
这里给一个实操判断方法:用带宽工具分别测单对单 P2P 读、单对单写、多对一读、一对多写,把结果整理成矩阵,再决定 AllReduce 的流量应该怎么分布。这个矩阵比任何理论模型都靠谱,因为不同主板的 PCIe switch 内部带宽差异很大。
3. PCIe IPC AllReduce 的设计与实现
3.1 CUDA IPC 机制是跨进程共享显存的关键
要在多个 GPU 进程之间直接访问对方的显存,标准做法是 CUDA IPC。每个进程先通过 cudaIpcGetMemHandle 把自己显存地址导出成一个句柄,再通过进程间通信(共享内存、socket、MPI 都行)把句柄发给对端,对端调用 cudaIpcOpenMemHandle 就能拿到一个直接指向本地显存的指针,后续 kernel 里可以像访问本地显存一样访问这个指针。
// 进程 A,导出 GPU 显存句柄 float* dptr; cudaMalloc(&dptr, bytes); cudaIpcMemHandle_t handle; cudaIpcGetMemHandle(&handle, dptr); // 发送 handle 给进程 B,通常走共享内存或 UNIX socket // 进程 B,打开进程 A 的句柄 cudaIpcMemHandle_t remote_handle; float* peer_ptr; cudaIpcOpenMemHandle(&peer_ptr, remote_handle, cudaIpcMemLazyEnablePeerAccess); // 此时 peer_ptr 可以直接在 kernel 里读写这里有三个容易踩的坑。第一,cudaIpcOpenMemHandle 必须在 cudaSetDevice 到对应设备之后调用,否则句柄可能打开失败。第二,打开后的 peer 指针理论上可以跨进程访问,但前提是设备之间支持 P2P,如果不支持,kernel 里访问这个指针会直接报 illegal memory access。第三,句柄的有效期取决于原始内存的生命周期,原始进程如果提前释放显存,对端再访问就是野指针,通常需要约定好释放顺序或者用引用计数管理。
在工程实现里,我们把这套句柄交换放在初始化阶段一次性完成,之后 AllReduce 调用只需要传递消息大小和输入输出指针,不涉及任何句柄操作。这样把昂贵的 IPC 建立成本摊到所有调用里,消息越短收益越明显。
3.2 单 Kernel 一次完成归约的设计思路
核心设计思路可以用一句话概括:把数据切成 N 份,每个 GPU 只负责归约其中一份,处理完后把结果广播写回所有 GPU 的输出缓冲区。这样每个 GPU 在 kernel 里做三件事:读取 N 份输入分片(自己一份、peer N-1 份)、累加归约、把结果写回 N 份输出分片(自己一份、peer N-1 份)。
// GPU rank r 负责归约第 r 份分片,每份大小为 chunk_size // peer_inputs / peer_outputs 是初始化时通过 IPC 打开的指针数组 __global__ void ipc_allreduce_kernel( const float* __restrict__ self_input, const float* const* __restrict__ peer_inputs, float* __restrict__ self_output, float* const* __restrict__ peer_outputs, const size_t chunk_size, const int nranks) { size_t tid = blockIdx.x * blockDim.x + threadIdx.x; if (tid >= chunk_size) return; float sum = self_input[tid]; for (int r = 0; r < nranks - 1; ++r) { sum += peer_inputs[r][tid]; // 跨 PCIe 读 peer 显存 } self_output[tid] = sum; // 本地写 for (int r = 0; r < nranks - 1; ++r) { peer_outputs[r][tid] = sum; // 跨 PCIe 写 peer 显存 } }这个 kernel 的通信量每个 GPU 是 2(N-1)M/N,和 Ring 完全相同。但关键差异在于:Ring 需要 2(N-1) 轮串行传递,每一步都有跨 GPU 同步;这里只有一轮读和一轮写,所有 peer 的读写请求在内存子系统层面并发进行,没有 GPU 间依赖等待。
当然,真实实现不会这么朴素。每个线程只处理一个元素,跨 PCIe 读的延迟暴露在循环里,性能会很难看。需要在每个线程里连续处理多个元素,用 float4 向量化访问,同时让多个 block 并行,用足够大的线程数掩盖 PCIe 延迟。共享内存也可以做 staging buffer,先把 peer 数据成块搬进来,再做本地归约,减少直接访问 peer 显存的次数。
3.3 内存布局与广播写回的权衡
针对输入输出缓冲区,有几个设计选择会影响性能。
第一个选择是分片大小。把总数据 M 分成 N 份,每份 M/N 由对应 GPU 负责。N 越大,单份越小,kernel 内每个线程处理的元素越少,但 peer 数量越多,读循环变长。我们在 4 卡上测试,M=1MB 时 chunk_size=256KB,每线程处理 16 个 float4,效果最好。M 超过 8MB 后,这种一次读完全部数据的方式会遇到显存带宽瓶颈,核内驻留的数据量太大,需要改成多轮流水线,复杂度立刻上来,这时性价比就不如 NCCL 了。
第二个选择是 Peer 指针要不要经过 L2。跨 PCIe 访问默认走的是 peer 设备的内存子系统,大部分流量会经过对端 L2,这对重复读场景有好处,但对只读一次的数据其实是浪费。用__ldg或者 const restrict 修饰可以把部分访问路由到只读缓存,实测能提升 5% 到 10% 的吞吐,但也要小心缓存污染把 SM 的 L1 搞没了。比较稳妥的做法是先构图,对每个 peer 指针测试一遍 kernel 耗时,再决定用哪种访问路径。
第三个选择是广播写回的粒度。每个 GPU 归约完 chunk 后,需要把结果写回 N 份输出。这里如果每个线程单写一个浮点数,写请求太小,PCIe 写效率很低。我们改成按 tile 写回,每个 block 归约完一个 tile 后,用共享内存组织 4KB 的批量写,一次 memcpy 风格地写到一个 peer 的显存区域,写吞吐能提升接近一倍。
3.4 关键参数与可调开关
整个实现有几个参数需要根据平台调整,我列一个速查表:
| 参数 | 推荐范围 | 选择依据 |
|---|---|---|
| chunk 数 | 等于 GPU 数 | 每个 GPU 固定负责一份,避免竞争 |
| 每线程元素数 | 8 到 32 | 太少延迟暴露,太多寄存器压力大 |
| 向量宽度 | float4 | 128 字节对齐,PCIe 吞吐友好 |
| block 大小 | 256 或 512 | 与数据量匹配,SM 占用率大于 50% |
| tile 大小 | 4KB 到 16KB | 与写回粒度强相关,实测决定 |
| 是否用共享内存 | 是 | 数据量适中时 staging 效果明显 |
还有一个容易忽略的配置:CUDA 的 L2 缓存访问策略。跨进程的 peer 显存访问默认可能被 L2 视为正常流量,如果多个 block 同时访问同一份 chunk,L2 会被打爆。可以在 kernel 启动前用 cudaStreamSetAttribute 设置访问策略窗口,把 peer 数据标记为 streaming,避免 L2 过度缓存。这个开关在 PCIe 环境下收益比 NVLink 环境明显得多。
最后的落地方案是可配置的原语选择器:小消息直连 IPC AllReduce,中等消息用带流水线的分块版本,大消息直接调用 NCCL,避免把所有场景都押在一种方案上。
4. 性能实测与分析
4.1 测试环境与基线建立
测试平台是一台 4 卡 PCIe Gen4 的 GPU 服务器,两颗 CPU,每颗挂两块 GPU。GPU 之间通过主板 PCIe switch 互联,支持 P2P,但不支持 NVLink。基准用的 NCCL 版本是较新的 2.x,测试覆盖 64KB 到 1GB 的消息范围,对比维度包括延迟、带宽和 CPU 占用。
| 项目 | 配置 |
|---|---|
| GPU | 4 卡,PCIe Gen4 x16 |
| GPU 互联 | PCIe switch,P2P 可达 |
| CPU | 双路,跨 socket 拓扑 |
| 消息范围 | 64KB 到 1GB |
| 对比基线 | NCCL Ring LL(默认) |
跑基线时有个细节要注意:NCCL 第一次调用会触发初始化,必须做 warmup,否则测出来的延迟会虚高一大截。我的做法是先跑 1000 次小消息预热,再取 100 次有效数据的平均。另一个问题是内核频率波动,GPU 默认的 boost 频率会导致延迟抖动,测试前最好锁定 GPU 时钟,否则很难复现结果。
实测下来基线数据和理论估算接近。64KB 消息 NCCL 大约在 40 微秒左右,1MB 消息稳定在 130 微秒附近,64MB 消息约 20 毫秒带宽接近 PCIe 上限。这个基线作为参照,能清楚看到 IPC 方案的收益落在哪个区间。
4.2 中小消息下的延迟收益
IPC AllReduce 在中消息段的优势非常明显。64KB 消息从约 40 微秒降到 25 微秒左右,1MB 消息从 130 微秒降到 95 微秒上下,整体延迟下降了百分之二三十。
原因不复杂。64KB 消息每卡只需传递 96KB 数据,理论传输时间几微秒,NCCL 的开销大头在流水线同步和 flag 自旋上。IPC 方案只需要两个阶段:一个 kernel 启动,一次读全部 peer 分片,一次写回。kernel 启动本身的延迟大约 8 到 10 微秒,读和写各占几微秒,剩下的就是归约计算时间。账就这么算出来的,没有魔法。
值得注意的还有 CPU 占用。NCCL Ring 的运行会拉起后台代理线程,小消息高频调用时 CPU 占用率不低。IPC 方案整个调用只有 kernel launch 和可能的完成轮询,CPU 占用几乎可以忽略。这对训练框架来说算个意外红利,尤其是在数据加载线程本来就吃紧的情况下。
4.3 大消息场景为什么回退 NCCL
消息超过 16MB 后,IPC AllReduce 的收益逐渐消失,32MB 以上反而开始劣于 NCCL。原因是数据量增大后,每个 GPU 要把 N 份 chunk 全部过一遍 L2,读流量是 Ring 的数倍,L2 和 PCIe 的拥堵让并行度优势被带宽浪费抵消。
而且大消息下 NCCL 会切换到更合适的算法,比如 RDMA 流水线或者 NVLink 优化路径,这些路径的切分和调度是经过反复压测的。我们自研方案在大消息上的唯一优势是可以在 kernel 里直接做融合算子,如果能把 AllReduce 和上游的 elementwise 操作合并,省一次 kernel 启动和一轮显存读写,40MB 以上消息依然能打平 NCCL。但如果只是纯 AllReduce,建议回退到 NCCL,做一个消息大小阈值切换即可。
4.4 拓扑差异带来的预期落差
同样一套代码换到不同的主板或 CPU 平台上,性能波动会比较大。同一 PCIe switch 下的 4 卡,收益最明显;两颗 CPU 各挂两卡、跨 socket 互联的拓扑,收益会缩水一半。这是因为跨 socket 的 P2P 需要经过 QPI/UPI,延迟本来就高,IPC 直读的方式并不能绕过这个物理瓶颈。
NVLink 全互联的机器上情况又不一样。NVLink 的带宽是 PCIe 的 5 到 10 倍,延迟更低,NCCL 的 NVLink 路径本身就非常高效,自研 IPC 很难占到便宜,有时候还会因为内存访问路径不友好输给 NCCL。所以这个优化最适用的场景就是 PCIe 互联的多卡单机,尤其是四卡和八卡里跨 switch 路径不多的情况。在做方案推广前,先用拓扑工具画一遍连接矩阵,别拿一把尺子量所有的布。
5. 踩坑记录与排查思路
5.1 cudaIpcOpenMemHandle 失败与设备上下文错位
第一个高频问题是 cudaIpcOpenMemHandle 返回错误,常见原因是调用前没有 cudaSetDevice 到目标设备。这不算难查,但错误信息往往不直观,容易让人怀疑是句柄本身的问题。
更隐蔽的是另一个问题:进程 A 导出句柄后,如果它重新初始化了 CUDA 上下文,或者句柄对应的显存指针是从池里分配又被回收过的,对端拿到的句柄会指向无效内存。我们的分布式训练框架里有显存池,AllReduce 处理的数据可能是从池中复用的,老句柄指向的内存早被重新映射到别处了。排查下来必须在每次真正分配显存后重新导出句柄,或者干脆不走显存池,给 AllReduce 单独留一块生命周期和通信域一致的内存。
排查这类问题最有效的工具是打开 CUDA 的同步错误检查,在 debug 版本里加cudaGetLastError轮询或者直接跑 compute-sanitizer,后者能精确到访问哪一行代码触发了非法地址。
5.2 P2P 不可用时 Kernel 静默崩掉
P2P 不是所有拓扑都默认开启的。某些驱动配置或者虚拟化环境下,cudaDeviceCanAccessPeer 返回 false,kernel 里访问 peer 指针会直接报 illegal memory access。让人头疼的是这个错误不一定在第一次访问就出现,经常是归约到一半才触发,定位成本很高。
解决方案是在初始化阶段做一次完整的 P2P 可达性扫描,对所有 GPU 组合调用 cudaDeviceCanAccessPeer,生成一张布尔矩阵。只要发现任何一对不可达,就退回 NCCL 或者走 cudaMemcpyPeer 的慢路径。我们还加了一个编译开关,专门模拟不可达情况来做测试,确保 fallback 路径不是摆设。
另外即使 P2P 可达,PCIe 的访问原子性也不如 NVLink。我们在一个很老的驱动版本上遇到过 peer 写回的数据偶尔丢失一两个元素,升级驱动后消失,但为了稳妥,归约完成后加了校验位,抽查输出和输入的一致性,宁可慢一点也不能错。
5.3 跨进程完成通知的竞态问题
kernel 执行完不意味着所有 peer 的写操作都可见。每个进程在自己默认 stream 上 launch kernel,kernel 结束只代表本地读写完成,peer 进程写回本进程的数据是异步的,如果后续计算立刻读取输出缓冲区,可能读到不完整数据。
一开始我用共享内存里的原子计数做完成通知:每个 kernel 结束时把对应 flag 置位,下一个 kernel 启动前先轮询所有 peer 的 flag。这个方案在高频调用下会卡死,因为轮询在 host 侧,CPU 空转占一个核,稍微有点调度抖动性能就崩。
后来改成 device-side 的方案,让后续 kernel 在 global sync 阶段用原子操作检查 flag,匹配上才继续。这个方案把等待从 host 侧搬到了 GPU 侧,延迟可控,但写起来麻烦不少。如果你们的框架允许,更简单的办法是每轮 AllReduce 后强制同步一次 cudaDeviceSynchronize,牺牲一点吞吐换正确性,等稳定了再优化同步粒度。
5.4 排查工具与整体验证流程
整个实现最花时间的不是写 kernel,而是建立可信的验证流程。我的做法分三层:第一层用 CPU 参考实现跑随机数据,对比每个元素的归约结果;第二层跑固定模式数据,比如全 1 或递增序列,这一类数据能让分片边界一目了然地暴露出来;第三层做多进程压力测试,用不同消息大小交叉调用来验证完成通知逻辑。
排查问题时我惯用三个工具:nvidia-smi topo -m 看拓扑,compute-sanitizer 查非法内存访问,NCCL 的 debug 日志看它自己是怎么选路径的。尤其是最后一条,NCCL 的输出里包含拓扑感知和算法选择的关键信息,能直接告诉你它认为最优的路径是什么,反过来校验我们自己的拓扑矩阵是否画错了。
| 报错现象 | 常见原因 | 解决方向 |
|---|---|---|
| cudaIpcOpenMemHandle 报错 | cudaSetDevice 未调用 | 先切设备再打开句柄 |
| kernel 内 illegal address | P2P 不支持 | 初始化阶段扫描可达矩阵 |
| 输出偶发不一致 | PCIe 原子性弱或驱动 bug | 升级驱动、增加校验位 |
| host 轮询 flag 卡死 | 跨进程通知设计不当 | 改 device-side 同步 |
| 大消息性能回退 | 数据量超过 L2 承载 | 阈值切换回 NCCL |
写在最后的几句闲话
做这个优化最大的心得是:不要把一个算法当成默认值无脑用。NCCL Ring LL 在多数环境下是好东西,但它不是为 PCIe 四卡这种小场景单独打磨的。通信库终究是通用层,比它少一层、贴着硬件语义写的专用原语,在小消息场景能省掉很多你看不见的固定开销。我自己在实际项目中保留了双路径策略:消息小于 8MB 走 IPC AllReduce,大于等于 8MB 走 NCCL,切换阈值用一次自动扫描定下来,省心又稳。
最后再分享一个调试技巧:遇到性能诡异上不去,先把数据量固定住,只改 chunk 数和线程数,通常能找到拐点。所有理论估算到最后都要回归到实测,PCIe 拓扑的水很深,每个主板的 switch 行为都不一样,多打几组基准数据比读手册管用得多。