Tech Blog

ลองทำ local QLoRA fine-tuning ด้วย GPU รุ่น Turing ตัวเดียว RTX 2070 (กำลังดำเนินการ)

LLM Fine-tuning QLoRA GPU AI

บทความนี้ กำลังดำเนินการ ผมเขียนบันทึกจุดที่ยืนอยู่ตอนนี้ (ตั้งค่าสภาพแวดล้อม → วัดค่าตั้งต้น → ทำให้ลูปการเทรนรันได้) โดยไม่ขัดเกลาให้ดูดีหลังจบ ส่วนการเปรียบเทียบความแม่นยำหลังเทรน จะเพิ่มเมื่อรันจบ

ทำอะไร และทำไม

ผมมีระบบ RAG ส่วนตัวที่เลี้ยงไว้ใช้เอง มันรับโน้ตดิบที่ผมโยนเข้าไป แล้ว จัดแยกอัตโนมัติเป็นชนิดต่าง ๆ — บทเรียน ความรู้ ค่านิยม การตัดสินใจเชิงออกแบบ — จากนั้นสรุปและจัดโครงสร้าง มัน index ทั้งหมดนั้นให้ค้นได้ด้วย hybrid ของ vector search กับ keyword search บวก re-ranking และ — นี่คือส่วนสำคัญ — มัน ฉีดเฉพาะชิ้นที่เกี่ยวข้องของความรู้ที่สะสมไว้ เข้าสู่ผู้ช่วย AI (เช่น Claude Code) ในทุก ๆ เทิร์นโดยอัตโนมัติ บทสนทนาที่ผมคุยก็ถูกดูดกลับเข้าไป จัดแยกใหม่ แล้วส่งกลับมา มันคือ ลูปปิดที่เปลี่ยนโน้ตกระจัดกระจายให้กลายเป็นความจำระยะยาวที่ AI ใช้ได้ (สมองที่สอง) อย่างต่อเนื่อง และผมเลี้ยงมันมาโดยวัดความแม่นยำของ retrieval เป็นตัวเลขตลอดทาง

ที่ทางเข้าของลูปนั้นคือขั้นตอน “การจัดแยก (classification)” ที่เพิ่งพูดถึง สิ่งที่ผมกำลังสร้างคราวนี้คือ classifier ที่ดูแลขั้นตอนนั้น ทำเป็น โมเดลขนาดเล็กที่ fine-tune แล้วและรันแบบ local

มีแรงจูงใจสามข้อ

  1. อยากเรียนรู้ fine-tuning (วิธีเทรน weights) จริง ๆ ด้วยการลงมือทำเองตั้งแต่ต้นจนจบ
  2. การจำแนกนั้นตอนนี้ฝากให้โมเดลใหญ่อเนกประสงค์ทำทุกครั้ง ผมจึงอยาก แทนที่ด้วยโมเดลเล็กเฉพาะทางที่ถูกกว่า เร็วกว่า และเสถียรกว่า
  3. และเหนือสิ่งอื่นใด — ข้อมูลเทรนคือโน้ตส่วนตัวของผมเอง ผมจึงไม่อยากอัปโหลดขึ้นคลาวด์ภายนอก ด้วยเหตุนี้ผมจึงตั้ง “ทำให้จบในแล็ปท็อปของตัวเองทั้งหมด” เป็นข้อจำกัดข้อแรก

ฮาร์ดแวร์ในมือคือเกมมิ่งแล็ปท็อปหนึ่งเครื่อง ที่มี GPU รุ่น Turing คือ RTX 2070 (VRAM 8GB) สำหรับการเทรนมันดูด้อยกว่ารุ่นล่าสุด แต่การพิสูจน์ว่า “ไปได้ไกลแค่ไหนด้วยเครื่องมือที่มีอยู่แล้ว” ก็เป็นจุดหนึ่งของการทดลองนี้

