ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零手搓AI工程:数据管道、训练循环与推理服务全链路实战

从零手搓AI工程:数据管道、训练循环与推理服务全链路实战 1. 从零手搓AI工程为什么我不建议你直接调包很多人一听到“AI工程”这四个字第一反应就是打开某个云平台拖几个组件调几个API然后跑通一个Demo就觉得自己已经入门了。我刚开始接触这个方向的时候也是这么想的直到有一次线上推理服务在高峰期直接雪崩日志里全是显存溢出和请求超时我才意识到——只会调包的人根本不知道模型在底层到底经历了什么。ai-engineering-from-scratch这个项目标题核心不在“AI”而在“from scratch”。它要解决的不是“怎么用现成工具跑一个模型”而是“当你把那些封装好的库全部拆掉之后你还能不能从零把一套AI工程链路搭起来”。这包括数据管道的构建、模型训练循环的手写、推理服务的部署、性能瓶颈的定位以及整个过程中每一个参数背后的数学含义。适合谁来参考这篇内容如果你已经会用PyTorch或TensorFlow跑通几个Demo但说不清楚反向传播时梯度到底怎么流动的如果你能调用推理接口但遇到延迟抖动就束手无策如果你想知道一个AI系统从数据到上线到底要经过哪些环节每个环节的坑在哪里——那这篇内容就是写给你的。我会按照一个真实项目的推进节奏把从零搭建AI工程链路的关键节点拆开来讲包括我踩过的坑和后来总结出来的实操技巧。2. 数据管道从原始文件到可训练张量的完整链路2.1 为什么数据加载会成为第一个性能瓶颈很多人写训练脚本的时候习惯把所有数据一次性读进内存然后用一个简单的DataLoader包起来就完事了。在小数据集上这没问题但一旦数据量超过内存容量或者需要做复杂的在线增强这种做法的代价就会立刻显现出来。我在一个图像分类项目里就吃过这个亏数据集大概200GB机器内存只有128GB第一次跑训练的时候直接OOM连第一个epoch都没撑过去。从零构建数据管道核心要解决三个问题数据怎么存、怎么读、怎么喂给模型。存储格式决定了读取效率读取方式决定了CPU和GPU的利用率而喂数据的方式则直接影响训练稳定性。这三个环节任何一个出问题都会让整个训练过程变得极其低效。2.2 存储格式的选择为什么我最终放弃了CSV和JSON在项目初期我用CSV存特征和标签用JSON存元数据。这种格式的好处是可读性强用pandas两行代码就能加载。但问题很快就暴露了CSV的解析速度极慢尤其是当特征维度达到几千维的时候pandas的read_csv几乎成了训练流程中最耗时的环节。我实测过一个包含500万条样本、每条样本2000维特征的CSV文件单次完整读取需要将近12分钟而模型训练一个epoch才需要8分钟。后来我转向了两种方案内存映射文件和列式存储。内存映射文件numpy的memmap的好处是可以像操作数组一样操作磁盘上的数据不需要一次性加载到内存操作系统会自动管理页缓存。列式存储比如Parquet的好处是压缩率高、读取时只加载需要的列特别适合特征维度多但每次只取部分特征的场景。下面是我最终采用的数据存储结构用numpy的memmap做特征存储用Parquet做标签和元数据存储import numpy as np import pandas as pd # 特征存储内存映射文件 # shape (num_samples, feature_dim) features np.memmap( features.dat, dtypefloat32, modew, shape(5000000, 2048) ) # 标签和元数据Parquet格式 meta pd.DataFrame({ sample_id: np.arange(5000000), label: np.random.randint(0, 10, 5000000), split: np.random.choice([train, val, test], 5000000) }) meta.to_parquet(meta.parquet, indexFalse)这个结构的好处是特征数据在磁盘上连续存储读取时可以直接按索引切片不需要解析任何格式标签和元数据用Parquet存储读取速度快且支持谓词下推可以只加载需要的行。2.3 自定义Dataset的正确写法与常见陷阱PyTorch的Dataset类看起来很简单只需要实现__len__和__getitem__两个方法。但就是这两个方法写不好会让训练速度下降好几倍。我见过最常见的错误是在__getitem__里做重复的初始化操作比如每次取数据都重新打开文件、重新创建转换对象。正确的做法是把所有初始化工作放在__init__里__getitem__只做最必要的操作。另外__getitem__的返回值类型要尽量统一避免在collate_fn里做复杂的类型转换。下面是我在一个项目中使用的Dataset实现重点标注了几个容易出问题的地方class MemmapDataset(torch.utils.data.Dataset): def __init__(self, feature_path, meta_path, splittrain, transformNone): # 初始化只做一次不要在__getitem__里重复 self.features np.memmap( feature_path, dtypefloat32, moder ) self.meta pd.read_parquet(meta_path) self.meta self.meta[self.meta[split] split].reset_index(dropTrue) self.transform transform # 转换对象也只创建一次 def __len__(self): return len(self.meta) def __getitem__(self, idx): # 只做最必要的操作 row self.meta.iloc[idx] feature self.features[row[sample_id]].copy() # copy避免memmap只读问题 label row[label] if self.transform: feature self.transform(feature) return torch.from_numpy(feature), label注意np.memmap返回的数组是只读的如果后续需要做原地修改比如归一化必须先.copy()否则会报错。这个坑我在第一次用memmap的时候踩过排查了半天才发现是只读属性导致的。2.4 DataLoader的num_workers和pin_memory怎么调DataLoader有两个参数对性能影响极大num_workers和pin_memory。num_workers决定了用几个进程来并行加载数据设置得太小会导致GPU等数据设置得太大则会增加进程间通信开销。我的经验值是num_workers设置为CPU物理核心数的70%左右比较合适。比如8核CPU设6个worker16核CPU设11到12个worker。pin_memory的作用是把数据加载到锁页内存中这样从CPU传到GPU的时候可以用DMA直接传输不需要经过CPU中转。开启这个选项通常能带来10%到20%的传输速度提升但代价是会占用更多内存。如果内存紧张可以关掉如果内存充足建议开启。还有一个容易被忽略的参数是persistent_workers。默认情况下每个epoch结束后worker进程会被销毁下一个epoch重新创建。如果数据集很大这个创建销毁的开销会非常可观。设置persistent_workersTrue可以让worker进程在epoch之间保持存活省去重复创建的开销。3. 训练循环手写反向传播时最容易搞错的几个地方3.1 计算图的构建与释放为什么你的显存总是爆PyTorch的动态计算图是它最大的优势但也是最容易出问题的地方。每次前向传播都会构建一张新的计算图如果不在合适的时候释放显存就会迅速累积。最常见的错误是在训练循环里累积了不必要的中间变量比如把每个step的loss都存到一个列表里而这个loss是带计算图的张量。正确的做法是在反向传播之前所有需要保留的标量值都要用.item()转成Python数字在反向传播之后及时调用optimizer.zero_grad()清空梯度。下面是一个标准的训练循环骨架我标注了每个步骤的作用和常见错误model.train() for epoch in range(num_epochs): for batch_idx, (data, target) in enumerate(train_loader): data, target data.to(device), target.to(device) # 前向传播构建计算图 output model(data) loss criterion(output, target) # 反向传播计算梯度 optimizer.zero_grad() # 清空上一轮梯度必须在backward之前 loss.backward() # 释放计算图计算梯度 # 参数更新 optimizer.step() # 记录日志用item()转成Python数字避免保留计算图 if batch_idx % log_interval 0: print(fEpoch {epoch}, Batch {batch_idx}, Loss {loss.item():.4f})提示loss.item()和loss.detach()的区别在于item()只能用于标量张量返回Python数字detach()返回一个不带计算图的张量可以用于后续计算。如果需要在日志里记录多个指标建议统一用detach()然后转numpy。3.2 梯度累积与混合精度训练的配合当显存不足以支撑大batch size的时候梯度累积是一个常用的技巧。它的原理是在多个mini-batch上分别计算梯度但不更新参数等累积到一定步数后再统一更新。这样做的好处是可以模拟大batch的效果但代价是训练速度会变慢因为每个step都要做一次前向和反向。混合精度训练则是另一个方向用float16做前向和反向计算用float32保存模型参数。这样既能减少显存占用又能利用GPU的Tensor Core加速计算。但混合精度训练有一个坑float16的数值范围有限梯度太小会下溢成0梯度太大会溢出成inf。PyTorch提供了GradScaler来自动处理这个问题它会动态调整loss的缩放因子避免梯度下溢。把梯度累积和混合精度结合起来用的时候需要注意缩放因子的更新时机。GradScaler的step()方法应该在参数更新时调用而不是在每个mini-batch后调用。下面是一个结合了两者的训练循环scaler torch.cuda.amp.GradScaler() accumulation_steps 4 for batch_idx, (data, target) in enumerate(train_loader): data, target data.to(device), target.to(device) with torch.cuda.amp.autocast(): output model(data) loss criterion(output, target) / accumulation_steps scaler.scale(loss).backward() if (batch_idx 1) % accumulation_steps 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()3.3 学习率调度从warmup到余弦退火的实操参数学习率调度对训练效果的影响有时候比模型结构还大。我在一个文本分类项目里做过对比实验同样的模型和数据用固定学习率训练准确率卡在87%上不去换成warmup加余弦退火之后直接到了91%。这不是玄学而是因为合适的学习率调度能让模型在训练初期稳定收敛在训练后期精细调整。Warmup的作用是在训练最开始的时候用很小的学习率让模型先“热身”避免一开始就大步长更新导致参数震荡。通常warmup的步数设置为总步数的5%到10%。余弦退火则是让学习率按照余弦曲线从最大值逐渐降到接近0这样在训练后期模型可以在局部最优附近做精细搜索。下面是我常用的学习率调度配置基于PyTorch的LambdaLR实现def get_cosine_schedule_with_warmup(optimizer, warmup_steps, total_steps, min_lr_ratio0.01): def lr_lambda(current_step): if current_step warmup_steps: # 线性warmup return float(current_step) / float(max(1, warmup_steps)) # 余弦退火 progress float(current_step - warmup_steps) / float(max(1, total_steps - warmup_steps)) return max(min_lr_ratio, 0.5 * (1.0 math.cos(math.pi * progress))) return LambdaLR(optimizer, lr_lambda)注意min_lr_ratio不要设为0否则训练后期学习率会完全变成0模型不再更新。我一般设为0.01也就是最终学习率是最大学习率的1%。4. 推理服务从单机脚本到可上线的API4.1 模型导出torchscript和onnx到底选哪个训练完模型之后下一步就是把它变成一个可以对外提供服务的接口。这时候第一个要做的决定是用什么格式导出模型。PyTorch原生格式.pt最简单但依赖PyTorch运行时TorchScript可以脱离Python环境运行但转换过程中可能会遇到不支持的操作ONNX的跨平台性最好但算子覆盖度有限某些自定义层可能无法导出。我的选择逻辑是这样的如果推理服务用Python写且对延迟不敏感直接用原生格式最省事如果需要部署到C环境或者对启动速度有要求用TorchScript如果需要跨框架部署比如从PyTorch转到TensorRT用ONNX。下面是一个TorchScript导出的示例重点在于torch.jit.trace和torch.jit.script的选择# trace方式适合没有控制流的模型 model.eval() example_input torch.randn(1, 3, 224, 224).to(device) traced_model torch.jit.trace(model, example_input) traced_model.save(model_traced.pt) # script方式适合有if/for等控制流的模型 scripted_model torch.jit.script(model) scripted_model.save(model_scripted.pt)提示trace方式不会记录Python的控制流如果模型里有if判断或者动态循环必须用script方式。我踩过的坑是一个模型里用了if self.training:来判断是否训练模式用trace导出后这个判断被固化了推理时永远走的是导出时的分支。4.2 批处理与动态batching吞吐量和延迟的平衡推理服务和训练最大的区别在于训练可以慢慢跑推理必须快速响应。但单个请求的推理往往无法充分利用GPU因为GPU的并行计算能力需要足够的batch size才能发挥出来。动态batching就是解决这个矛盾的关键技术服务端不立即处理每个请求而是等待一小段时间比如10毫秒把这段时间内到达的请求合并成一个batch一起推理。这个等待时间的设置很关键。设得太短batch size上不去GPU利用率低设得太长请求延迟增加用户体验变差。我的经验值是对于实时性要求高的场景比如在线推荐等待时间设为5到10毫秒对于离线批处理场景可以设为50到100毫秒。下面是一个简单的动态batching实现思路import asyncio from collections import deque class DynamicBatcher: def __init__(self, model, max_batch_size32, max_wait_ms10): self.model model self.max_batch_size max_batch_size self.max_wait max_wait_ms / 1000.0 self.queue deque() self.lock asyncio.Lock() async def predict(self, input_data): async with self.lock: future asyncio.Future() self.queue.append((input_data, future)) if len(self.queue) self.max_batch_size: await self._process_batch() else: asyncio.get_event_loop().call_later( self.max_wait, lambda: asyncio.ensure_future(self._process_batch()) ) return await future async def _process_batch(self): if not self.queue: return batch list(self.queue) self.queue.clear() inputs [item[0] for item in batch] futures [item[1] for item in batch] # 合并成一个batch推理 batch_tensor torch.stack(inputs).to(device) with torch.no_grad(): outputs self.model(batch_tensor) # 分发结果 for future, output in zip(futures, outputs): future.set_result(output.cpu())4.3 显存预分配与推理加速的取舍推理服务上线后最常遇到的问题不是模型不准而是显存不够用。因为推理服务需要同时处理多个请求每个请求都要分配显存如果显存管理不当很容易出现碎片化或者OOM。PyTorch提供了一个torch.cuda.empty_cache()方法来释放未使用的显存但频繁调用会导致性能下降因为每次释放后重新分配都需要时间。更好的做法是在服务启动时预分配一块显存池后续所有推理请求都从这个池子里分配。PyTorch的CUDACachingAllocator已经做了这件事但默认的池子大小是动态增长的。可以通过设置环境变量PYTORCH_CUDA_ALLOC_CONF来控制分配策略比如设置max_split_size_mb来减少碎片化。另外推理时一定要用torch.no_grad()上下文管理器否则PyTorch会构建计算图白白浪费显存和计算资源。这个坑我在第一次写推理脚本的时候踩过当时发现推理速度比训练还慢排查了半天才发现是忘了加no_grad。5. 性能调优定位瓶颈的完整排查链路5.1 用profiler找到真正的耗时点性能调优的第一步不是盲目优化而是找到瓶颈在哪里。PyTorch自带的profiler可以记录每个算子的执行时间、显存占用、调用次数是定位性能问题的利器。我通常会在训练或推理脚本里加上profiler跑几个step之后导出结果用Chrome的tracing工具打开查看。下面是一个典型的profiler使用方式from torch.profiler import profile, record_function, ProfilerActivity with profile( activities[ProfilerActivity.CPU, ProfilerActivity.CUDA], scheduletorch.profiler.schedule(wait1, warmup1, active3, repeat1), on_trace_readytorch.profiler.tensorboard_trace_handler(./log), record_shapesTrue, profile_memoryTrue ) as prof: for step, (data, target) in enumerate(train_loader): if step 5: break with record_function(model_forward): output model(data) with record_function(model_backward): loss criterion(output, target) loss.backward() prof.step()跑完之后在TensorBoard里可以看到每个算子的耗时排名。我遇到过的典型瓶颈包括数据加载耗时占比过高需要增加num_workers、某个自定义算子实现效率低需要重写、频繁的CPU-GPU传输需要把数据提前放到GPU上。5.2 数据加载、前向、反向、通信的时间占比分析一个健康的训练流程时间应该主要花在前向和反向上数据加载和通信占比不应该超过20%。如果数据加载占比过高说明CPU是瓶颈需要优化数据管道或者增加worker数量。如果通信占比过高多卡训练时说明梯度同步开销太大可以考虑用梯度压缩或者调整通信策略。我通常用简单的计时器来快速评估各阶段的时间占比import time class Timer: def __init__(self): self.reset() def reset(self): self.times {data: 0, forward: 0, backward: 0, step: 0} self.count 0 def record(self, phase, duration): self.times[phase] duration if phase step: self.count 1 def report(self): total sum(self.times.values()) for phase, t in self.times.items(): print(f{phase}: {t:.3f}s ({t/total*100:.1f}%)) timer Timer() for data, target in train_loader: t0 time.time() data, target data.to(device), target.to(device) t1 time.time() output model(data) loss criterion(output, target) t2 time.time() loss.backward() t3 time.time() optimizer.step() optimizer.zero_grad() t4 time.time() timer.record(data, t1 - t0) timer.record(forward, t2 - t1) timer.record(backward, t3 - t2) timer.record(step, t4 - t3)5.3 我踩过的三个性能坑与修复过程第一个坑是在训练循环里做同步的日志写入。我一开始用print在每个step输出loss结果发现训练速度比预期慢了30%。原因是print是同步IO操作会阻塞训练线程。后来改成用logging模块异步写入或者每隔N个step才输出一次速度就恢复了。第二个坑是在__getitem__里做CPU密集型的图像增强。图像增强比如随机裁剪、颜色抖动是CPU密集型的操作如果放在__getitem__里会严重拖慢数据加载速度。我的解决方案是把增强操作移到GPU上做用torchvision.transforms的GPU版本或者用NVIDIA的DALI库。这样数据加载只负责读取原始数据增强在GPU上并行执行速度提升非常明显。第三个坑是多卡训练时没有设置正确的后端。PyTorch的分布式训练支持多种后端nccl是GPU间通信的最优选择但需要手动指定。我一开始用了默认的gloo后端结果多卡训练的加速比只有1.5倍理想情况应该接近线性。改成nccl之后加速比提升到了3.8倍4卡。设置方式很简单import torch.distributed as dist dist.init_process_group(backendnccl)注意nccl后端只支持NVIDIA GPU如果用的是其他硬件需要选择对应的后端。另外init_process_group必须在模型和数据加载器创建之前调用否则会报错。6. 从脚本到工程项目结构与环境管理的经验6.1 配置文件驱动为什么我不再把参数写死在代码里刚开始做项目的时候我把所有超参数都写在训练脚本的开头改一个参数就要重新改代码、重新提交。后来项目多了不同实验的参数混在一起根本记不清哪个模型对应哪组参数。痛定思痛之后我把所有配置抽出来放到YAML文件里用argparse或hydra来加载。配置文件的好处是实验可复现、参数可追溯、不同环境可以共用同一套代码。下面是我常用的配置结构# config/train_config.yaml model: name: resnet50 num_classes: 10 pretrained: true data: feature_path: /data/features.dat meta_path: /data/meta.parquet batch_size: 64 num_workers: 8 training: epochs: 100 learning_rate: 0.001 warmup_steps: 500 weight_decay: 0.0001 inference: max_batch_size: 32 max_wait_ms: 10然后在代码里用OmegaConf或hydra加载import yaml from omegaconf import OmegaConf config OmegaConf.load(config/train_config.yaml) print(config.model.name) # resnet50 print(config.training.learning_rate) # 0.0016.2 日志、指标与模型检查点的管理策略一个可维护的AI工程项目必须有清晰的日志和检查点管理策略。我的做法是每次实验创建一个独立的目录目录名包含时间戳和实验名称日志用logging模块同时输出到控制台和文件指标用tensorboard或wandb记录模型检查点按epoch保存同时保留最佳模型。目录结构大概是这样experiments/ ├── 20240101_120000_resnet50_lr001/ │ ├── config.yaml │ ├── train.log │ ├── checkpoints/ │ │ ├── epoch_10.pt │ │ ├── epoch_20.pt │ │ └── best.pt │ └── tensorboard/ │ └── events.out.tfevents...这样做的好处是任何时候都能找到某个实验的完整信息包括用了什么参数、训练了多久、最终效果如何。我吃过亏的地方是早期没有做实验管理几个月后想复现一个结果发现代码和参数都找不到了只能从头再跑一遍。6.3 依赖版本锁定一次因为numpy版本导致的线上事故Python生态的依赖管理是个老大难问题。我遇到过一次线上事故本地训练好的模型部署到服务器上之后推理结果完全不对。排查了很久才发现本地用的是numpy 1.24服务器上是numpy 1.19两个版本在某些数值计算上的行为不一致导致预处理后的数据分布有细微差异最终影响了模型输出。从那以后我养成了两个习惯第一用requirements.txt锁定所有依赖的精确版本包括间接依赖第二在Docker镜像里构建环境确保开发、测试、生产环境完全一致。生成锁定文件的方式# 导出当前环境的精确版本 pip freeze requirements.txt # 或者用pip-tools生成锁定文件 pip-compile requirements.in -o requirements.txt提示pip freeze会导出所有已安装的包包括那些不是项目直接依赖的。更推荐用pip-tools或poetry来管理依赖它们可以区分直接依赖和间接依赖生成的锁定文件更干净。7. 一些让我少走弯路的实操习惯7.1 小步快跑先用小数据集验证全链路每次开始一个新项目我的第一步永远是用一个极小的数据集比如100条样本把整个链路跑通。从数据加载、模型前向、反向传播、参数更新到推理输出确保每个环节都能正常工作。这样做的好处是如果链路有问题可以快速定位而不是等到用全量数据跑了好几个小时才发现某个环节报错。我见过太多人一上来就用全量数据跑训练结果跑到一半发现数据格式不对、显存不够、或者某个算子不支持白白浪费了大量时间。小数据集验证的成本极低但收益极高。7.2 版本控制模型、数据、代码一个都不能少代码用Git管理是常识但很多人忽略了模型和数据的版本管理。模型文件动辄几百MB不适合直接放在Git仓库里可以用DVC或者Git LFS来管理。数据集的版本管理同样重要因为数据分布的变化会直接影响模型效果。我的做法是代码用Git模型检查点用DVC数据集用DVC加哈希校验。每次实验记录对应的代码commit、模型版本和数据版本确保任何时候都能完整复现。7.3 异常处理让训练脚本在崩溃后能自动恢复训练大模型的时候最怕的就是跑到一半因为各种原因崩溃比如显存溢出、网络中断、磁盘写满。如果每次崩溃都要从头开始那效率就太低了。我的解决方案是在训练脚本里加入检查点恢复机制每隔N个step保存一次完整的训练状态包括模型参数、优化器状态、学习率调度器状态、当前epoch和step崩溃后可以从最近的检查点继续训练。def save_checkpoint(model, optimizer, scheduler, epoch, step, path): torch.save({ model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), scheduler_state_dict: scheduler.state_dict(), epoch: epoch, step: step, }, path) def load_checkpoint(model, optimizer, scheduler, path): checkpoint torch.load(path) model.load_state_dict(checkpoint[model_state_dict]) optimizer.load_state_dict(checkpoint[optimizer_state_dict]) scheduler.load_state_dict(checkpoint[scheduler_state_dict]) return checkpoint[epoch], checkpoint[step]这个机制帮我省下了大量时间。有一次训练一个模型跑了三天在第二天晚上因为磁盘写满崩溃了幸好有检查点恢复后只损失了不到一个小时的进度。7.4 性能监控训练过程中的显存和利用率观察训练过程中我习惯开一个终端用nvidia-smi或者gpustat实时观察GPU的利用率和显存占用。如果发现GPU利用率长期低于50%说明数据加载是瓶颈如果显存占用持续增长说明有内存泄漏如果温度过高说明散热有问题需要降频。gpustat比nvidia-smi更简洁推荐安装pip install gpustat gpustat -i 1 # 每秒刷新一次观察到的典型模式GPU利用率在70%到90%之间波动是正常的因为数据加载和计算交替进行如果利用率一直在30%以下那肯定有问题需要检查数据管道或者batch size是否太小。7.5 代码审查AI项目也需要严格的review流程很多人觉得AI项目就是做实验代码质量不重要。但我经历过一次因为代码bug导致实验结论完全错误的事故一个同事在计算准确率的时候把预测值和标签的顺序搞反了结果得出了一个“模型效果很好”的结论实际上模型完全没学到东西。如果当时有代码审查这个bug一眼就能看出来。从那以后我坚持所有进入主分支的代码都要经过review重点检查数据预处理的逻辑是否正确、损失函数的计算是否符合预期、评估指标的定义是否合理、有没有潜在的数据泄漏。这些检查不需要很深的AI背景但能避免大部分低级错误。8. 关于“从零”这件事的个人体会写了这么多其实核心就一句话从零搭建AI工程链路的价值不在于你省下了多少调包的时间而在于当系统出问题的时候你知道该从哪里下手。调包的人遇到报错只能搜索错误信息而从零搭建过的人能根据错误类型快速定位到是数据管道、模型结构、训练循环还是推理服务的问题。我自己的经验是每当我用一个新的框架或者新的模型架构时都会先花时间把它的核心链路手写一遍。这个过程很慢但收获很大。手写一遍之后再看官方文档和源码很多之前不理解的设计决策就豁然开朗了。而且当你真正理解了每个环节的原理优化起来也会更有方向不会盲目地试各种“技巧”。这个方向还有很多可以深入的地方比如分布式训练的通信优化、模型量化与剪枝、推理服务的自动扩缩容等等。每一个话题都值得单独展开。但不管深入到哪个层面底层的那套从数据到上线的完整链路思维始终是最核心的东西。
返回列表