已合并
【课程社区共建】新增大语言模型微调实战课程框架 #69
【课程社区共建】新增大语言模型微调实战课程框架 #69
已合并
pescn创建于 5月12日
共 10 个文件变更+1435-0
@@ -0,0 +1,26 @@
1+<div align="center" style="border: 1px solid #eee; padding: 20px; border-radius: 10px; margin-bottom: 20px; max-width: 320px; margin-left: auto; margin-right: auto;">
2+ 
3+![SwanLab](../images/swanlab.png)
4+ 
5+SwanLab x CANN 社区合作课程
6+ 
7+</div>
8+ 
9+---
10+ 
11+# 第 01 章 · 大语言模型基础理论介绍
12+ 
13+## 章节定位
14+ 
15+本章面向**完全没有大模型背景**的同学,建立后续 02-05 章所需的基础知识与术语体系。
16+预期读者在学完本章后,能看懂后续章节里出现的 token、Transformer、self-attention、KV cache、SFT、LoRA、RLHF 等术语,知道它们大致在解决什么问题。
17+ 
18+## 计划节次
19+ 
20+| 节次 | 标题(暂定) | 状态 |
21+|------|---------------|------|
22+| 01.01 | 章节简介与学习路径 | 建设中 |
23+| 01.02 | 从词向量到 Transformer | 建设中 |
24+| 01.03 | 自注意力机制与位置编码 | 建设中 |
25+| 01.04 | 预训练、SFT、RLHF 三段式流程 | 建设中 |
26+| 01.05 | 推理过程与 KV cache | 建设中 |
@@ -0,0 +1,544 @@
1+{
2+ "cells": [
3+ {
4+ "cell_type": "markdown",
5+ "id": "16cfc8a9-8593-4b8e-8d8b-4433c547bf8f",
6+ "metadata": {},
7+ "source": [
8+ "# Qwen3 大模型指令微调实战——通过训练,让模型具备问答聊天的能力\n",
9+ "\n",
10+ "以Qwen3作为基座大模型,通过参数微调的方式,实现垂直专业领域聊天,甚至支持DeepSeek R1 / QwQ式的带推理过程的对话,是学习LLM微调的入门任务。\n",
11+ "\n",
12+ "## 使用Qwen进行微调\n",
13+ "\n",
14+ "要想将Qwen3完成微调,我们要进行三个步骤:\n",
15+ "\n",
16+ "1. 将模型加载进 NPU\n",
17+ "2. 下载数据集并调整成正确格式\n",
18+ "3. 运行模型训练\n"
19+ ]
20+ },
21+ {
22+ "cell_type": "markdown",
23+ "id": "52258740",
24+ "metadata": {},
25+ "source": [
26+ "## 环境准备\n",
27+ "\n",
28+ "本 Notebook 在 CANNLab 的 Ascend NPU 环境下运行。CANNLab 默认镜像已预装 `torch` 与 `torch_npu`,下方 cell 仅安装训练所需的上层依赖。\n",
29+ "\n",
30+ "> **首次运行**:直接执行下方 cell 即可,安装完成后无需重启 kernel。\n",
31+ ">\n",
32+ "> **重跑场景**:如果环境里已经残留过旧版 `transformers` / `trl` 且本 Notebook 之前 import 过它们,安装后请点 `Kernel → Restart`,否则新版本不会生效。"
33+ ]
34+ },
35+ {
36+ "cell_type": "code",
37+ "execution_count": null,
38+ "id": "e7ad9c46",
39+ "metadata": {},
40+ "outputs": [],
41+ "source": [
42+ "# 1. 安装训练所需依赖\n",
43+ "%pip install -q -i https://pypi.tuna.tsinghua.edu.cn/simple modelscope==1.35.4 transformers==5.5.4 trl==1.2.0 datasets==4.8.4 swanlab==0.7.15 accelerate==1.13.0\n",
44+ "\n",
45+ "# 2. 注册 Ascend NPU 后端:torch_npu 由 CANNLab 镜像预装,只需 import 即可让 PyTorch 识别 NPU\n",
46+ "import torch\n",
47+ "import torch_npu # noqa: F401\n",
48+ "\n",
49+ "print(f\"torch: {torch.__version__}\")\n",
50+ "print(f\"NPU 可用: {torch.npu.is_available()}, 卡数: {torch.npu.device_count()}\")"
51+ ]
52+ },
53+ {
54+ "cell_type": "markdown",
55+ "id": "b779ced8-a6c8-4062-8edb-a0c4245f51a4",
56+ "metadata": {},
57+ "source": [
58+ "## 下载并加载基座模型\n",
59+ "\n",
60+ "首先我们需要从 ModelScope 中下载基座模型 Qwen3-0.6B-Base"
61+ ]
62+ },
63+ {
64+ "cell_type": "code",
65+ "execution_count": null,
66+ "id": "49129e5c-7bd2-4eb8-81ad-de38e25a0923",
67+ "metadata": {
68+ "scrolled": true,
69+ "tags": []
70+ },
71+ "outputs": [],
72+ "source": [
73+ "!modelscope download --model Qwen/Qwen3-0.6B-Base --local_dir ./Qwen3-0.6B-Base"
74+ ]
75+ },
76+ {
77+ "cell_type": "markdown",
78+ "id": "b1df7107-87ab-465b-8071-b5d4b5f5e23a",
79+ "metadata": {},
80+ "source": [
81+ "接下来,我们可以将下载好的模型加载到 NPU 的显存中,同时,我们可以观察到 HBM 占用率的上升"
82+ ]
83+ },
84+ {
85+ "cell_type": "code",
86+ "execution_count": null,
87+ "id": "b6b275a0-e8da-4107-a6db-a4e748299262",
88+ "metadata": {},
89+ "outputs": [],
90+ "source": [
91+ "from transformers import AutoTokenizer, AutoModelForCausalLM\n",
92+ "\n",
93+ "MODEL_PATH=\"./Qwen3-0.6B-Base\" # 模型路径\n",
94+ "\n",
95+ "tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH)\n",
96+ "model = AutoModelForCausalLM.from_pretrained(\n",
97+ " MODEL_PATH,\n",
98+ " torch_dtype=\"auto\",\n",
99+ " device_map=\"auto\"\n",
100+ ")"
101+ ]
102+ },
103+ {
104+ "cell_type": "markdown",
105+ "id": "3c3e2b45-2274-4224-a287-7a9a618b3740",
106+ "metadata": {},
107+ "source": [
108+ "使用普通文本测试一下模型是否成功加载了"
109+ ]
110+ },
111+ {
112+ "cell_type": "code",
113+ "execution_count": null,
114+ "id": "d1e961c8-69e1-4596-b300-7b7fe8562bd9",
115+ "metadata": {},
116+ "outputs": [],
117+ "source": [
118+ "\n",
119+ "# 输入的文本⬇️\n",
120+ "text=\"Python 是一种\" # 普通文本——Base 模型擅长续写陈述性内容\n",
121+ "\n",
122+ "# 模型推理代码⬇️\n",
123+ "model_inputs = tokenizer([text], return_tensors=\"pt\").to(model.device)\n",
124+ "generation_ids = model.generate(**model_inputs,max_new_tokens=128)\n",
125+ "content = tokenizer.decode(generation_ids[0], skip_special_tokens=True)\n",
126+ "print(content)\n"
127+ ]
128+ },
129+ {
130+ "cell_type": "markdown",
131+ "id": "b1600802-2498-48d2-98e8-e0047f9f481a",
132+ "metadata": {},
133+ "source": [
134+ "使用聊天文本测试模型能力\n",
135+ "\n",
136+ "> ⚠️ **预期结果会很怪**:当前模型是 `Qwen3-0.6B-Base`,**没有经过指令微调**,不知道如何按对话格式回答。下面这个 cell 的输出多半会是杂乱的续写或重复文字——这正是接下来我们要做 SFT 训练的原因。"
137+ ]
138+ },
139+ {
140+ "cell_type": "code",
141+ "execution_count": null,
142+ "id": "abc289bf-15c1-464f-bf9e-0ec76d74586c",
143+ "metadata": {},
144+ "outputs": [],
145+ "source": [
146+ "# 输入聊天文本⬇️\n",
147+ "messages = [\n",
148+ " {\"role\": \"user\", \"content\": \"什么是机器学习?\"},\n",
149+ "]\n",
150+ "\n",
151+ "# 模型推理代码⬇️\n",
152+ "text=tokenizer.apply_chat_template(messages,tokenize=False,add_generation_prompt=True,enable_thinking=False)\n",
153+ "model_inputs = tokenizer([text], return_tensors=\"pt\").to(model.device)\n",
154+ "generation_ids = model.generate(**model_inputs,max_new_tokens=128)\n",
155+ "content = tokenizer.decode(generation_ids[0], skip_special_tokens=True)\n",
156+ "print(content)"
157+ ]
158+ },
159+ {
160+ "cell_type": "markdown",
161+ "id": "5cce8b14-c1e4-4dbe-8eee-9985f4b489fc",
162+ "metadata": {},
163+ "source": [
164+ "---\n",
165+ "\n",
166+ "## 加载我们要训练的数据集\n",
167+ "\n",
168+ "Alpaca_zh 是面向中文场景的指令监督微调数据集,基于斯坦福大学 Alpaca 项目的核心结构设计,通过本地化改造适配中文语言习惯与任务需求。该数据集遵循 Alpaca 格式标准,每条数据包含必填的 instruction(任务指令)和 output(模型期望回答),以及可选的 input(任务输入)与 history(多轮对话历史)字段,其核心价值在于通过 指令-输入-输出 的强关联结构,优化大模型对中文任务的理解与执行能力。数据源包含从 Alpaca-3.5-en 翻译的 52K 指令样本,并融合中文社区优化的问答对,覆盖翻译、推理、摘要等场景。\n",
169+ "\n",
170+ "数据集下载地址:\n",
171+ "\n",
172+ "* Aplaca_zh数据集下载链接:https://www.modelscope.cn/datasets/llamafactory/alpaca_zh/summary"
173+ ]
174+ },
175+ {
176+ "cell_type": "markdown",
177+ "id": "742d9f3f-12ce-4ed0-a4a3-7d65b9acbf20",
178+ "metadata": {},
179+ "source": [
180+ "下载数据集命令"
181+ ]
182+ },
183+ {
184+ "cell_type": "code",
185+ "execution_count": null,
186+ "id": "07b03ea5-f8b7-418f-9734-63455dce201b",
187+ "metadata": {
188+ "scrolled": true
189+ },
190+ "outputs": [],
191+ "source": [
192+ "!modelscope download --dataset llamafactory/alpaca_zh --local_dir ./alpaca_zh"
193+ ]
194+ },
195+ {
196+ "cell_type": "markdown",
197+ "id": "6852c507-2dfc-4899-83e8-21ce39e5471e",
198+ "metadata": {},
199+ "source": [
200+ "### 读取数据集"
201+ ]
202+ },
203+ {
204+ "cell_type": "code",
205+ "execution_count": null,
206+ "id": "cd4dae53-6526-4a52-94fb-5092ab320cc2",
207+ "metadata": {},
208+ "outputs": [],
209+ "source": [
210+ "import datasets\n",
211+ "\n",
212+ "alpaca_data = datasets.load_dataset(\"json\", data_files=\"alpaca_zh/alpaca_data_zh_51k.json\")\n",
213+ "\n",
214+ "alpaca_data=alpaca_data[\"train\"]\n",
215+ "print(alpaca_data)"
216+ ]
217+ },
218+ {
219+ "cell_type": "markdown",
220+ "id": "f313670d-411d-49e4-9e43-b21b66a056d4",
221+ "metadata": {},
222+ "source": [
223+ "展示一条数据"
224+ ]
225+ },
226+ {
227+ "cell_type": "code",
228+ "execution_count": null,
229+ "id": "dde7deac-6943-44bd-9ae5-cc78d0eeb8c0",
230+ "metadata": {},
231+ "outputs": [],
232+ "source": [
233+ "print(alpaca_data[0][\"instruction\"])\n",
234+ "print(alpaca_data[0][\"input\"])\n",
235+ "print(alpaca_data[0][\"output\"])"
236+ ]
237+ },
238+ {
239+ "cell_type": "markdown",
240+ "id": "0f4dfe4a-c925-4d31-a4f9-7c1afc8f6f1b",
241+ "metadata": {},
242+ "source": [
243+ "## 整理数据格式"
244+ ]
245+ },
246+ {
247+ "cell_type": "markdown",
248+ "id": "d356e959-f0bb-47d4-a9e2-9ec9b80b88ab",
249+ "metadata": {},
250+ "source": [
251+ "将数据集拼接成我们之前模型能接受的聊天格式"
252+ ]
253+ },
254+ {
255+ "cell_type": "code",
256+ "execution_count": null,
257+ "id": "7e67964c-d26d-4736-8ca6-5a43ecd7d5c1",
258+ "metadata": {},
259+ "outputs": [],
260+ "source": [
261+ "example=alpaca_data[0]\n",
262+ "\n",
263+ "user_text = example[\"instruction\"]+example[\"input\"]\n",
264+ "assistant_text = example[\"output\"]\n",
265+ "messages = [\n",
266+ " {\"role\": \"user\", \"content\": user_text},\n",
267+ " {\"role\": \"assistant\", \"content\": assistant_text},\n",
268+ "]\n",
269+ "text=tokenizer.apply_chat_template(messages,tokenize=False,add_generation_prompt=False)\n",
270+ "print(text)"
271+ ]
272+ },
273+ {
274+ "cell_type": "markdown",
275+ "id": "13e4c34b-9e0d-4593-9287-ec87f6236eb8",
276+ "metadata": {},
277+ "source": [
278+ "#### 通过函数对数据集进行批量处理"
279+ ]
280+ },
281+ {
282+ "cell_type": "code",
283+ "execution_count": null,
284+ "id": "933ed965-cde4-4b4b-af36-5b5f80cd0683",
285+ "metadata": {},
286+ "outputs": [],
287+ "source": [
288+ "def format_data(example):\n",
289+ " user_text = example[\"instruction\"]+example[\"input\"]\n",
290+ " assistant_text = example[\"output\"]\n",
291+ " messages = [\n",
292+ " {\"role\": \"user\", \"content\": user_text},\n",
293+ " {\"role\": \"assistant\", \"content\": assistant_text},\n",
294+ " ]\n",
295+ " text=tokenizer.apply_chat_template(messages,tokenize=False,add_generation_prompt=False,enable_thinking=False)\n",
296+ " return {\"text\":text}\n",
297+ "\n",
298+ "format_alpaca_data=alpaca_data.map(format_data,remove_columns=alpaca_data.column_names)"
299+ ]
300+ },
301+ {
302+ "cell_type": "code",
303+ "execution_count": null,
304+ "id": "3d0004cc-7fe7-498c-9ba2-2c65ba529f65",
305+ "metadata": {},
306+ "outputs": [],
307+ "source": [
308+ "print(format_alpaca_data[0][\"text\"])"
309+ ]
310+ },
311+ {
312+ "cell_type": "markdown",
313+ "id": "8a438ff3-0142-458c-8c4d-a071803628ce",
314+ "metadata": {},
315+ "source": [
316+ "### 切分数据集"
317+ ]
318+ },
319+ {
320+ "cell_type": "code",
321+ "execution_count": null,
322+ "id": "9673b01d-8ab5-48af-847f-1340f83dccdf",
323+ "metadata": {},
324+ "outputs": [],
325+ "source": [
326+ "train_test_data=format_alpaca_data.train_test_split(0.01)\n",
327+ "train_data = train_test_data[\"train\"]\n",
328+ "test_data = train_test_data[\"test\"]\n",
329+ "\n",
330+ "print(\"=====训练集数量\")\n",
331+ "print(train_data)\n",
332+ "print(\"=====测试集数量\")\n",
333+ "print(test_data)"
334+ ]
335+ },
336+ {
337+ "cell_type": "markdown",
338+ "id": "1a2b6e49-4fa1-40bc-8b91-139395c740c1",
339+ "metadata": {},
340+ "source": [
341+ "不使用lora微调⬇️ "
342+ ]
343+ },
344+ {
345+ "cell_type": "markdown",
346+ "id": "e18907f1-d439-4f45-93c6-f7690122e3b5",
347+ "metadata": {},
348+ "source": [
349+ "---\n",
350+ "\n",
351+ "## 开始训练"
352+ ]
353+ },
354+ {
355+ "cell_type": "markdown",
356+ "id": "462e0726-d1f2-4115-a6d2-1294ae04107c",
357+ "metadata": {},
358+ "source": [
359+ "### 构建训练参数"
360+ ]
361+ },
362+ {
363+ "cell_type": "markdown",
364+ "id": "670bddb5",
365+ "metadata": {},
366+ "source": [
367+ "注意下方代码运行后,会在页面上方有一个输入框的弹窗出现,请在这里输入 SwanLab 的 API Key,然后按回车提交。可以从 https://swanlab.cn/settings 获取。如果不输入则会卡在这里无法继续运行。"
368+ ]
369+ },
370+ {
371+ "cell_type": "code",
372+ "execution_count": null,
373+ "id": "de6cd272-9c91-4936-b5d3-4a2ce7d55ef3",
374+ "metadata": {},
375+ "outputs": [],
376+ "source": [
377+ "import getpass\n",
378+ "import swanlab\n",
379+ "from pathlib import Path\n",
380+ "from trl import SFTConfig, SFTTrainer\n",
381+ "\n",
382+ "# ====== SwanLab 项目配置 ======\n",
383+ "# 下方的SWANLAB_PROJ请勿修改!\n",
384+ "%env SWANLAB_PROJ=CANN_SwanLab\n",
385+ "\n",
386+ "# 将下方的 SWANLAB_EXP_NAME 改成自己的名字,方便看出哪个实验是自己的!!支持中文\n",
387+ "%env SWANLAB_EXP_NAME=Qwen3-Finetune-Instruction\n",
388+ "\n",
389+ "\n",
390+ "# ====== SwanLab 登录(首次运行弹出安全输入框,凭证保存后下次自动复用) ======\n",
391+ "def _swanlab_credential_saved() -> bool:\n",
392+ " \"\"\"检查本地是否已有 SwanLab 凭证(默认存储在 ~/.netrc)\"\"\"\n",
393+ " netrc = Path.home() / \".netrc\"\n",
394+ " if not netrc.exists():\n",
395+ " return False\n",
396+ " try:\n",
397+ " return \"swanlab.cn\" in netrc.read_text()\n",
398+ " except Exception:\n",
399+ " return False\n",
400+ "\n",
401+ "\n",
402+ "if _swanlab_credential_saved():\n",
403+ " print(\"检测到本地已有 SwanLab 凭证,跳过登录。\")\n",
404+ "else:\n",
405+ " api_key = getpass.getpass(\"请输入你的 SwanLab API Key(从 https://swanlab.cn/settings 获取): \")\n",
406+ " swanlab.login(api_key=api_key, save=True)\n",
407+ " print(\"SwanLab 登录成功!凭证已保存到 ~/.netrc,下次运行无需再输入。\")\n",
408+ "\n",
409+ "\n",
410+ "sft_config = SFTConfig(\n",
411+ " output_dir = \"./sft_qwen\", # 保存位置\n",
412+ " max_steps=1000, # 训练步长\n",
413+ " per_device_train_batch_size=4, # 批量大小\n",
414+ " learning_rate = 0.00001, # 学习率lr\n",
415+ " logging_steps=1, # 打印日志的频率\n",
416+ " eval_strategy=\"steps\", # 开启评估\n",
417+ " eval_steps=0.2, # 评估次数\n",
418+ " save_total_limit=2, # 设置最多保存的权重数量\n",
419+ " report_to=\"swanlab\" # 将日志报告给swanlab\n",
420+ ")"
421+ ]
422+ },
423+ {
424+ "cell_type": "markdown",
425+ "id": "89a76937-ff54-43e2-8dd0-994ccacef5db",
426+ "metadata": {},
427+ "source": [
428+ "### 开启训练\n",
429+ "\n",
430+ "> ⏳ **训练耗时较长**(默认 1000 步约 30-60 分钟)。**点击运行后等当前 cell 跑完再点下一个 cell**,连续点会重复造成异常。SwanLab 训练曲线可在 [swanlab.cn](https://swanlab.cn) 实时查看。"
431+ ]
432+ },
433+ {
434+ "cell_type": "code",
435+ "execution_count": null,
436+ "id": "02878430-0408-485b-94e8-a2a4b8cf11f7",
437+ "metadata": {
438+ "tags": []
439+ },
440+ "outputs": [],
441+ "source": [
442+ "trainer = SFTTrainer(\n",
443+ " model=model,\n",
444+ " processing_class=tokenizer,\n",
445+ " args=sft_config,\n",
446+ " data_collator=None,\n",
447+ " train_dataset=train_data,\n",
448+ " eval_dataset=test_data,\n",
449+ " \n",
450+ ")\n",
451+ "trainer.train()\n",
452+ "swanlab.finish()"
453+ ]
454+ },
455+ {
456+ "cell_type": "markdown",
457+ "id": "19b0634b-5586-4b50-a33f-878fbedef097",
458+ "metadata": {},
459+ "source": [
460+ "---\n",
461+ "\n",
462+ "## 保存模型"
463+ ]
464+ },
465+ {
466+ "cell_type": "code",
467+ "execution_count": null,
468+ "id": "dc598c9e-2519-47b5-a410-20b9b1e496af",
469+ "metadata": {},
470+ "outputs": [],
471+ "source": [
472+ "model.save_pretrained(sft_config.output_dir, safe_serialization=True)\n",
473+ "tokenizer.save_pretrained(sft_config.output_dir)"
474+ ]
475+ },
476+ {
477+ "cell_type": "markdown",
478+ "id": "6be412eb-96b8-4d76-b959-a7f6ec7abd61",
479+ "metadata": {},
480+ "source": [
481+ "---\n",
482+ "\n",
483+ "## 推理训练好的模型"
484+ ]
485+ },
486+ {
487+ "cell_type": "code",
488+ "execution_count": null,
489+ "id": "965f50ab-7982-47ed-89da-c84e901ed2f9",
490+ "metadata": {},
491+ "outputs": [],
492+ "source": [
493+ "your_model_path=\"./sft_qwen\"\n",
494+ "\n",
495+ "tokenizer = AutoTokenizer.from_pretrained(your_model_path)\n",
496+ "sft_model = AutoModelForCausalLM.from_pretrained(\n",
497+ " your_model_path,\n",
498+ " torch_dtype=\"auto\",\n",
499+ " device_map=\"auto\"\n",
500+ ")"
501+ ]
502+ },
503+ {
504+ "cell_type": "code",
505+ "execution_count": null,
506+ "id": "a87d5c37-ebca-4639-afe0-ce05ba3271fa",
507+ "metadata": {},
508+ "outputs": [],
509+ "source": [
510+ "messages = [\n",
511+ " {\"role\": \"user\", \"content\": \"你好\"},\n",
512+ "]\n",
513+ "text=tokenizer.apply_chat_template(messages,tokenize=False,add_generation_prompt=True,enable_thinking=False)\n",
514+ "model_inputs = tokenizer([text], return_tensors=\"pt\").to(model.device)\n",
515+ "generation_ids = sft_model.generate(**model_inputs, max_new_tokens=128, eos_token_id=tokenizer.eos_token_id)\n",
516+ "content = tokenizer.decode(\n",
517+ " generation_ids[0], skip_special_tokens=True\n",
518+ ")\n",
519+ "print(content)"
520+ ]
521+ }
522+ ],
523+ "metadata": {
524+ "kernelspec": {
525+ "display_name": "base",
526+ "language": "python",
527+ "name": "python3"
528+ },
529+ "language_info": {
530+ "codemirror_mode": {
531+ "name": "ipython",
532+ "version": 3
533+ },
534+ "file_extension": ".py",
535+ "mimetype": "text/x-python",
536+ "name": "python",
537+ "nbconvert_exporter": "python",
538+ "pygments_lexer": "ipython3",
539+ "version": "3.12.2"
540+ }
541+ },
542+ "nbformat": 4,
543+ "nbformat_minor": 5
544+}
@@ -0,0 +1,30 @@
1+<div align="center" style="border: 1px solid #eee; padding: 20px; border-radius: 10px; margin-bottom: 20px; max-width: 320px; margin-left: auto; margin-right: auto;">
2+ 
3+![SwanLab](../images/swanlab.png)
4+ 
5+SwanLab x CANN 社区合作课程
6+ 
7+</div>
8+ 
9+---
10+ 
11+# 第 02 章 · 大语言模型微调
12+ 
13+## 章节定位
14+ 
15+本章围绕 Qwen3 系列模型,按"**原理 → 数据 / 模板 → SFT → LoRA → 工程实践**"的顺序,完整跑通一次端到端微调流程。
16+预期读者在学完本章后,能理解 Chat Template、Special Token、Loss Mask、LoRA 低秩适配、超参数选择、效果评估等工程细节,并能在昇腾 NPU 上独立完成一次面向自己业务数据的指令微调,而不只是停留在套用 LLaMA Factory 等工具。
17+ 
18+## 计划节次
19+ 
20+| 节次 | 标题 | 状态 |
21+|------|------|------|
22+| 02.01 | 章节介绍 | 建设中 |
23+| 02.02 | 微调与 LoRA 原理 | 建设中 |
24+| 02.03 | Chat Template 与 Special Token | 建设中 |
25+| [02.04](./02.04_qwen3_instruction_sft.ipynb) | Qwen3 基座模型指令微调(SFT) | 已发布 |
26+| 02.05 | Qwen3 LoRA 微调:量化因子代码生成实战 | 建设中 |
27+| 02.06 | 用 ms-swift 复现 SFT / LoRA 微调(成熟工具链对照) | 建设中 |
28+| 02.07 | 数据合成 | 建设中 |
29+| 02.08 | 微调超参数与科学调参 | 建设中 |
30+| 02.09 | 微调效果评估 | 建设中 |
@@ -0,0 +1,26 @@
1+<div align="center" style="border: 1px solid #eee; padding: 20px; border-radius: 10px; margin-bottom: 20px; max-width: 320px; margin-left: auto; margin-right: auto;">
2+ 
3+![SwanLab](../images/swanlab.png)
4+ 
5+SwanLab x CANN 社区合作课程
6+ 
7+</div>
8+ 
9+---
10+ 
11+# 第 03 章 · 大语言模型强化学习
12+ 
13+## 章节定位
14+ 
15+在第 02 章 SFT / LoRA 的基础上,引入**偏好学习与强化学习**这一阶段,覆盖从 RLHF(PPO)到 DPO、GRPO 等更现代的方案。
16+目标是让同学理解:为什么仅有 SFT 不够、奖励信号如何注入、训练为什么更不稳定,以及在昇腾 NPU 上跑 RL 训练时的工程权衡。
17+ 
18+## 计划节次
19+ 
20+| 节次 | 标题(暂定) | 状态 |
21+|------|---------------|------|
22+| 03.01 | 章节简介与对齐范式概览 | 建设中 |
23+| 03.02 | 偏好数据集与 Reward Model | 建设中 |
24+| 03.03 | PPO 端到端实战 | 建设中 |
25+| 03.04 | DPO / GRPO 等无 RM 方案对比 | 建设中 |
26+| 03.05 | 代码可执行性作为奖励信号的案例 | 建设中 |
@@ -0,0 +1,26 @@
1+<div align="center" style="border: 1px solid #eee; padding: 20px; border-radius: 10px; margin-bottom: 20px; max-width: 320px; margin-left: auto; margin-right: auto;">
2+ 
3+![SwanLab](../images/swanlab.png)
4+ 
5+SwanLab x CANN 社区合作课程
6+ 
7+</div>
8+ 
9+---
10+ 
11+# 第 04 章 · 大语言模型推理部署
12+ 
13+## 章节定位
14+ 
15+本章聚焦"训练好的模型如何对外提供服务"这一环节,覆盖在昇腾 NPU 上的推理引擎选型、权重量化、批处理与服务化部署。
16+预期读者在学完本章后,能在 CANNLab 上把一个微调后的 Qwen3 模型起成一个对外的 HTTP / OpenAI 兼容服务,并理解吞吐、延迟、显存之间的权衡。
17+ 
18+## 计划节次
19+ 
20+| 节次 | 标题(暂定) | 状态 |
21+|------|---------------|------|
22+| 04.01 | 章节简介与推理瓶颈分析 | 建设中 |
23+| 04.02 | KV cache 与 continuous batching | 建设中 |
24+| 04.03 | 昇腾上的推理引擎选型与对比 | 建设中 |
25+| 04.04 | 权重量化(W8A8 / AWQ / GPTQ)实战 | 建设中 |
26+| 04.05 | 推理服务化与 OpenAI 兼容接口 | 建设中 |
@@ -0,0 +1,676 @@
1+{
2+ "cells": [
3+ {
4+ "cell_type": "markdown",
5+ "metadata": {},
6+ "source": "# 实训大作业 · Notebook:把课程提供的 SwanRmsNorm 算子接到 Qwen3-8B\n\n本 Notebook 是 [`guidance.pdf`](pdf/guidance.pdf) 学生操作指导书 的实操配套。\n\n**前提**:课程提供的 SwanRmsNorm AscendC kernel 已经在指导书 §5 编译并部署到 NPU 上。本 Notebook 只做**集成层 + 端到端实验**,分三步:\n\n1. **写集成层** — 用 `torch.autograd.Function` 包装 SwanRmsNorm 的 forward / backward,并 monkey-patch 到 Qwen3 模型里\n2. **测推理加速** — 对比 baseline / patched 的 tokens/sec,画图\n3. **跑 LoRA SFT** — 用 alpaca_zh 数据集做一轮微调,对比训练 step 时间,并把 loss + step time 用 SwanLab 记录起来\n\n**前置条件**:\n- 已经按 [guidance.pdf](pdf/guidance.pdf) §5 把课程提供的 `swan_rms_norm` AscendC kernel 编译并安装到 `~/Ascend/custom_opp`\n- 启动 Jupyter 之前已在 shell 里 `source ~/Ascend/custom_opp/vendors/xllm/bin/set_env.bash`(否则 §1 中 `import rms_norm_lib` 会失败)\n- Python 依赖与 Qwen3-8B 基座模型由下面的 §0 自动处理,无需手动准备\n\n**Notebook 内有 2 个 `# TODO` 需要你补全**——参考 [guidance.pdf](pdf/guidance.pdf) §6 完成。直接运行会在 TODO 处 `NotImplementedError`。\n\n> **注意**:你**不需要**写 AscendC kernel——那是课程已经独立实现好的。你的任务是把它接到 PyTorch 与 Qwen3 模型上。"
7+ },
8+ {
9+ "cell_type": "markdown",
10+ "id": "env-prep-md",
11+ "metadata": {},
12+ "source": "---\n\n## §0 环境准备\n\n本 Notebook 在 **CANNLab 的 Ascend NPU 环境**下运行。镜像默认已预装 `torch` 与 `torch_npu`,下面两步分别处理:\n\n1. **Python 上层依赖**:用 `pip` 装 `transformers` / `peft` / `datasets` / `swanlab` / `accelerate` / `modelscope`\n2. **Qwen3-8B 基座模型**:从 ModelScope 下载到 `./Qwen3-8B`(约 16GB,仅首次需要,10-20 分钟)\n\n> **首次运行**:直接顺序执行下方两个 cell 即可。\n>\n> **重跑场景**:如果环境里已经残留过旧版 `transformers` 且本 Notebook 之前 import 过它,安装后请点 `Kernel → Restart`,否则新版本不会生效。\n\n> **⚠️ 重要前提**:自定义算子 `rms_norm_lib` **无法通过 pip 安装**,必须按 [guidance.pdf](pdf/guidance.pdf) §5 在 shell 里手动编译并安装到 `~/Ascend/custom_opp`,并在启动 Jupyter 之前 `source ~/Ascend/custom_opp/vendors/xllm/bin/set_env.bash`。否则 §1 环境检查中的 `import rms_norm_lib` 会失败。"
13+ },
14+ {
15+ "cell_type": "code",
16+ "execution_count": null,
17+ "id": "env-pip",
18+ "metadata": {},
19+ "outputs": [],
20+ "source": [
21+ "# 1. 安装 Python 上层依赖\n",
22+ "%pip install -q -i https://pypi.tuna.tsinghua.edu.cn/simple modelscope==1.35.4 transformers==5.5.4 peft==0.18.0 datasets==4.8.4 swanlab==0.7.15 accelerate==1.13.0"
23+ ]
24+ },
25+ {
26+ "cell_type": "code",
27+ "execution_count": null,
28+ "id": "env-download",
29+ "metadata": {},
30+ "outputs": [],
31+ "source": [
32+ "# 2. 下载 Qwen3-8B 基座模型(首次约 16GB / 10-20 分钟,已下载则跳过)\n",
33+ "import os\n",
34+ "\n",
35+ "MODEL_DIR = './Qwen3-8B'\n",
36+ "if os.path.exists(MODEL_DIR) and os.listdir(MODEL_DIR):\n",
37+ " print(f'{MODEL_DIR} 已存在,跳过下载')\n",
38+ "else:\n",
39+ " !modelscope download --model Qwen/Qwen3-8B --local_dir ./Qwen3-8B"
40+ ]
41+ },
42+ {
43+ "cell_type": "markdown",
44+ "metadata": {},
45+ "source": [
46+ "---\n",
47+ "\n",
48+ "## §1 环境检查\n",
49+ "\n",
50+ "确认 NPU 可用、torch_npu 装好、自定义算子能 import。任何一行报错都得回到指导书 §4-§5 排查。"
51+ ]
52+ },
53+ {
54+ "cell_type": "code",
55+ "execution_count": null,
56+ "metadata": {},
57+ "outputs": [],
58+ "source": [
59+ "import time, gc, types\n",
60+ "import torch\n",
61+ "import torch_npu\n",
62+ "\n",
63+ "print(f'torch : {torch.__version__}')\n",
64+ "print(f'torch_npu : {torch_npu.__version__}')\n",
65+ "assert torch.npu.is_available(), 'NPU 不可用!'\n",
66+ "torch_npu.npu.set_device(0)\n",
67+ "print(f'device : {torch_npu.npu.get_device_name(0)}')\n",
68+ "\n",
69+ "import rms_norm_lib\n",
70+ "print(f'rms_norm_lib loaded: {rms_norm_lib}')\n",
71+ "print(f'available functions: {[x for x in dir(rms_norm_lib) if not x.startswith(\"_\")]}')"
72+ ]
73+ },
74+ {
75+ "cell_type": "markdown",
76+ "metadata": {},
77+ "source": [
78+ "---\n",
79+ "\n",
80+ "## §2 算子精度验证\n",
81+ "\n",
82+ "在调用模型前先确认我们的算子计算正确。和 PyTorch native 实现对比,bf16 + 随机 γ 的实测最大绝对误差通常落在 **0.1-0.4** 范围内,超过 0.5 才是真有问题。\n",
83+ "\n",
84+ "我们用 Qwen3-8B 的 `hidden_size = 4096` 来测,更接近真实场景的形状。"
85+ ]
86+ },
87+ {
88+ "cell_type": "code",
89+ "execution_count": null,
90+ "metadata": {},
91+ "outputs": [],
92+ "source": [
93+ "torch.manual_seed(42)\n",
94+ "\n",
95+ "# Qwen3-8B 的 hidden_size = 4096\n",
96+ "M, N = 16, 4096\n",
97+ "x = torch.randn(M, N, dtype=torch.bfloat16, device='npu')\n",
98+ "gamma = torch.randn(N, dtype=torch.bfloat16, device='npu')\n",
99+ "\n",
100+ "# 我们的算子\n",
101+ "y_swan = rms_norm_lib.rms_norm(x, gamma, 1e-6)\n",
102+ "\n",
103+ "# PyTorch native(同 Qwen3RMSNorm.forward)\n",
104+ "xf = x.to(torch.float32)\n",
105+ "y_torch = (gamma * (xf * torch.rsqrt(xf.pow(2).mean(-1, keepdim=True) + 1e-6)).to(x.dtype))\n",
106+ "\n",
107+ "diff = (y_swan.float() - y_torch.float()).abs().max().item()\n",
108+ "print(f'shape : {tuple(y_swan.shape)}, dtype: {y_swan.dtype}')\n",
109+ "print(f'max |diff| : {diff:.4f} (期望 < 0.5;典型范围 0.1-0.4)')\n",
110+ "assert diff < 0.5, '精度不对,回去检查 kernel 实现'"
111+ ]
112+ },
113+ {
114+ "cell_type": "markdown",
115+ "metadata": {},
116+ "source": [
117+ "---\n",
118+ "\n",
119+ "## §3 加载 Qwen3-8B\n",
120+ "\n",
121+ "用之前已经下载的 Qwen3-8B 模型。后面所有测试都会重复加载这个模型——8B 在 bf16 下 ~16GB,HBM 60GB 够用,但每次 load 大约 10-30 秒。"
122+ ]
123+ },
124+ {
125+ "cell_type": "code",
126+ "execution_count": null,
127+ "metadata": {},
128+ "outputs": [],
129+ "source": [
130+ "from transformers import AutoModelForCausalLM, AutoTokenizer\n",
131+ "from transformers.models.qwen3 import modeling_qwen3\n",
132+ "\n",
133+ "MODEL_PATH = './Qwen3-8B' # ~ 自动解析当前用户家目录\n",
134+ "\n",
135+ "tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH)\n",
136+ "if tokenizer.pad_token is None:\n",
137+ " tokenizer.pad_token = tokenizer.eos_token\n",
138+ "\n",
139+ "# 数一下模型里有多少个 Qwen3RMSNorm 模块\n",
140+ "_tmp = AutoModelForCausalLM.from_pretrained(MODEL_PATH, torch_dtype=torch.bfloat16)\n",
141+ "n_rmsnorm = sum(1 for m in _tmp.modules() if isinstance(m, modeling_qwen3.Qwen3RMSNorm))\n",
142+ "n_layers = len(_tmp.model.layers)\n",
143+ "print(f'Qwen3-8B:')\n",
144+ "print(f' layers : {n_layers}')\n",
145+ "print(f' Qwen3RMSNorm : {n_rmsnorm} (= {n_layers} 层 × 4/层 + 1 final)')\n",
146+ "print(f' hidden_size : {_tmp.config.hidden_size}')\n",
147+ "del _tmp; gc.collect(); torch.npu.empty_cache()"
148+ ]
149+ },
150+ {
151+ "cell_type": "markdown",
152+ "metadata": {},
153+ "source": [
154+ "---\n",
155+ "\n",
156+ "## §4 定义 SwanRmsNormFunction(autograd.Function)\n",
157+ "\n",
158+ "**TODO #1**:参考指导书 §6.2.2,补全下面的 `forward` 和 `backward`。\n",
159+ "\n",
160+ "要点:\n",
161+ "- **forward** 调 `rms_norm_lib.rms_norm(x, gamma, eps)`,并通过 `ctx` 保存 backward 需要的张量\n",
162+ "- **backward** 用 `torch_npu.npu_rms_norm_backward(grad_y, x, gamma, rstd)`,注意 `rstd` 必须是 fp32 且要从 `x` 重算\n",
163+ "- 返回值数量必须与 forward 形参一致——没有梯度的参数返回 `None`"
164+ ]
165+ },
166+ {
167+ "cell_type": "code",
168+ "execution_count": null,
169+ "metadata": {},
170+ "outputs": [],
171+ "source": [
172+ "class SwanRmsNormFunction(torch.autograd.Function):\n",
173+ " \"\"\"\n",
174+ " Forward: 用我们的 SwanRmsNorm AscendC kernel\n",
175+ " Backward: 用 torch_npu 的 fused npu_rms_norm_backward\n",
176+ " \"\"\"\n",
177+ "\n",
178+ " @staticmethod\n",
179+ " def forward(ctx, x, gamma, eps):\n",
180+ " # ───────── TODO #1 (a) ─────────\n",
181+ " # 1) x.contiguous() 一下(kernel 假设 stride 对齐,stride 不对会 silently 算错);\n",
182+ " # 2) 调 rms_norm_lib.rms_norm(x_contig, gamma, eps) 算 forward;\n",
183+ " # 3) 用 ctx.save_for_backward(x_contig, gamma) 保存反向需要的张量;\n",
184+ " # 4) eps 是 float(不是 tensor),存到 ctx.eps 上。\n",
185+ " # 详见指导书 §6.2.2。\n",
186+ " raise NotImplementedError('请补全 forward')\n",
187+ "\n",
188+ " @staticmethod\n",
189+ " def backward(ctx, grad_y):\n",
190+ " x, gamma = ctx.saved_tensors\n",
191+ " eps = ctx.eps\n",
192+ " # ───────── TODO #1 (b) ─────────\n",
193+ " # 用 torch_npu.npu_rms_norm_backward(grad_y, x, gamma, rstd) 算梯度。\n",
194+ " # rstd 不在 ctx 里,需要从 x 重算(fp32 精度,否则报 dtype 错)。\n",
195+ " # 返回 (grad_x, grad_gamma, None) ——eps 没有梯度。\n",
196+ " # 详见指导书 §6.2.2。\n",
197+ " raise NotImplementedError('请补全 backward')\n",
198+ "\n",
199+ "\n",
200+ "# 简单 forward 验证\n",
201+ "y_check = SwanRmsNormFunction.apply(x, gamma, 1e-6)\n",
202+ "diff = (y_check.float() - y_torch.float()).abs().max().item()\n",
203+ "print(f'autograd.Function forward max |diff| = {diff:.4f}')\n",
204+ "assert diff < 0.5"
205+ ]
206+ },
207+ {
208+ "cell_type": "markdown",
209+ "metadata": {},
210+ "source": [
211+ "---\n",
212+ "\n",
213+ "## §5 实现 patch_qwen3_rmsnorm(带计数)\n",
214+ "\n",
215+ "**TODO #2**:参考指导书 §6.2.3,补全下面的 patch 函数。\n",
216+ "\n",
217+ "要点:\n",
218+ "- 把这个 module 的 `forward` 替换为调 `SwanRmsNormFunction.apply` 的版本\n",
219+ "- 每个 module 有自己的 `weight`,需要用**工厂函数**捕获——避免 Python 闭包后期绑定让所有 module 都用最后一个 weight\n",
220+ "- 替换时用 `types.MethodType(...)` 让函数能正确接收 `self`\n",
221+ "- 别忘了 `n += 1` 计数"
222+ ]
223+ },
224+ {
225+ "cell_type": "code",
226+ "execution_count": null,
227+ "metadata": {},
228+ "outputs": [],
229+ "source": [
230+ "def patch_qwen3_rmsnorm(model):\n",
231+ " n = 0\n",
232+ " for module in model.modules():\n",
233+ " if isinstance(module, modeling_qwen3.Qwen3RMSNorm):\n",
234+ " eps = module.variance_epsilon\n",
235+ " weight = module.weight\n",
236+ "\n",
237+ " # ───────── TODO #2 ─────────\n",
238+ " # 1) 写一个工厂函数 make_fw(w, e) 返回新 forward\n",
239+ " # 2) 用 types.MethodType 把它绑定到 module\n",
240+ " # 3) n += 1\n",
241+ " # 详见指导书 §6.2.3。\n",
242+ " raise NotImplementedError('请补全 patch 逻辑')\n",
243+ " return n\n",
244+ "\n",
245+ "\n",
246+ "# 验证 patch\n",
247+ "_test_model = AutoModelForCausalLM.from_pretrained(MODEL_PATH, torch_dtype=torch.bfloat16).to('npu').eval()\n",
248+ "n_patched = patch_qwen3_rmsnorm(_test_model)\n",
249+ "print(f'patched {n_patched} Qwen3RMSNorm modules (期望: {n_rmsnorm})')\n",
250+ "assert n_patched == n_rmsnorm, 'patch 数量不对'\n",
251+ "del _test_model; gc.collect(); torch.npu.empty_cache()"
252+ ]
253+ },
254+ {
255+ "cell_type": "markdown",
256+ "metadata": {},
257+ "source": [
258+ "---\n",
259+ "\n",
260+ "## §6 推理加速测试\n",
261+ "\n",
262+ "测 Qwen3-8B 的端到端推理 tokens/sec。Baseline / Patched 各加载一次模型,避免相互污染。"
263+ ]
264+ },
265+ {
266+ "cell_type": "code",
267+ "execution_count": null,
268+ "metadata": {},
269+ "outputs": [],
270+ "source": [
271+ "PROMPT = '请用一段话介绍量子计算的基本原理。'\n",
272+ "MAX_NEW = 50\n",
273+ "\n",
274+ "inputs = tokenizer(PROMPT, return_tensors='pt').to('npu')\n",
275+ "\n",
276+ "def measure_tps(model, max_new=MAX_NEW, warmup=2):\n",
277+ " \"\"\"测一次 generate 的 tokens/sec\"\"\"\n",
278+ " for _ in range(warmup):\n",
279+ " _ = model.generate(**inputs, max_new_tokens=max_new, do_sample=False,\n",
280+ " pad_token_id=tokenizer.eos_token_id)\n",
281+ " torch_npu.npu.synchronize()\n",
282+ " t0 = time.perf_counter()\n",
283+ " out = model.generate(**inputs, max_new_tokens=max_new, do_sample=False,\n",
284+ " pad_token_id=tokenizer.eos_token_id)\n",
285+ " torch_npu.npu.synchronize()\n",
286+ " dt = time.perf_counter() - t0\n",
287+ " n_new = out.shape[1] - inputs['input_ids'].shape[1]\n",
288+ " return n_new / dt, tokenizer.decode(out[0], skip_special_tokens=True)\n",
289+ "\n",
290+ "\n",
291+ "# Baseline\n",
292+ "print('===== Baseline (PyTorch native RMSNorm) =====')\n",
293+ "m_base = AutoModelForCausalLM.from_pretrained(MODEL_PATH, torch_dtype=torch.bfloat16).to('npu').eval()\n",
294+ "tps_baseline, text_baseline = measure_tps(m_base)\n",
295+ "print(f' tokens/sec: {tps_baseline:.2f}')\n",
296+ "del m_base; gc.collect(); torch.npu.empty_cache()\n",
297+ "\n",
298+ "# Patched\n",
299+ "print('\\n===== Patched (SwanRmsNorm AscendC kernel) =====')\n",
300+ "m_pt = AutoModelForCausalLM.from_pretrained(MODEL_PATH, torch_dtype=torch.bfloat16).to('npu').eval()\n",
301+ "n = patch_qwen3_rmsnorm(m_pt)\n",
302+ "print(f' patched {n} modules')\n",
303+ "tps_patched, text_patched = measure_tps(m_pt)\n",
304+ "print(f' tokens/sec: {tps_patched:.2f}')\n",
305+ "del m_pt; gc.collect(); torch.npu.empty_cache()\n",
306+ "\n",
307+ "# 总结\n",
308+ "speedup = tps_patched / tps_baseline\n",
309+ "print('\\n' + '═' * 50)\n",
310+ "print(f' Baseline : {tps_baseline:.2f} tokens/sec')\n",
311+ "print(f' Patched : {tps_patched:.2f} tokens/sec')\n",
312+ "print(f' Speedup : {speedup:.3f} ×')\n",
313+ "print('═' * 50)\n",
314+ "print(f'\\nGreedy decode 一致性:{text_baseline == text_patched}')"
315+ ]
316+ },
317+ {
318+ "cell_type": "markdown",
319+ "metadata": {},
320+ "source": [
321+ "## §6.1 推理加速可视化"
322+ ]
323+ },
324+ {
325+ "cell_type": "code",
326+ "execution_count": null,
327+ "metadata": {},
328+ "outputs": [],
329+ "source": [
330+ "import os\n",
331+ "import matplotlib\n",
332+ "matplotlib.use('Agg') # headless backend\n",
333+ "import matplotlib.pyplot as plt\n",
334+ "\n",
335+ "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4.5))\n",
336+ "\n",
337+ "# 左图:tokens/sec 对比\n",
338+ "ax1.bar(['Baseline\\n(PyTorch native)', 'Patched\\n(SwanRmsNorm)'],\n",
339+ " [tps_baseline, tps_patched],\n",
340+ " color=['#9E9E9E', '#4CAF50'])\n",
341+ "ax1.set_ylabel('tokens / sec')\n",
342+ "ax1.set_title('Qwen3-8B Inference Throughput')\n",
343+ "ax1.text(1, tps_patched, f'{speedup:.2f}×', ha='center', va='bottom',\n",
344+ " fontsize=14, fontweight='bold', color='#2E7D32')\n",
345+ "\n",
346+ "# 右图:speedup 单柱\n",
347+ "ax2.bar(['Speedup'], [speedup], color='#4CAF50', width=0.5)\n",
348+ "ax2.axhline(1.0, color='gray', linestyle='--', linewidth=1)\n",
349+ "ax2.set_ylabel('Speedup (×)')\n",
350+ "ax2.set_title('End-to-end Speedup')\n",
351+ "ax2.text(0, speedup, f'{speedup:.3f}×', ha='center', va='bottom',\n",
352+ " fontsize=14, fontweight='bold', color='#2E7D32')\n",
353+ "ax2.set_ylim(0, max(2, speedup * 1.2))\n",
354+ "\n",
355+ "plt.tight_layout()\n",
356+ "plt.savefig('notebook_inference.png', dpi=120)\n",
357+ "plt.show()\n",
358+ "print('保存到 notebook_inference.png')"
359+ ]
360+ },
361+ {
362+ "cell_type": "markdown",
363+ "metadata": {},
364+ "source": [
365+ "---\n",
366+ "\n",
367+ "## §7 LoRA 微调:使用 alpaca_zh 数据集\n",
368+ "\n",
369+ "下面用 alpaca_zh 数据集对 Qwen3-8B 做一轮 LoRA 微调,对比 baseline / patched 的训练速度。\n",
370+ "\n",
371+ "### §7.1 下载并加载 alpaca_zh"
372+ ]
373+ },
374+ {
375+ "cell_type": "code",
376+ "execution_count": null,
377+ "metadata": {},
378+ "outputs": [],
379+ "source": [
380+ "import os\n",
381+ "if not os.path.exists('alpaca_zh'):\n",
382+ " print('下载 alpaca_zh 数据集...')\n",
383+ " !modelscope download --dataset llamafactory/alpaca_zh --local_dir ./alpaca_zh\n",
384+ "else:\n",
385+ " print('alpaca_zh 已存在,跳过下载')\n",
386+ "\n",
387+ "import datasets\n",
388+ "alpaca = datasets.load_dataset('json', data_files='alpaca_zh/alpaca_data_zh_51k.json')['train']\n",
389+ "print(f'数据集样本数: {len(alpaca)}')\n",
390+ "print('\\n第一条样本:')\n",
391+ "print(f' instruction: {alpaca[0][\"instruction\"]}')\n",
392+ "print(f' input : {alpaca[0][\"input\"]}')\n",
393+ "print(f' output : {alpaca[0][\"output\"][:80]}...')"
394+ ]
395+ },
396+ {
397+ "cell_type": "markdown",
398+ "metadata": {},
399+ "source": [
400+ "### §7.2 数据格式化\n",
401+ "\n",
402+ "把 instruction/input/output 拼成 Qwen3 chat 格式:"
403+ ]
404+ },
405+ {
406+ "cell_type": "code",
407+ "execution_count": null,
408+ "metadata": {},
409+ "outputs": [],
410+ "source": [
411+ "def format_data(example):\n",
412+ " user_text = example['instruction'] + (example['input'] or '')\n",
413+ " assistant_text = example['output']\n",
414+ " messages = [\n",
415+ " {'role': 'user', 'content': user_text},\n",
416+ " {'role': 'assistant', 'content': assistant_text},\n",
417+ " ]\n",
418+ " text = tokenizer.apply_chat_template(\n",
419+ " messages, tokenize=False, add_generation_prompt=False, enable_thinking=False)\n",
420+ " return {'text': text}\n",
421+ "\n",
422+ "# 取 200 条做演示(实测 alpaca_zh 全集 51k 单 epoch 太长)\n",
423+ "DEMO_SIZE = 200\n",
424+ "demo_data = alpaca.select(range(DEMO_SIZE)).map(format_data, remove_columns=alpaca.column_names)\n",
425+ "print(f'演示用数据集大小: {len(demo_data)} 条')\n",
426+ "print(f'\\n格式化后第一条:\\n{demo_data[0][\"text\"][:300]}...')"
427+ ]
428+ },
429+ {
430+ "cell_type": "markdown",
431+ "metadata": {},
432+ "source": [
433+ "### §7.3 构造 LoRA 模型 + 测一步训练时间(同时用 SwanLab 记录)\n",
434+ "\n",
435+ "跑 30 步 LoRA SFT,每步把 loss 和 step_time_ms 推到 SwanLab。两个变体(baseline / patched)独立 run,可以在 SwanLab 上叠加对比。\n",
436+ "\n",
437+ "> **使用 SwanLab 前**:在 shell 里跑 `swanlab login`,输入你的 API key([swanlab.cn/settings](https://swanlab.cn/settings))。如果没登录,cell 里有 try/except 兜底,跟踪会跳过但测速本身不受影响。\n",
438+ "\n",
439+ "> ⏳ **训练耗时较长**(baseline + patched 共两轮 LoRA SFT,约 10-20 分钟)。**点击运行后等 cell 跑完再点下一个**——SwanLab 训练 cell 不会自动跳转,连续点会重复触发训练。"
440+ ]
441+ },
442+ {
443+ "cell_type": "code",
444+ "execution_count": null,
445+ "metadata": {},
446+ "outputs": [],
447+ "source": [
448+ "from peft import LoraConfig, get_peft_model\n",
449+ "\n",
450+ "BATCH_SIZE = 4\n",
451+ "SEQ_LEN = 512\n",
452+ "LORA_R = 16\n",
453+ "N_TRAIN_STEPS = 30 # 跑 30 步看时间趋势(前 5 步 warmup)\n",
454+ "WARMUP = 5\n",
455+ "\n",
456+ "def build_lora_model():\n",
457+ " base = AutoModelForCausalLM.from_pretrained(MODEL_PATH, torch_dtype=torch.bfloat16)\n",
458+ " for p in base.parameters():\n",
459+ " p.requires_grad = False\n",
460+ " cfg = LoraConfig(\n",
461+ " r=LORA_R, lora_alpha=LORA_R*2,\n",
462+ " target_modules=['q_proj','k_proj','v_proj','o_proj','gate_proj','up_proj','down_proj'],\n",
463+ " lora_dropout=0.0, bias='none', task_type='CAUSAL_LM',\n",
464+ " )\n",
465+ " return get_peft_model(base, cfg).to('npu')\n",
466+ "\n",
467+ "def make_batches(n):\n",
468+ " \"\"\"取 n 个 batch(每个 batch_size 条),固定 padding 到 seq_len。\n",
469+ " label 里的 pad 位置设为 -100 → CrossEntropy 会忽略,loss 才能正常下降。\"\"\"\n",
470+ " batches = []\n",
471+ " for i in range(n):\n",
472+ " texts = [demo_data[(i * BATCH_SIZE + j) % len(demo_data)]['text']\n",
473+ " for j in range(BATCH_SIZE)]\n",
474+ " enc = tokenizer(texts, return_tensors='pt', truncation=True,\n",
475+ " padding='max_length', max_length=SEQ_LEN).to('npu')\n",
476+ " labels = enc['input_ids'].clone()\n",
477+ " labels[enc['attention_mask'] == 0] = -100 # 不要 train pad token\n",
478+ " enc['labels'] = labels\n",
479+ " batches.append(enc)\n",
480+ " return batches\n",
481+ "\n",
482+ "batches = make_batches(N_TRAIN_STEPS)\n",
483+ "print(f'构造 {N_TRAIN_STEPS} 个 batch, batch_size={BATCH_SIZE}, seq_len={SEQ_LEN}')\n",
484+ "print(f'M = batch × seq_len = {BATCH_SIZE * SEQ_LEN}')"
485+ ]
486+ },
487+ {
488+ "cell_type": "code",
489+ "execution_count": null,
490+ "metadata": {},
491+ "outputs": [],
492+ "source": [
493+ "# ====== SwanLab 登录 + 配置 ======\n",
494+ "import getpass\n",
495+ "import swanlab\n",
496+ "from pathlib import Path\n",
497+ "\n",
498+ "# 项目名统一为 CANN_SwanLab,便于和前两章实验放在同一项目下\n",
499+ "%env SWANLAB_PROJ=CANN_SwanLab\n",
500+ "\n",
501+ "def _swanlab_credential_saved() -> bool:\n",
502+ " \"\"\"检查本地是否已有 SwanLab 凭证(默认存储在 ~/.netrc)\"\"\"\n",
503+ " netrc = Path.home() / '.netrc'\n",
504+ " if not netrc.exists():\n",
505+ " return False\n",
506+ " try:\n",
507+ " return 'swanlab.cn' in netrc.read_text()\n",
508+ " except Exception:\n",
509+ " return False\n",
510+ "\n",
511+ "if _swanlab_credential_saved():\n",
512+ " print('检测到本地已有 SwanLab 凭证,跳过登录。')\n",
513+ "else:\n",
514+ " api_key = getpass.getpass('请输入你的 SwanLab API Key(从 https://swanlab.cn/settings 获取): ')\n",
515+ " swanlab.login(api_key=api_key, save=True)\n",
516+ " print('SwanLab 登录成功!凭证已保存到 ~/.netrc,下次运行无需再输入。')\n",
517+ "\n",
518+ "\n",
519+ "swanlab_runs_logged = 0 # 累计真正记录成功的 run 数\n",
520+ "\n",
521+ "\n",
522+ "def measure_step_time(model, batches, warmup=WARMUP, run_name='baseline'):\n",
523+ " \"\"\"跑 len(batches) 个 step,返回稳定阶段平均 step time + 全部 loss。\n",
524+ " 每步的 loss / step_time_ms 同步推到 SwanLab。\"\"\"\n",
525+ " global swanlab_runs_logged\n",
526+ " swanlab.init(\n",
527+ " project=os.environ['SWANLAB_PROJ'],\n",
528+ " experiment_name=f'SwanRmsNorm-{run_name}',\n",
529+ " config=dict(\n",
530+ " model=MODEL_PATH, batch_size=BATCH_SIZE, seq_len=SEQ_LEN,\n",
531+ " lora_r=LORA_R, optimizer='AdamW', lr=2e-4,\n",
532+ " variant=run_name,\n",
533+ " ),\n",
534+ " reinit=True,\n",
535+ " )\n",
536+ "\n",
537+ " model.train()\n",
538+ " opt = torch.optim.AdamW(\n",
539+ " [p for p in model.parameters() if p.requires_grad], lr=2e-4)\n",
540+ "\n",
541+ " times, losses = [], []\n",
542+ " for i, batch in enumerate(batches):\n",
543+ " torch_npu.npu.synchronize()\n",
544+ " t0 = time.perf_counter()\n",
545+ " opt.zero_grad(set_to_none=True)\n",
546+ " out = model(**batch)\n",
547+ " out.loss.backward()\n",
548+ " opt.step()\n",
549+ " torch_npu.npu.synchronize()\n",
550+ " step_ms = (time.perf_counter() - t0) * 1000\n",
551+ " loss_v = float(out.loss.detach().item())\n",
552+ " times.append(step_ms)\n",
553+ " losses.append(loss_v)\n",
554+ "\n",
555+ " swanlab.log({\n",
556+ " 'loss': loss_v,\n",
557+ " 'step_time_ms': step_ms,\n",
558+ " 'is_warmup': int(i < warmup),\n",
559+ " }, step=i)\n",
560+ "\n",
561+ " swanlab.finish()\n",
562+ " swanlab_runs_logged += 1\n",
563+ "\n",
564+ " stable = times[warmup:]\n",
565+ " return sum(stable) / len(stable), times, losses\n",
566+ "\n",
567+ "\n",
568+ "# Baseline LoRA SFT\n",
569+ "print('===== Baseline LoRA SFT =====')\n",
570+ "m = build_lora_model()\n",
571+ "n_train = sum(p.numel() for p in m.parameters() if p.requires_grad)\n",
572+ "print(f' trainable params: {n_train:,}')\n",
573+ "t_base, times_base, losses_base = measure_step_time(m, batches, run_name='baseline')\n",
574+ "print(f' step time: {t_base:.2f} ms')\n",
575+ "print(f' loss: first={losses_base[0]:.3f} last={losses_base[-1]:.3f} (期望下降)')\n",
576+ "del m; gc.collect(); torch.npu.empty_cache()\n",
577+ "\n",
578+ "# Patched LoRA SFT\n",
579+ "print('\\n===== Patched LoRA SFT (SwanRmsNorm) =====')\n",
580+ "m = build_lora_model()\n",
581+ "patch_qwen3_rmsnorm(m)\n",
582+ "t_patched, times_patched, losses_patched = measure_step_time(m, batches, run_name='patched')\n",
583+ "print(f' step time: {t_patched:.2f} ms')\n",
584+ "print(f' loss: first={losses_patched[0]:.3f} last={losses_patched[-1]:.3f} (期望下降)')\n",
585+ "del m; gc.collect(); torch.npu.empty_cache()\n",
586+ "\n",
587+ "sft_speedup = t_base / t_patched\n",
588+ "print('\\n' + '═' * 50)\n",
589+ "print(f' Baseline step : {t_base:.2f} ms')\n",
590+ "print(f' Patched step : {t_patched:.2f} ms')\n",
591+ "print(f' SFT Speedup : {sft_speedup:.3f} ×')\n",
592+ "print('═' * 50)\n",
593+ "print(f'\\n已记录 {swanlab_runs_logged} 个 SwanLab run(baseline + patched),在 swanlab.cn 上看 loss / step_time 对比。')"
594+ ]
595+ },
596+ {
597+ "cell_type": "markdown",
598+ "metadata": {},
599+ "source": [
600+ "### §7.4 微调加速可视化"
601+ ]
602+ },
603+ {
604+ "cell_type": "code",
605+ "execution_count": null,
606+ "metadata": {},
607+ "outputs": [],
608+ "source": [
609+ "import os\n",
610+ "import numpy as np\n",
611+ "import matplotlib.pyplot as plt # 显式 import:即使你跳过了 §6.1,本 cell 也能跑\n",
612+ "\n",
613+ "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4.5))\n",
614+ "\n",
615+ "# 左图:每个 step 的 step time(看稳定性)\n",
616+ "xs = np.arange(len(times_base))\n",
617+ "ax1.plot(xs, times_base, 'o-', color='#9E9E9E', markersize=3, label='Baseline')\n",
618+ "ax1.plot(xs, times_patched, 'o-', color='#4CAF50', markersize=3, label='Patched (SwanRmsNorm)')\n",
619+ "ax1.axvspan(0, WARMUP - 0.5, alpha=0.15, color='red', label='warmup (excluded)')\n",
620+ "ax1.set_xlabel('step')\n",
621+ "ax1.set_ylabel('step time (ms)')\n",
622+ "ax1.set_title(f'LoRA SFT step time (B={BATCH_SIZE}, S={SEQ_LEN})')\n",
623+ "ax1.legend()\n",
624+ "ax1.grid(alpha=0.3)\n",
625+ "\n",
626+ "# 右图:平均 step time 对比\n",
627+ "ax2.bar(['Baseline', 'Patched\\n(SwanRmsNorm)'],\n",
628+ " [t_base, t_patched],\n",
629+ " color=['#9E9E9E', '#4CAF50'])\n",
630+ "ax2.set_ylabel('avg step time (ms)')\n",
631+ "ax2.set_title(f'LoRA SFT Speedup: {sft_speedup:.3f}×')\n",
632+ "ax2.text(1, t_patched, f'{sft_speedup:.2f}×', ha='center', va='bottom',\n",
633+ " fontsize=14, fontweight='bold', color='#2E7D32')\n",
634+ "\n",
635+ "plt.tight_layout()\n",
636+ "plt.savefig('notebook_lora_sft.png', dpi=120)\n",
637+ "plt.show()\n",
638+ "print('保存到 notebook_lora_sft.png')"
639+ ]
640+ },
641+ {
642+ "cell_type": "markdown",
643+ "metadata": {},
644+ "source": [
645+ "---\n",
646+ "\n",
647+ "## §8 总结\n",
648+ "\n",
649+ "你完成了一条完整的「把课程提供的 AscendC kernel 接到大模型」实操链路:\n",
650+ "\n",
651+ "1. ✅ 读懂了 ~150 行 SwanRmsNorm AscendC kernel 的 fused 设计\n",
652+ "2. ✅ 在 NPU 上完成编译、部署\n",
653+ "3. ✅ **自己写**了 PyTorch 集成层:autograd.Function + monkey-patch(带工厂函数)\n",
654+ "4. ✅ 推理对比:tokens/sec 端到端 **~1.30-1.55×** 加速(145 个 RMSNorm 模块全部接管)\n",
655+ "5. ✅ LoRA SFT 对比:step time 端到端 **~1.05-1.15×** 加速,loss + step time 用 SwanLab 全记录\n",
656+ "\n",
657+ "为什么单算子 ~7× 加速但端到端只有 1.5×?这是 **Amdahl 律**——RMSNorm 在整步推理只占 25%,所以 7× 局部 → 1.5× 全局;在 SFT 一步里只占 ~10%,所以只有 1.05-1.15× 端到端。\n",
658+ "\n",
659+ "**详见指导书第九章总结与讨论。** 提交 `results/notebook_inference.png` 和 `results/notebook_lora_sft.png` 作为本次实训交付物,SwanLab 的 run 链接也可以一并提交。"
660+ ]
661+ }
662+ ],
663+ "metadata": {
664+ "kernelspec": {
665+ "display_name": "base",
666+ "language": "python",
667+ "name": "python3"
668+ },
669+ "language_info": {
670+ "name": "python",
671+ "version": "3.12.2"
672+ }
673+ },
674+ "nbformat": 4,
675+ "nbformat_minor": 4
676+}
@@ -0,0 +1,24 @@
1+<div align="center" style="border: 1px solid #eee; padding: 20px; border-radius: 10px; margin-bottom: 20px; max-width: 320px; margin-left: auto; margin-right: auto;">
2+ 
3+![SwanLab](../images/swanlab.png)
4+ 
5+SwanLab x CANN 社区合作课程
6+ 
7+</div>
8+ 
9+---
10+ 
11+# 第 05 章 · 性能调优
12+ 
13+## 章节定位
14+ 
15+本章面向**已经跑通过 02 章 SFT / LoRA 的同学**,时长约 **45–60 分钟**,作为大模型课程通往 **AscendC 算子开发课程**的过渡节。
16+预期读者在学完本章后,能用 Amdahl 律解释"为什么单算子 ~7× 加速到端到端只剩 ~1.5×",能跑通一次别人写好的 fused AscendC kernel 在 Qwen3 上的接入与提速,并理解下一步为什么要自己动手写 kernel。
17+ 
18+## 计划节次
19+ 
20+| 节次 | 标题 | 状态 |
21+|------|------|------|
22+| 05.01 | 性能调优全景与 Amdahl 律 | 建设中 |
23+| [05.02](./05.02_swan_rmsnorm_acceleration.ipynb) | SwanRmsNorm AscendC 算子加速 Qwen3 微调 | 已发布 |
24+| 05.03 | 章节小结:从"接算子"走向"写算子" | 建设中 |
@@ -0,0 +1,83 @@
1+<div align="center" style="border: 1px solid #eee; padding: 20px; border-radius: 10px; margin-bottom: 20px; max-width: 320px; margin-left: auto; margin-right: auto;">
2+ 
3+![SwanLab](images/swanlab.png)
4+ 
5+SwanLab x CANN 社区合作课程
6+ 
7+</div>
8+ 
9+---
10+ 
11+# Swan LLM 大模型实战课程
12+ 
13+## 课程作者与联系方式
14+ 
15+- **作者**:韩翔宇(情感机器 SwanLab 实验室 AI 研究员)等
16+- **邮箱**:[pescn@115lab.club](mailto:pescn@115lab.club)
17+- **课程背景**:本课程内容来源于 CANN 社区与 SwanLab 团队正在开展的**线下启航营**,面向高校同学讲解昇腾算力上的大语言模型训练、对齐、推理与加速。如果你所在的学校 / 社团 / 实验室希望接入这套教材,或者希望在你们学校落地一期启航营,欢迎通过讨论区或邮件联系。
18+ 
19+## 课程简介
20+ 
21+本课程面向**高校在校学生**,结合正在开展的**线下启航营**实践内容,围绕大语言模型在昇腾 NPU 上的**基础理论 → 微调 → 强化学习 → 推理部署 → 性能调优**展开。我们希望通过这套课程,帮助同学们:
22+ 
23+- 建立大模型从训练到部署的完整心智模型,而不是孤立地学某一个工具
24+- 真正理解微调(SFT / LoRA)、强化学习对齐(RLHF / DPO / GRPO)的工程实现细节
25+- 在昇腾 NPU + CANN 的真实硬件上完成训练 / 推理,体会国产算力栈下的工程权衡
26+- 通过 AscendC 自定义算子优化实战,理解大模型训练 / 推理性能瓶颈的来源及优化思路
27+ 
28+## 适合人群
29+ 
30+> 建议学习者具备 Python 基础和线性代数基础。但 **不要求** 事先有 Ascend C 经验
31+ 
32+- 对大模型有兴趣,但还没真正跑通一次端到端训练 / 部署的本科生 / 研究生
33+- 已经在其他智算卡上跑过 transformers / trl,想了解昇腾 NPU 上有什么不一样的同学
34+- 想理解为什么大家都在写自定义算子,并亲手优化一次的同学
35+ 
36+## 课程目录
37+ 
38+| 章节 | 标题 | 内容概要 | 状态 |
39+|------|------|----------|------|
40+| [01](./01_llm_basic/) | 大语言模型基础理论介绍 | Transformer、自注意力、预训练 / SFT / RLHF 三段式、推理与 KV cache | 建设中 |
41+| [02](./02_llm_finetune/) | 大语言模型微调 | Qwen3 全参数 SFT、LoRA 微调、Loss Mask、AST + 可执行性评估 | 已发布部分节次 |
42+| [03](./03_llm_rl/) | 大语言模型强化学习 | RLHF / PPO、DPO、GRPO,以代码可执行性作为奖励信号的案例 | 建设中 |
43+| [04](./04_llm_inference_deploy/) | 大语言模型推理部署 | KV cache、continuous batching、量化、推理引擎与服务化 | 建设中 |
44+| [05](./05_performance_optimization/) | 性能调优 | AscendC 自定义算子接入 PyTorch、Amdahl 律、推理 / 训练端到端加速 | 已发布部分节次 |
45+ 
46+### 已发布节次速览
47+ 
48+| 节次 | 标题 |
49+|------|------|
50+| [02.04](./02_llm_finetune/02.04_qwen3_instruction_sft.ipynb) | Qwen3 基座模型指令微调(SFT) |
51+| [05.02](./05_performance_optimization/05.02_swan_rmsnorm_acceleration.ipynb) | SwanRmsNorm AscendC 算子加速 Qwen3 微调 |
52+ 
53+## 目录与命名规范
54+ 
55+每个章节目录的统一约定如下:
56+ 
57+```
58+0X_<chapter_slug>/
59+├── README.md # 章节定位、节次清单、运行说明
60+├── 0X.01_<section_slug>.ipynb # 节次 Notebook
61+├── 0X.02_<section_slug>.ipynb
62+├── data/ # 节次配套数据集(可选)
63+├── pdf/ # 节次配套 PDF(可选)
64+├── images/ # 章节配图(可选)
65+└── answer/ # 实操题参考答案(可选)
66+```
67+ 
68+## 运行环境
69+ 
70+| 项目 | 推荐配置 |
71+|------|----------|
72+| 硬件 | 昇腾 910C / 910B |
73+| 软件 | CANN ≥ 8.5、Python 3.10/3.11、PyTorch 2.x + `torch_npu` |
74+| 平台 | 推荐 [CANNLab](https://gitcode.com/) |
75+| 第三方 | SwanLab(实验可视化)、ModelScope(模型与数据集下载)、TRIO |
76+ 
77+## 反馈与贡献
78+ 
79+发现 Notebook 里的 bug、对某一章节有改进建议,或希望补充占位章节的内容,欢迎在 [cann/cann-learning-hub](https://gitcode.com/cann/cann-learning-hub) 仓库提 Issue 或 PR。
80+ 
81+---
82+ 
83+> 如果这套课程帮到了你,欢迎给本课程项目点个 Star ⭐,分享给更多对大模型感兴趣的同学!