ภาพรวมของกระบวนการเป็นแบบนี้ สิ่งที่ผมทำเป็นไปตามวินัยเดียวกับที่ผมรันทุกวันบน RAG ของตัวเอง: “วัด → เปลี่ยน → วัด”

 ┌──────────┐   ┌────────────┐   ┌────────────┐   ┌────────────┐   ┌──────────────┐
 │ 1. Data  │ → │ 2.Baseline │ → │ 3. Train   │ → │ 4.Evaluate │ → │ 5. Compare   │
 │   prep   │   │  (before)  │   │  (QLoRA)   │   │  (after)   │   │ before/after │
 └──────────┘   └────────────┘   └────────────┘   └────────────┘   └──────────────┘
     same "measure → change → measure" loop as before/after evaluation

ขั้นที่จืดที่สุดและสำคัญที่สุด: เผชิญหน้ากับข้อมูล

พอได้ยินคำว่า “fine-tuning” คนมักนึกถึงอัลกอริทึมการเรียนรู้ แต่สิ่งที่กินเวลาจริง ๆ คือ การเตรียมข้อมูลเทรน เมื่อสำรวจข้อมูลในมืออย่างซื่อสัตย์ เป็นแบบนี้

  • ตั้งแต่ต้นมัน น้อยและเอนเอียง (ปนกันระหว่างชนิดที่มีเยอะ กับชนิดที่มีแค่สิบกว่าชิ้น)
  • ไม่มีข้อมูลเฉลยสำหรับคลาส “ไม่เข้าพวกไหนเลย” ผมจึงต้องสร้างเอง
  • โน้ตที่จัดรูปแล้ว กับโน้ตดิบที่อยากให้จำแนกจริง ๆ มี เนื้อสัมผัสของข้อความที่ต่างกัน

ข้อมูลสะอาดไม่ตกลงมาจากฟ้า งานเตรียมที่เปื้อนโคลนนี้แหละคือเนื้องานจริง และการตัดสินใจว่าจะตัดมุมตรงไหน ส่งผลต่อผลลัพธ์โดยตรง สุดท้ายผมจัดข้อมูลให้อยู่ในรูปที่ใช้ได้ เตรียมตัวอย่างเทรนระดับไม่กี่ร้อยชิ้น แล้วแบ่งส่วนหนึ่งไว้ประเมินผล


กฎเหล็ก: วัด “จุดที่ยืนอยู่” ก่อนเทรน

พอข้อมูลพร้อม ก็ไม่ใช่ว่าจะเริ่มเทรนทันที ถ้าจะปรับปรุงอะไร ต้องมี ตัวเลขก่อนการปรับปรุง ก่อน ถ้าไม่มี ก็พูดไม่ได้ว่า “ดีขึ้น” หรือ “แย่ลง”

ผมจึงให้โมเดลใหญ่อเนกประสงค์จำแนกชุดประเมินตามสภาพ แล้ววัดความแม่นยำ ผลคือ ราว 48% โดยรวม เมื่อแยกตามคลาส ผมยังพบว่ามัน พลาดโน้ตชนิดหนึ่งไปแทบทั้งหมด “ยังมีที่ให้โตอีกเยอะ” กลายเป็นสิ่งที่เห็นเป็นตัวเลข ไม่ใช่ความรู้สึก — และแค่นั้นก็ทำให้ทุกการตัดสินใจหลังจากนี้ไม่สั่นคลอน


หัวใจของโค้ด: โครงสร้าง QLoRA

นี่คือแก่นของการทำให้พอดีกับขีดจำกัด 8GB บีบตัวฐานเป็น 4-bit แล้วแช่แข็ง และเทรนเฉพาะ LoRA adapter เล็ก ๆ

   ┌────────────────────────────────────┐
   │  Base model — FROZEN (4-bit)  ❄     │       ┌────────────────────┐
   │  billions of weights, NOT updated   │ ────► │ LoRA adapter        │
   │                                     │       │ (trainable, tiny)   │
   └────────────────────────────────────┘       └────────────────────┘
       Train only the small adapter (~0.1–1%) → fits a modest GPU
