Skip to content

Git 与版本控制

Git 是软件团队在不互相覆盖彼此工作的前提下进行协作的方式。本文涵盖 Git 的心智模型、分支策略、合并与变基、冲突解决、拉取请求,以及处理大文件和实验跟踪等机器学习特有挑战。

  • 每个正经的软件项目都使用版本控制。Git 是主导系统,几乎所有开源项目和公司都在使用。没有 Git,协作就变成了邮件发送 zip 文件,并祈祷没人覆盖你的更改。有了 Git,每次更改都可追踪、可回滚、可追溯责任。

  • 对于机器学习工程师:Git 跟踪你的代码、配置和实验脚本。结合实验跟踪工具,它能为你提供可复现性:“到底是哪份代码和配置产生了这个模型?”

心智模型

  • Git 跟踪项目的快照。每次提交都是那一刻所有跟踪文件的完整快照,而不是差异(实际上,Git 内部为了效率会存储差异,但概念上每次提交都是一个完整状态)。

  • 文件的四个“位置”:

    1. 工作目录:磁盘上的实际文件。你在这里编辑它们。
    2. 暂存区(索引):你标记为下一次提交要包含的文件。git add 将更改移到这里。
    3. 本地仓库:你的提交历史,存储在 .git/ 中。git commit 将暂存区保存为一个新快照。
    4. 远程仓库(例如 GitHub):共享副本。git push 上传你的提交,git pull 下载他人的提交。
工作目录  →  git add  →  暂存区  →  git commit  →  本地仓库  →  git push  →  远程
                                                      ←  git pull  ←
  • 暂存区是 Git 强大之处。你可以编辑 10 个文件但只提交其中 3 个,将其余更改留到另一个提交。这可以实现干净、聚焦的提交。

基础命令

git init                          # 新建一个仓库
git clone url                     # 下载一个远程仓库
git status                        # 哪些文件发生了变化?(最常用命令)
git add file.py                   # 暂存一个特定文件
git add .                         # 暂存所有更改(谨慎使用)
git commit -m "描述性消息"         # 提交已暂存的更改
git push                          # 上传提交到远程
git pull                          # 下载+合并远程更改
git log --oneline                 # 紧凑的提交历史
git diff                          # 显示未暂存的更改
git diff --staged                 # 显示已暂存的更改

分支

  • 分支是指向一个提交的指针。默认分支是 main(或 master)。创建分支可以让你获得一条独立的开发线:在不影响 main 的情况下进行更改。
git branch feature-x              # 创建分支
git checkout feature-x            # 切换到该分支
git checkout -b feature-x         # 创建并切换一步完成
git branch -d feature-x           # 删除分支(合并后)
git branch -a                     # 列出所有分支(本地+远程)
  • 何时分支:永远要分支。永远不要直接提交到 main。每个功能、错误修复或实验都应该有自己的分支。这能让 main 保持稳定且可部署。

分支策略

  • 功能分支(最常见):每个功能/修复从 main 分出分支。完成后,打开拉取请求(PR)合并回去。简单,适用于大多数团队。

  • 主干开发:开发人员频繁(每天多次)提交到 main,使用功能开关隐藏未完成的工作。持续部署的团队(Google、Facebook)偏爱这种方式。需要优秀的 CI/CD。

  • Gitflow:功能分支、发布分支和热修复分支相互分离。更复杂,更适合有版本化发布的软件(移动应用、打包软件)。对大多数 ML 项目来说过于复杂。

  • 对 ML 团队而言:功能分支加上短生命周期分支(1-3 天内合并)是最佳选择。长生命周期的分支会与 main 发生分歧,产生痛苦的合并冲突。

合并与变基

  • 合并会创建一个新的“合并提交”,将两个分支合并起来:
git checkout main
git merge feature-x
  • 这会保留完整的历史:你可以看到工作曾在一个分支上进行,以及它何时被合并。合并提交有两个父提交。

  • 变基会将你的分支上的提交在目标分支之上重放:

git checkout feature-x
git rebase main
  • 这会重写历史:你的分支上的提交会获得新的哈希值,就好像你从当前 main 的顶端开始你的工作。结果是线性的历史(没有合并提交),阅读更清晰。

  • 何时使用哪种

    • 变基用于将你特性分支更新为最新的 main(保持分支干净且最新)。
    • 合并用于将你的特性分支集成到 main 中(保留分支历史)。
    • 永远不要变基那些已经推送并共享的提交。变重重写历史;如果其他人已经基于原始提交进行了工作,变基会导致混乱。

解决冲突

  • 当两个分支修改了同一个文件的同一行代码时,就会发生冲突。Git 无法自动决定保留哪个更改,因此要求你手动解决。
