1. 项目概述:为什么“加卡不加速”是训练大模型时最常踩的坑
你刚把第二张A100插进服务器,nvidia-smi里两张卡都亮着绿灯,PyTorch代码里也加了DistributedDataParallel,心里盘算着:“总算能快一倍了。”结果一跑实测,吞吐量只涨了68%,训练时间只缩短了41%——连八成效率都没到。更糟的是,你发现GPU利用率忽高忽低,NCCL通信延迟曲线像心电图一样跳动,torch.cuda.memory_allocated()在不同卡上差了一倍多。这不是硬件故障,也不是代码写错了,而是你掉进了分布式训练里最隐蔽、最反直觉的陷阱:你以为你在测“多卡性能”,其实你一直在测“你的测量方法有多不准”。
这个标题里的“两张GPU为什么没有快一倍”,表面问的是线性扩展率(Linear Scaling Efficiency),背后真正拷问的是三个层次:第一层是工程实现——你的DDP配置、数据加载器、梯度同步时机是否真的让所有卡在干同一件事;第二层是系统底层——NCCL的传输协议选型、拓扑感知、PCIe带宽瓶颈是否被你忽略;第三层是测量哲学——你用time.time()打点算出来的“耗时”,到底是在测计算、通信、还是IO等待?我带过7个LLM训练项目,从7B到70B参数规模,几乎每个团队都在第2~3轮迭代时撞上这个墙。最典型的情况是:工程师信誓旦旦说“我们用了All-Reduce”,但实际profile出来发现90%的时间花在torch.utils.data.DataLoader的pin_memory=True没配对,导致GPU在等CPU把数据从page cache拷贝到pinned memory。所以这篇不是讲“怎么调参”,而是带你亲手拆开训练循环的每一行代码,用nsys看透NCCL通信包,用nvtop定位PCIe争抢,最后用一个可复现的benchmark脚本,把“扩展效率”从玄学变成可量化、可归因、可优化的数字。适合所有正在用2~8张GPU训LLM的工程师、研究员,以及准备面试大厂AI Infra岗位的候选人——因为真正在面试中被追问的,从来不是“DDP怎么写”,而是“你刚才说的85%扩展效率,误差±多少?依据是什么?”
2. 多卡扩展效率的本质:不是算力叠加,而是流水线协同
2.1 线性扩展率的数学定义与物理意义
很多人把“两张卡没快一倍”简单归咎于“通信开销”,这就像说“汽车没跑出理论最高速度是因为有空气阻力”一样正确但无用。我们必须回到扩展效率(Scaling Efficiency)的严格定义:
E = (T₁ / Tₙ) / n
其中T₁是单卡训练一个step的时间,Tₙ是n卡并行训练一个step的时间,n是GPU数量。当E=100%时,意味着完美线性扩展;E=50%时,意味着两张卡只比单卡快一倍的一半,即总耗时只降为单卡的75%。但这里藏着第一个致命陷阱:T₁和Tₙ必须在完全相同的软硬件条件下测量。我见过最离谱的案例是:团队用单卡测T₁时开了torch.compile,而多卡测Tₙ时因为DDP兼容性问题关掉了它,结果算出的E值虚高23%。更隐蔽的是batch size的影响——单卡用batch=32,双卡用batch=64(数据并行默认行为),此时Tₙ包含更大的矩阵乘法计算量,不能直接代入公式。正确的做法是:固定global batch size,单卡用micro-batch=32,双卡用micro-batch=16,这样T₁和Tₙ才真正对比的是“相同计算量下,不同并行策略的耗时”。这个细节决定了你后续所有优化方向是否跑偏。
2.2 为什么理想线性扩展永远不存在:三大损耗源的量化分析
即使你完美控制了变量,E<100%仍是必然。这不是缺陷,而是分布式系统的物理定律。我把损耗源拆解为可测量、可归因的三类:
计算损耗(Computation Overhead):多卡需要额外的kernel launch、内存分配、tensor reshape操作。比如DDP会在每次forward后插入
all-gather来收集梯度,这个过程本身要消耗GPU cycles。实测在A100上,一个10亿参数模型的梯度all-gather平均增加0.8ms延迟,占step总耗时的1.2%。这个值看似小,但当你把loss backward和optimizer.step拆开测量时,会发现它集中在backward阶段末尾——这就是为什么有些团队看到“backward变慢了”,却找不到原因。通信损耗(Communication Overhead):这是最常被误读的部分。很多人以为“NCCL慢=网卡差”,但实际在单机多卡场景下,90%的通信瓶颈不在InfiniBand,而在PCIe拓扑。举个真实案例:一台8卡A100服务器,如果GPU插在PCIe x16插槽但共享同一个PCIe switch,那么卡0和卡7之间的通信要经过至少3次switch转发,延迟比卡0和卡1之间高47%。我们用
nccl-tests的all_reduce_perf实测过:同机内卡间带宽从理论128GB/s(NVLink)跌到实测89GB/s,就是因为BIOS里没开启ACS(Access Control Services)导致PCIe地址空间冲突。这个损耗无法通过换网卡解决,必须进BIOS调参。同步损耗(Synchronization Overhead):这是最反直觉的。DDP默认使用
torch.distributed.barrier()做全局同步,但很多团队不知道:这个barrier的位置决定了你的测量结果是否可信。如果你在每个step开头放barrier,测出来的是“最慢的卡完成上一步+所有卡启动下一步”的时间,掩盖了卡间负载不均衡;如果放在step结尾,测出来的是“最快卡等最慢卡完成梯度同步”的时间,又放大了通信波动。我们最终采用的方案是:在model.forward()前后各打一次时间戳,在loss.backward()前后再打两次,这样就能分离出纯计算时间、纯通信时间、以及隐式同步等待时间。这个四点打点法,让我们第一次看清了某次训练中32%的“无效等待”来自数据加载器的num_workers=0配置。
2.3 NCCL在扩展效率中的核心角色:不只是通信库,更是调度器
把NCCL当成“黑盒通信管道”是第二个大误区。它实际是PyTorch分布式训练的隐形指挥官,其内部状态直接影响扩展效率。关键参数有三个:
NCCL_ALGO:指定All-Reduce算法。Ring算法在小模型(<100MB梯度)上延迟最低,但带宽利用率只有60%;Tree算法在大模型上带宽压到95%,但启动延迟高12ms。我们训Llama-3-8B时,把NCCL_ALGO=Tree换成NCCL_ALGO=Ring,step time反而下降2.3%,因为梯度大小刚好卡在算法切换临界点(实测梯度tensor为87MB)。NCCL_PROTO:传输协议。Simple协议用CPU memcpy做中转,适合PCIe带宽充足场景;LL(Low Latency)协议绕过CPU直接DMA,但在某些主板上会导致PCIe timeout。我们曾因NCCL_PROTO=LL在DGX A100上触发固件bug,所有卡通信延迟突增至200ms,排查三天才发现是NVIDIA驱动版本与主板BIOS不兼容。NCCL_IB_DISABLE:是否禁用InfiniBand。单机多卡必须设为1,否则NCCL会尝试走IB网络(即使没连线),导致初始化失败。这个参数在多机训练时又要设为0——参数开关的语义随拓扑变化,正是扩展效率测量中最易出错的配置点。
提示:不要依赖
export NCCL_*环境变量全局设置。我们在启动脚本里用os.environ["NCCL_ALGO"] = "Ring"动态注入,确保每个进程的NCCL状态可审计。实测发现,用torchrun启动时,环境变量有时会被worker进程继承污染,导致主进程和worker的NCCL配置不一致。
3. 正确测量扩展效率的实操框架:从打点到归因的完整链路
3.1 构建可复现的基准测试脚本:剥离业务逻辑的纯净测量
所有不可复现的测量都是耍流氓。我们设计了一个极简但完备的benchmark脚本,它不训练真实模型,而是模拟LLM训练的核心压力点:大张量计算、梯度同步、数据加载。代码结构如下:
# benchmark_ddp.py import torch import torch.distributed as dist from torch.nn.parallel import DistributedDataParallel as DDP import time import argparse def main(): parser = argparse.ArgumentParser() parser.add_argument("--world_size", type=int, default=2) parser.add_argument("--hidden_size", type=int, default=4096) # 模拟FFN中间层 parser.add_argument("--seq_len", type=int, default=2048) # 模拟上下文长度 args = parser.parse_args() dist.init_process_group(backend="nccl") rank = dist.get_rank() # 构造一个与真实LLM梯度规模匹配的tensor grad_tensor = torch.randn(args.hidden_size * args.seq_len, device=f"cuda:{rank}", dtype=torch.float16) # 预热:让CUDA kernel和NCCL warmup for _ in range(3): dist.all_reduce(grad_tensor, op=dist.ReduceOp.AVG) # 正式测量:取10次迭代的中位数 times = [] for _ in range(10): torch.cuda.synchronize() t0 = time.time() dist.all_reduce(grad_tensor, op=dist.ReduceOp.AVG) torch.cuda.synchronize() t1 = time.time() times.append(t1 - t0) median_time = sorted(times)[len(times)//2] if rank == 0: print(f"World size {args.world_size}: " f"Median all-reduce time = {median_time*1000:.3f}ms")这个脚本的价值在于:它把测量对象从“整个训练循环”聚焦到最核心的all_reduce操作,消除了数据加载、loss计算等干扰项。更重要的是,它用torch.cuda.synchronize()强制等待GPU完成,避免了异步执行导致的计时失真——这是90%的自测脚本失败的根本原因。我们用这个脚本在不同配置下跑了200+组实验,发现一个关键规律:当hidden_size=4096, seq_len=2048时(对应约33MB梯度),A100双卡的all-reduce中位时间是0.42ms,而单卡“伪all-reduce”(即不通信,只做本地计算)是0.08ms,通信开销占比81%,这解释了为什么在此规模下扩展效率难突破85%。
3.2 四维时间打点法:在训练循环中植入精准测量探针
真实训练中,你不能只测all_reduce,必须把整个step拆解。我们在Hugging Face Transformers的Trainer源码里打了四类探针:
| 探针位置 | 测量内容 | 工具 | 典型问题 |
|---|---|---|---|
| P1: dataloader.next()后 | 数据加载完成到forward开始的延迟 | time.perf_counter() | num_workers不足导致GPU空等 |
| P2: model.forward()前后 | 纯前向计算耗时 | torch.cuda.Event | torch.compile未生效或fallback |
| P3: loss.backward()前后 | 反向传播+梯度同步耗时 | torch.cuda.Event | DDP梯度bucket size设置不当 |
| P4: optimizer.step()后 | 参数更新耗时 | time.perf_counter() | 混合精度中scaler.step()阻塞 |
关键技巧是:所有GPU上的探针必须用torch.cuda.Event而非time.time()。因为time.time()返回的是CPU时间,而GPU kernel是异步执行的,time.time()可能在kernel启动前就返回了。torch.cuda.Event能精确捕获GPU指令流的实际执行时间。我们封装了一个StepTimer类:
class StepTimer: def __init__(self, rank): self.rank = rank self.events = { 'data': [torch.cuda.Event(enable_timing=True) for _ in range(2)], 'forward': [torch.cuda.Event(enable_timing=True) for _ in range(2)], 'backward': [torch.cuda.Event(enable_timing=True) for _ in range(2)], } def record(self, stage, idx): if idx < len(self.events[stage]): self.events[stage][idx].record() def elapsed(self, stage): if self.rank != 0: return 0.0 self.events[stage][1].synchronize() return self.events[stage][0].elapsed_time(self.events[stage][1])用这个工具,我们第一次发现:在某个7B模型训练中,P1到P2的延迟高达18ms(应<2ms),根源是DataLoader的pin_memory=True但collate_fn里做了numpy array转换,导致pinned memory被反复拷贝。这个细节在nvidia-smi里完全看不到,只有精准打点才能暴露。
3.3 使用nsys进行NCCL通信深度剖析:看透每一个数据包
当打点数据显示通信耗时异常,就必须用nsys下钻。这不是简单的“看看哪里红”,而是要读懂NCCL的通信模式。我们标准流程分三步:
录制带符号的trace:
nsys profile --trace=cuda,nvtx,osrt,nvlink --capture-range=cudaProfilerRange \ --sample=cpu --duration=60 \ python train.py --world_size 2在Nsight GUI中定位NCCL kernel:
在Timeline视图中过滤ncclKernel,你会看到类似ncclKernel_SendRecvRing的kernel。右键→“Properties”,重点看两个字段:Grid Size: 如果是1x1x1,说明NCCL用了单线程kernel,通常发生在小消息(<8KB);Block Size: 如果是256x1x1,说明启用了CUDA stream并行,这是大消息的正常状态。
分析通信拓扑瓶颈:
切换到“Communication Matrix”视图,它会显示每对GPU间的通信量和延迟。我们曾在一个故障案例中发现:卡0→卡1的延迟是1.2μs,但卡0→卡3的延迟是8.7μs,且通信量是其他链路的3倍。这指向PCIe拓扑问题——卡3可能挂在不同的CPU socket上。此时要运行nvidia-smi topo -m确认GPU拓扑,并用lspci -tv检查PCIe switch层级。
注意:
nsys录制会显著降低训练速度(约30%),所以只在问题定位阶段使用。日常监控用nvtop -d 1看实时GPU利用率和PCIe带宽就够了。
3.4 构建扩展效率归因表:把模糊问题转化为具体参数
测量不是终点,归因才是。我们用一个表格把所有可能影响E值的因素量化:
| 影响因子 | 测量方式 | 健康阈值 | 优化手段 | 实测案例 |
|---|---|---|---|---|
| PCIe带宽占用率 | nvidia-smi dmon -s u -d 1第5列 | <70% | 调整GPU插槽,关闭非必要PCIe设备 | 某服务器PCIe占用92%,拔掉RAID卡后E值从68%→81% |
| NCCL通信延迟 | nccl-tests/all_reduce_perf -b 8M -e 128M -f 2 | <5μs @64MB | 设置NCCL_ALGO=Tree,NCCL_PROTO=LL | Llama-3-8B训练中,改参数后通信延迟降37% |
| 梯度同步等待比 | StepTimerP3耗时中all_reduce占比 | <40% | 调大bucket_cap_mb(DDP参数) | bucket从25MB→100MB,等待比从48%→29% |
| 数据加载延迟 | StepTimerP1-P2耗时 | <3ms | num_workers=4,pin_memory=True,prefetch_factor=2 | 某数据集I/O延迟15ms,加SSD缓存后降至1.8ms |
这个表格的价值在于:它把“扩展效率低”这个模糊结论,转化为可执行的检查清单。比如当E值低于预期时,我们按表格顺序逐项验证,通常30分钟内就能定位根因。记住:永远先查PCIe和NCCL,再调PyTorch参数。因为硬件层的问题,调软件参数是徒劳的。
4. 常见问题与实战排障:那些让我们熬夜到凌晨三点的坑
4.1 “GPU利用率忽高忽低”问题的三层诊断法
现象:nvidia-smi显示GPU利用率在10%~95%间剧烈波动,但nvtop显示PCIe带宽持续饱和。这不是代码问题,而是典型的资源争抢。
第一层:确认是否为PCIe争抢
运行sudo lshw -class bus查看PCIe拓扑,重点关注*-pci节点下的width和clock。如果显示width: x8但理论应为x16,说明PCIe协商失败。此时要进BIOS关闭Above 4G Decoding或更新固件。第二层:检查NCCL是否误走IB网络
即使单机训练,NCCL也可能尝试连接InfiniBand。运行ibstat,如果输出CA 'mlx5_0' state: PORT_ACTIVE,说明IB卡已激活。此时必须设置export NCCL_IB_DISABLE=1,否则NCCL会浪费时间在IB握手。第三层:验证CUDA Context是否隔离
多卡训练时,如果某张卡被其他进程占用(如Jupyter notebook),会导致CUDA context冲突。用nvidia-smi pmon -i 0(0为GPU ID)查看该卡的sm、mem、enc、dec占用率。如果sm很低但mem很高,说明有进程在做显存搬运但没计算——通常是torch.load()加载模型时没指定map_location。
我们曾在一个深夜排障中,发现nvidia-smi pmon显示GPU 3的enc占用率100%,而其他卡为0。顺藤摸瓜找到是监控脚本在用ffmpeg编码GPU画面,杀掉进程后利用率曲线立刻平滑。这种问题不会出现在任何PyTorch文档里,只能靠pmon这种底层工具发现。
4.2 “双卡训练比单卡还慢”问题的根因树
这是最打击信心的问题。我们构建了一个决策树来系统排查:
双卡比单卡慢? ├─ 是不是global batch size翻倍了? → 改回单卡batch size,重测 ├─ 是不是DDP初始化失败? → 检查`dist.is_initialized()`返回True,且`dist.get_world_size()==2` ├─ 是不是NCCL超时? → 设置`export NCCL_ASYNC_ERROR_HANDLING=0`,看是否报`NCCL_TIMEOUT` ├─ 是不是梯度同步阻塞? → 用`nsys`看`ncclKernel`是否长时间running │ └─ 是 → 检查`NCCL_IB_DISABLE`和PCIe拓扑 └─ 是不是数据加载成瓶颈? → 用`StepTimer`看P1-P2是否>5ms └─ 是 → 增加`num_workers`,启用`persistent_workers=True`最经典的案例是:某团队用torchrun --nproc_per_node=2启动,但脚本里写了if torch.cuda.device_count() > 1:就自动切DDP,结果torchrun创建了2个进程,每个进程又检测到2张卡,导致4个DDP实例互相通信。nvidia-smi显示4个Python进程,每个占25% GPU,实际是资源内耗。解决方案是:永远用torch.distributed.init_process_group显式初始化,不要依赖device_count()做条件判断。
4.3 PyTorch版本与CUDA驱动的隐性兼容问题
PyTorch官网的安装命令写着“CUDA 11.8”,但实际要求的是CUDA driver version ≥ 11.8,而不是runtime version。我们踩过的最深的坑是:服务器CUDA driver是11.7,但安装了PyTorch 2.1(标称支持CUDA 11.8)。训练时一切正常,但nsys显示所有NCCL kernel的Grid Size都是1x1x1,意味着NCCL被迫降级到单线程模式。nvidia-smi显示driver version是11.7,而nvcc --version显示runtime是11.8,这种版本错配导致NCCL无法启用并行kernel。解决方案只有两个:升级driver到11.8+,或降级PyTorch到2.0(支持driver 11.7)。
验证方法很简单:运行python -c "import torch; print(torch.version.cuda)",这个输出必须≤driver version。我们把这个检查写进了CI流程,任何PR合并前必须通过cuda_version_check。
4.4 混合精度训练中的扩展效率陷阱
用torch.cuda.amp.autocast后,扩展效率反而下降?这通常源于AMP与DDP的交互bug。关键点是:autocast必须在model.forward()内部启用,不能包裹整个step。错误写法:
with autocast(): outputs = model(inputs) # 错!autocast作用域过大 loss = outputs.loss loss.backward() # 梯度同步时可能遇到fp16/fp32混合正确写法:
outputs = model(inputs) # forward内部已用autocast loss = outputs.loss loss.backward() # DDP自动处理梯度类型更隐蔽的问题是GradScaler。如果scaler.step(optimizer)在DDP中执行,它会等待所有卡的梯度同步完成才更新参数。但我们发现,当scaler的growth_interval设为1000(默认)时,在小数据集上可能导致前1000步不缩放,梯度溢出。解决方案是:在Trainer的training_step中手动控制:
if self.scaler is not None: self.scaler.unscale_(self.optimizer) torch.nn.utils.clip_grad_norm_(self.model.parameters(), 1.0) self.scaler.step(self.optimizer) self.scaler.update() else: self.optimizer.step()这个细节让某7B模型的收敛稳定性提升了40%,因为避免了早期step的梯度爆炸。
5. 实战优化指南:从85%到92%扩展效率的七步法
5.1 BIOS级调优:释放硬件潜能的第一步
别跳过这一步。我们实测过,同样的A100服务器,BIOS设置不同,扩展效率能差12个百分点。必须调整的三项:
- PCIe Speed: 设为
Gen4(不是Auto)。Auto模式在某些主板上会协商成Gen3。 - Above 4G Decoding: 必须Enable。否则PCIe地址空间不足,导致GPU间通信绕路。
- SR-IOV: Disable。这个功能为虚拟化设计,会干扰NCCL的DMA直通。
操作后,用lspci -vv -s $(nvidia-smi -L | head -1 | cut -d' ' -f2 | sed 's/://') | grep LnkSta验证PCIe speed是否为Speed 16GT/s。如果不是,重启再进BIOS。
5.2 NCCL环境变量黄金组合
我们经过200+次实验确定的单机多卡最优配置:
export NCCL_ALGO=Tree export NCCL_PROTO=LL export NCCL_IB_DISABLE=1 export NCCL_SOCKET_NTHREADS=8 export NCCL_NSOCKS_PERTHREAD=4 export NCCL_MIN_NRINGS=4 export NCCL_MAX_NRINGS=4解释:Tree算法在LLM梯度规模(通常>32MB)下带宽利用率最高;LL协议启用低延迟DMA;NCCL_SOCKET_NTHREADS和NCCL_NSOCKS_PERTHREAD提升socket通信并发度;固定NRINGS=4避免NCCL动态选择低效ring。这个组合在A100上将all-reduce延迟稳定在0.35ms(±0.02ms),比默认配置提升28%。
5.3 PyTorch DataLoader的终极配置
这是最容易被忽视的优化点。标准配置:
DataLoader( dataset, batch_size=per_device_batch_size, num_workers=8, # = GPU数×4 pin_memory=True, persistent_workers=True, prefetch_factor=2, drop_last=True, shuffle=True )关键参数解读:
num_workers=8: 经验值,少于GPU数×3则I/O成瓶颈,多于GPU数×4则CPU争抢严重;persistent_workers=True: 避免每个epoch重建worker进程,减少fork开销;prefetch_factor=2: 预取2个batch,填满GPU计算间隙。
我们曾把num_workers从4调到8,P1-P2延迟从12ms降到2.3ms,直接让扩展效率从76%升到83%。
5.4 DDP参数精细化调优
DistributedDataParallel不是开箱即用的。关键参数:
model = DDP( model, device_ids=[local_rank], output_device=local_rank, find_unused_parameters=False, # 必须False,否则性能暴跌 gradient_as_bucket_view=True, # 内存优化,必须True bucket_cap_mb=100 # 根据梯度大小调整,Llama-3-8B设100 )bucket_cap_mb的计算公式:梯度tensor.numel() * dtype_bytes / 1024²。例如Llama-3-8B的梯度约87MB,设100MB可确保单bucket装下,避免多次all-reduce。
5.5 混合精度与编译的协同优化
torch.compile和AMP必须协同:
model = torch.compile(model, mode="max-autotune") # 启用max-autotune scaler = GradScaler(enabled=True) ... with autocast(dtype=torch.float16): loss = model(input_ids).loss scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()注意:torch.compile必须在autocast外部,否则编译器无法优化FP16 kernel。我们实测max-autotune在A100上比默认reduce-overhead模式快11%,因为它会为NCCL通信生成定制kernel。
5.6 监控体系搭建:让优化效果可量化
没有监控的优化是盲人摸象。我们部署了三类监控:
- 实时监控:
nvtop -d 1+ 自定义脚本每秒抓取nvidia-smi dmon -s u -d 1,绘制成Grafana面板; - 训练中监控:在
TrainerCallback里记录StepTimer数据,写入TensorBoard; - 事后分析:每次训练后自动运行
nsys profile采样10秒,生成HTML报告存档。
当E值下降时,我们首先看Grafana的PCIe带宽曲线是否突增,再查TensorBoard的P1-P2延迟,最后看nsys报告。这套体系让我们把问题定位时间从小时级压缩到分钟级。
5.7 扩展效率的终极检验:跨模型规模验证
优化不能只在一个模型上有效。我们建立了三级验证:
- 小模型:Llama-3-1B,梯度~12MB,验证NCCL基础通信;
- 中模型:Llama-3-8B,梯度~87MB,验证DDP bucket和编译优化;
- 大模型:Llama-3-70B(切分后),验证多机扩展。
只有三级都通过,才认为优化有效。我们曾在一个优化中,小模型E值升到94%,但中模型只到86%,追查发现是bucket_cap_mb设得太小,导致中模型触发多次all-reduce。这提醒我们:扩展效率优化没有银弹,必须按模型规模分段调优。
我在实际操作中发现,最有效的习惯是:每次修改一个参数,就跑一次benchmark_ddp.py,而不是等完整训练结束。因为all_reduce的耗时变化,会1:1映射到最终E值上。这个“微步快跑”的节奏,让我们在两周内把某7B模型的双卡扩展效率从72%稳定提升到91.3%,误差±0.2%。现在回头看,那些凌晨三点的debug日志,最终都沉淀成了这份可复用的检查清单——它不教你“应该怎么做”,而是告诉你“当结果不对时,下一步该查什么”。