# Load the base in 4-bit (NF4) = a large cut in VRAM usage
bnb = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_use_double_quant=True,
    bnb_4bit_compute_dtype=torch.float16,  # Turing (RTX 2070) can't do bf16 -> fp16
)
model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-3B-Instruct",
    quantization_config=bnb,
    device_map="auto",
    dtype=torch.float16,
)
model = prepare_model_for_kbit_training(model, use_gradient_checkpointing=True)

# Add only a small trainable adapter (LoRA) on top of the frozen base
peft_config = LoraConfig(
    r=16, lora_alpha=32, lora_dropout=0.05, bias="none", task_type="CAUSAL_LM",
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
                    "gate_proj", "up_proj", "down_proj"],
)

จุดสำคัญ — เริ่มจาก compute_dtype — คือ ระบุรูปแบบตัวเลขอย่างชัดเจนตั้งแต่ต้น ถ้าปล่อยไว้คลุมเครือ ความต่างของรุ่น GPU จะมาสะดุดขาคุณทีหลัง (หัวข้อถัดไป)


ไคลแม็กซ์: ผมล้มที่ “สภาพแวดล้อม” ไม่ใช่ที่โค้ด

ตรงนี้คือจุดที่งานจริงเริ่ม — แต่สิ่งที่ทำให้สะดุดไม่ใช่อัลกอริทึม กลับเป็น สภาพแวดล้อมระดับพื้น ทุกครั้งไป ตัวขั้นตอนมีในเอกสารทางการและบทความ แต่พอรันบนเครื่องตัวเอง กำแพงที่ไม่มีในตำราก็โผล่มา ผมเรียงตามลำดับที่เจอ

#อาการแท้จริงคืออะไรแก้อย่างไร
1error ชนิดที่ไม่รู้จักตอน import ไลบรารีเวอร์ชันที่ไม่เข้ากันระหว่างไลบรารีแกนหลัก (ตัวหนึ่งใหม่เกิน ชนิดที่อีกตัวต้องการหายไป)จัดเวอร์ชันไลบรารีพื้นฐานให้เข้ากับชั้นบน
2โหลดไฟล์ config ตายเพราะ decoding errorencoding เริ่มต้น ของ Windows ภาษาญี่ปุ่นอ่านไฟล์ UTF-8 ไม่ได้สั่งให้โปรแกรมเริ่มในโหมด UTF-8
3API การเทรนไม่รับอาร์กิวเมนต์API เปลี่ยนข้ามเวอร์ชัน ของไลบรารี (ชื่ออาร์กิวเมนต์ถูกเปลี่ยน)ปรับการเรียกให้ตรงกับธรรมเนียมของเวอร์ชันที่ติดตั้ง
4หยุดที่ training step ที่ 1บน Turing (RTX 2070) การคำนวณรูปแบบตัวเลขใหม่ (bf16) ยังไม่ถูก implementระบุการจัดการตัวเลขชัดเจน และถอดกลไก mixed precision เพื่อเลี่ยง

ข้อ 4 ดื้อเป็นพิเศษ: แค่ระบุ dtype ชัดเจนยังไม่พอ สุดท้าย การปิด mixed precision (กลไกเร่งความเร็ว) ทิ้งไปเลย ต่างหากที่ทำให้ลูปการเทรนขยับได้ ผมเพิ่งรู้หลังเหยียบเข้าไปว่า นี่คือหลุมพรางคลาสสิกของรุ่น Turing

ในรูปโค้ด การตัดสินใจมีแค่สองบรรทัด แต่การพูดได้ว่า “ทำไมต้องปิดตรงนี้” คือความต่างระหว่างการคัดลอกกับการสร้างเอง