<<<<<<< HEAD
learning_rate = 0.001
=======
learning_rate = 0.0005
>>>>>>> feature-x
  • <<<<<<< HEAD======= 之间是当前分支的版本。=======>>>>>>> feature-x 之间是传入分支的版本。你需要决定保留哪个(或组合它们),删除标记,保存,然后 git add 已解决的文件。

  • 陷阱:不要将冲突标记留在已提交的文件中。它们是字面文本,会破坏你的代码。解决后始终搜索 <<<<<<<

  • 减少冲突:保持分支短生命周期,经常将 main 合并到你的分支,避免多人同时编辑同一个文件。

编写良好的提交信息

  • 提交信息是为未来的你和你的队友准备的。“修复 bug”告诉你任何信息。“修复批大小计算中的差一错误,该错误导致 8-GPU 训练时内存不足”则告诉你一切。

  • 格式

简短的总结(50 个字符以内,使用祈使语气)

如果需要更长的描述。解释为什么(WHY),而不是什么(WHAT)
(差异已经显示了什么改变)。每行 72 个字符左右。

Fixes #123
  • 祈使语气:“Add feature”而不是“Added feature”或“Adds feature”。将其读作补全句子:“如果应用这个提交,它将 add feature。”

  • 原子提交:每个提交应该只做一件事。“Add data loader”是一个提交。“Add data loader and fix unrelated bug and update README”应该是三个提交。这使得 git bisect(查找哪个提交引入了 bug)成为可能。

拉取请求与代码审查

  • 拉取请求(PR) 提议将一个分支合并到 main 中。它是代码审查的门户:队友阅读你的更改,提出改进建议,并在合并之前批准。

  • 良好的 PR 实践

    • 保持 PR 小(更改少于 400 行)。大的 PR 只会被走过场式批准,因为没人想审查 2000 行代码。
    • 写清晰的描述:改变了什么,为什么改变,以及如何测试。
    • 关联到促使这次更改的问题或工单。
    • 及时回应审查评论。
    • 在合并之前压缩琐碎的提交(这样 main 就有干净的历史)。
  • 代码审查的目的不是找 bug(那是测试的工作)。它的目的是:知识共享(审查者学习代码库)、设计反馈(这是正确的方法吗?)、以及维护标准(命名、风格、架构)。

.gitignore

  • .gitignore 文件告诉 Git 忽略哪些文件,不进行跟踪。对于 ML 项目:
# Python
__pycache__/
*.pyc
*.egg-info/
.venv/
env/

# 数据和模型(对于 git 来说太大)
data/
*.csv
*.parquet
models/
*.pt
*.onnx
*.bin
checkpoints/

# 密钥
.env
*.pem
credentials.json

# IDE
.vscode/
.idea/
*.swp

# 操作系统
.DS_Store
Thumbs.db

# Jupyter
.ipynb_checkpoints/

# 实验输出
wandb/
mlruns/
outputs/
logs/
  • 陷阱:在文件已经被提交之后才将它添加到 .gitignore,这并不会将其从仓库中移除。你还必须执行 git rm --cached file 来停止跟踪。除非你重写历史(这很麻烦),否则该文件将永远留在历史中。

ML 场景下的 Git

  • 机器学习引入了传统软件未曾面对的挑战:

  • 大文件:数据集和模型权重可达几 GB 甚至更大。Git 是为文本文件(源代码)设计的,而不是二进制大对象。解决方案:

    • Git LFS(大文件存储):在 Git 中跟踪指针,实际文件存储在单独的服务器上。简单,但在 GitHub 上有存储/带宽限制。
    • DVC(数据版本控制):将数据和模型文件与 Git 分开管理,使用远程存储(S3、GCS)。像 Git 处理数据一样工作:dvc add data.csvdvc pushdvc pull
  • 实验跟踪:哪个提交 + 哪些超参数 + 哪个数据 → 产生了哪些指标?Git 跟踪代码,但不跟踪完整的实验上下文。

    • Weights & Biases(W&B):记录指标、超参数、系统信息,并链接到 Git 提交。提供用于比较运行的仪表板。
    • MLflow:开源实验跟踪,带有模型注册表。记录参数、指标和产物。
    • 简单方法:在训练脚本中记录 Git 哈希:git_hash = subprocess.check_output(['git', 'rev-parse', 'HEAD']).strip()。随结果一起保存。
  • 可复现性检查清单(每个实验应跟踪的内容):

    • Git 提交哈希(精确的代码版本)
    • 配置文件 / 超参数
    • 随机种子
    • Python 和库版本(pip freeze
    • 数据版本(DVC 哈希或数据集版本标签)
    • 硬件(GPU 类型、GPU 数量)
# 快速获取可复现性快照
echo "Commit: $(git rev-parse HEAD)" > experiment_info.txt
echo "Branch: $(git branch --show-current)" >> experiment_info.txt
echo "Dirty: $(git status --porcelain | wc -l) files" >> experiment_info.txt
pip freeze >> experiment_info.txt
nvidia-smi >> experiment_info.txt