数据截至 (上游 commit bd2a0fc7c314)
01 · recipe 与配置系统
这一章讲什么: torchtune 最与众不同的两件事:① recipe(训练配方)的组织哲学——脚本即文档、禁止继承;② 配置系统——一份 YAML 如何通过
_component_点路径被递归装配成模型、优化器、数据集。读完你会知道tune run ... --config llama3_1/8B_full这行命令背后发生了什么。
1. 它要解决的小问题
微调框架通常长成一个「Trainer 类 + 几十个子类 + 回调钩子」的继承树。想改一个行为,你得先搞清楚该 override 哪一层。
torchtune 的作者们反过来问:能不能让每个训练配 方就是一份从上到下读完的脚本? 难点在于:不抽象,十几个 recipe 之间会有大量重复代码;抽象了,又走回继承树的老路。
2. 思路:复制是特性,不是坏味道
torchtune 的选择是容忍重复、拒绝继承。这条纪律直接写进接口契约里——FTRecipeInterface 的 docstring(torchtune/recipe_interfaces.py:10-22)有三句关键的话:
- 「torchtune strictly prohibits implementation inheritance in the codebase」——代码库里严禁实现继承。
- 「Minimizing code duplication is not the goal. Recipe-writers are encouraged to copy-paste-modify.」——最小化重复不是目标,鼓励复制-粘贴-修改。
- 接口只约束方法名(
setup/train/save_checkpoint/cleanup),不承载任何共享实现。
配套的工作流也围绕「复制」设计:tune cp 子命令(torchtune/_cli/cp.py:18 的 Copy)存在的全部意义就是把内置 recipe 或 config 拷到你的本地目录让你改。复制粘贴从「坏实践」升格为一等公民。
代价与收益的权衡很直白:重复代码确实存在(比如各 recipe 的 _setup_profiler 几乎一样),但每份脚本都是自包含的答案——读 lora_finetune_single_device.py 不需要先读另外三个基类。
3. tune CLI:从命令行到 recipe 进程
3.1 入口与子命令
安装包在 pyproject.toml:48-49 注册了 tune = "torchtune._cli.tune:main"。TuneCLIParser(torchtune/_cli/tune.py:18)挂六个子命令:
| 子命令 | 干什么 |
|---|---|
tune run | 跑一个 recipe(单进程直接跑,多进程走 torchrun) |
tune ls | 列出全部内置 recipe 及其配套 config |
tune cp | 把内置 recipe/config 拷到本地(改造的起点) |
tune download | 从 HF Hub / Meta 下载模型权重 |
tune cat | 打印某个内置 config 的内容 |
tune validate | 校验一份 config 能被正确解析 |
3.2 注册表:recipe 名怎么找到文件
内置配方登记在 _ALL_RECIPES(torchtune/_recipe_registry.py:24)这个纯数据列表里,每条是一个 Recipe dataclass(torchtune/_recipe_registry.py:17):
Recipe(name="full_finetune_single_device",
file_path="full_finetune_single_device.py",
configs=[Config("llama3_2/1B_full_single_device", ...), ...],
supports_distributed=True)
get_all_recipes()(torchtune/_recipe_registry.py:701)返回这份列表,tune ls 和 tune run 都靠它解析名字。注册表是数据而非插件机制——加一个内置 recipe 就是往列表里加一条。
3.3 tune run 的两条路径
Run._run_cmd(torchtune/_cli/run.py:168)的分发逻辑:
tune run [--nproc_per_node N ...] <recipe> --config <config> [k=v ...]
│
▼
recipe 是内置名? ──否──► 当作本地文件/模块(自定义 recipe)
│是
▼
查注册表拿到绝对路径 + supports_distributed
│
带了 torchrun 参数?(_is_distributed_args)
│ │
是 否
▼ ▼
_run_distributed _run_single_device
(torchrun 语义) (runpy 直接跑)
两个实现细节值得记住:
- torchrun 参数是「偷」来的。
_add_arguments(torchtune/_cli/run.py:61)直接遍历torch.distributed.run自己的 argparser actions,把--nproc_per_node等参数原样嫁接到tune run上,只把training_script改名为recipe。所 以 torchrun 新增参数时tune run自动跟进。 - 单机路径用
runpy。_run_single_device(torchtune/_cli/run.py:103)把 recipe 文件用runpy.run_path(..., run_name="__main__")在当前进程里跑起来;分布式路径_run_distributed(torchtune/_cli/run.py:83)则把参数回填后调 torchrun 的run(args),且没指定 rendezvous 时强制standalone=True,让多个训练任务可以并行起跑。
自定义 recipe 不在注册表里也能跑:传文件路径即可,_convert_to_dotpath 把它转成模块点路径用 python -m 语义执行(torchtune/_cli/run.py:146-157)。
4. @config.parse:YAML 与命令行怎么合成一棵树
每个 recipe 的入口都长一个样(recipes/full_finetune_distributed.py:1152-1165):
@config.parse
def recipe_main(cfg: DictConfig) -> None:
config.log_config(recipe_name="FullFinetuneRecipeDistributed", cfg=cfg)
recipe = FullFinetuneRecipeDistributed(cfg=cfg)
recipe.setup(cfg=cfg)
recipe.train()
recipe.cleanup()
@config.parse(torchtune/config/_parse.py:68)这个装饰器做三件事:
- 用
TuneRecipeArgumentParser(torchtune/config/_parse.py:20)解析。它是 argparse 的子类,内置一个必填的--config参数;parse_known_args(torchtune/config/_parse.py:43)先正常解析拿到 yaml 路径,然后把 yaml 内容设为 argparse 的默认值再解析一遍(torchtune/config/_parse.py:58-64)——这样命令行参数天然覆盖 yaml 值。 - 用
_merge_yaml_and_cli_args(torchtune/config/_utils.py:121)把结果与key=value点路径覆盖(model.lora_rank=16)合并成一棵DictConfig。OmegaConf 的 dotlist 语法让a.b.c=v直接写到嵌套层级。 - 调
recipe_main(conf)并sys.exit(torchtune/config/_parse.py:99)。
这个合并器有三个贴心的小设计:
| 语法 | 行为 | 位置 |
|---|---|---|
model=torchtune.models.lora_llama2_7b | 若覆盖目标带 _component_,自动改写成 model._component_=... | torchtune/config/_utils.py:182-184 |
~metric_logger | ~ 前缀表示从 yaml 里删除这个键(组件本身不可删) | torchtune/config/_utils.py:155-160 |
x=None | 字符串 "None" 转成 OmegaConf 的 null | torchtune/config/_utils.py:186-188 |
坑: 命令行不接受任何额外的 --flag(只允许 --config 和 k=v),否则直接 ValueError(torchtune/config/_parse.py:52-56)。这是故意的——recipe 的可配置面完全由 yaml schema 定义,不允许脚本作者私加 flag。
5. _component_ + instantiate:配置即装配图
这是整个库的心脏机制。规则只有一条:任何含 _component_ 键的字典,都会被替换成「该键指向的可调用对象、以其余键为 kwargs 调用后」的返回值。