args = SFTConfig(
    per_device_train_batch_size=1,   # smallest, to fit 8GB; earn effective batch via accumulation
    gradient_accumulation_steps=8,
    num_train_epochs=3,
    learning_rate=2e-4,
    fp16=False, bf16=False,          # <- decision: on Turing, mixed precision (fp16) trips on the gradient.
                                     #    give up speed, prioritize "it runs reliably" first
    optim="paged_adamw_8bit",        # memory-frugal optimizer
    gradient_checkpointing=True,     # redo part of the compute to save activation memory
)

ข้ามอันหนึ่งได้ อันถัดไปก็โผล่ มันบั่นทอนใจได้ แต่ การจดทุกจุดที่ติดและ “ทำไมจึงแก้แบบนั้น” เปลี่ยนมันเป็นสินทรัพย์ที่ไม่หายไป สำหรับคนถัดไปที่จะลองตั้งค่าแบบเดียวกัน — เช่นตัวผมเองในอีกไม่กี่เดือนข้างหน้า — บันทึกนี้เองคือแผนที่


การเทรนรันได้ แต่ความจริงคือ “เวลา”

หลังข้ามทุกการสะดุด ลูปการเทรนก็เริ่มหมุนในที่สุด loss (ตัวเลขบอกว่าโมเดลผิดมากแค่ไหน) ถูกคำนวณอย่างถูกต้องและค่อย ๆ ลดลง ความโล่งใจในวินาทีนั้นดีเสมอ ไม่ว่าจะรู้สึกกี่ครั้ง

แต่สิ่งสุดท้ายที่ขวางอยู่คือ ความเร็ว บน RTX 2070 (Turing) ราว 100 วินาทีต่อ step การรันจนจบประเมินไว้ที่ ราว 6 ชั่วโมง และตลอดเวลานั้น GPU ถูกยึดครอง เครื่องก็ร้อนต่อเนื่อง

ตรงนี้ผมตัดสินใจตามความเป็นจริงข้อหนึ่ง หยุดพักก่อน และในการรันครั้งหน้า จะตัดสินใจเด็ดขาดเรื่องความยาวข้อความและจำนวน epoch เพื่อย่อให้อยู่ในเวลาที่สมจริง แทนที่จะฝืนรัน 6 ชั่วโมงด้วยลัทธิสมบูรณ์แบบ “วิ่งหนึ่งรอบให้ได้ผลก่อน ถ้าดูดีค่อยรันของจริง” — ผมตัดสินว่านั่นคือทางที่เรียนรู้ได้เร็วกว่า


จุดที่ยืนอยู่ตอนนี้ (กำลังดำเนินการ)

  • ✅ สร้างสภาพแวดล้อมการเทรน แยกต่างหากโดยไม่แตะ RAG ตัวจริง (GPU ถูกตรวจพบแล้วด้วย)
  • ✅ จัดข้อมูลเทรนและ แบ่งสำหรับเทรนและประเมิน
  • วัดตัวเลข before (ราว 48% โดยรวม)
  • ยืนยันว่าลูปการเทรนรันได้ (ข้ามทุกการสะดุด)
  • ⏳ รันการเทรนจนจบ → ออกตัวเลข after แล้วเปรียบเทียบ ← ครั้งหน้าเริ่มจากตรงนี้

นั่นคือยัง “ระหว่างทาง” แต่ผมข้ามภูเขาที่ชันที่สุด — การตั้งค่าสภาพแวดล้อม — มาแล้ว ที่เหลือคือหาเวลามารันให้จบ และวัดผลอย่างซื่อสัตย์


ปิดท้าย: สิ่งที่คัดลอกได้ และสิ่งที่คัดลอกไม่ได้

“ใช้ AI แล้วใคร ๆ ก็ทำได้ง่าย ๆ” คนมักพูดกัน ครึ่งหนึ่งเป็นจริง โค้ดคัดลอกได้จริง ทั้งขั้นตอนทางการและตัวอย่าง ค้นก็เจอ

แต่ มีสิ่งที่คัดลอกไม่ได้ ติดตรงไหน ทิ้งอะไร และทำไมจึงเลือกค่าพวกนั้น — คือ การตัดสินใจ ความจริงของ 6 ชั่วโมงนี้ และคุณจะตัดสินใจย่อมันอย่างไรเมื่อเผชิญหน้า สิ่งที่เกิดขึ้นบน GPU รุ่น Turing วิธีเลี่ยงกับดัก character encoding “สิ่งที่เข้าใจหลังเหยียบเท่านั้น” เหล่านี้ไม่มีเขียนในคู่มือ

คุณค่าเหลืออยู่ตรงนั้นเสมอ ผมจึงบันทึก รวมทั้งการสะดุดด้วย เมื่อรันจบ ผมจะเขียนต่อ พร้อมตัวเลข after


การจัดระเบียบเชิงแนวคิด — prompt tuning กับ fine-tuning ต่างกันตรงไหน — อยู่ในอีกบทความหนึ่ง

🔗 ฉบับแนวคิด: “prompt tuning” กับ “fine-tuning” ต่างกันตรงไหน


อภิธานศัพท์ (คำที่ใช้ในบทความนี้)

คำความหมาย
fine-tuningอัปเดต weights ของโมเดลด้วยตัวอย่าง เพื่อเทรนพฤติกรรม
personal RAGระบบความจำระยะยาวที่สร้างเอง ค้นความรู้ที่สะสมแล้วส่งให้ AI เป็น context
vector search / hybrid search / re-rankingค้นเอกสารทั้งด้านความหมาย (vector) และคำ (keyword) แล้วจัดลำดับผลบนสุดใหม่ก่อนส่ง
training dataตัวอย่าง “input → เฉลย” สำหรับเรียนรู้ ในบทความนี้ระดับไม่กี่ร้อยชิ้น
QLoRAวิธีประหยัดหน่วยความจำ: quantize ตัวฐานเป็น 4-bit แล้วแช่แข็ง เทรนเฉพาะ LoRA เล็ก ๆ
quantization / 4-bit / NF4แทน weights ด้วยบิตต่ำเพื่อประหยัด VRAM (NF4 คือรูปแบบ 4-bit แบบหนึ่ง)
VRAMหน่วยความจำ GPU ที่อยู่ของโมเดลและการคำนวณ GPU ในบทความนี้คือ 8GB
LoRA adapterเมทริกซ์เล็ก ๆ ที่เทรนได้ ซึ่งเพิ่มบนตัวฐานที่แช่แข็ง
frozenไม่ถูกอัปเดตระหว่างเทรน (ประหยัด VRAM และการคำนวณ)
loss / gradientตัวเลขบอกว่าผิดแค่ไหน / วิธีขยับ weight แต่ละตัว
mixed precision (fp16 / bf16)ลดความละเอียดตัวเลขเพื่อเร่งความเร็วและประหยัดหน่วยความจำ; bf16 มุ่ง GPU รุ่นใหม่กว่า (Turing ทำไม่ได้)
gradient accumulation / gradient checkpointingรวมแบตช์เล็กเพื่อให้ได้ effective batch / ทำการคำนวณบางส่วนซ้ำเพื่อประหยัด activation memory
optimizer (paged_adamw_8bit)กลไกที่อัปเดต weights; เวอร์ชัน 8-bit ประหยัดหน่วยความจำ
baseline (before)ค่าอ้างอิงก่อนปรับปรุง (ที่นี่คือความแม่นยำของโมเดลอเนกประสงค์ ~48%)
recall / accuracyตัวชี้วัดการประเมินสำหรับการจำแนก
pipeline (Data prep → Baseline → Train → Evaluate → Compare)กระบวนการ: เตรียมข้อมูล → วัดล่วงหน้า → เทรน → ประเมิน → เปรียบเทียบ

ส่งข้อความได้ตามสบาย

กรุณาส่งข้อความ หากมีความคิดเห็น หรือคำถาม