Tìm hiểu về Speculative Decoding, phương pháp tăng tốc LLM gấp 3 lần

Cuối năm 2022, ba nhà nghiên cứu Yaniv Leviathan, Matan Kalman và Yossi Matias tại Google Research ở Israel đăng lên arXiv bài báo Fast Inference from Transformers via Speculative Decoding. Họ tuyên bố tìm được cách giúp LLM trả lời nhanh gấp đôi hoặc gấp ba với claim rằng output của phương pháp này không thay đổi so với thông thường, giữ nguyên chính xác phân phối xác suất ban đầu.

Đầu năm 2023, một nhóm khác tại DeepMind do Charlie Chen dẫn đầu công bố bài báo Accelerating Large Language Model Decoding with Speculative Sampling với thuật toán cốt lõi của họ gần như giống hệt. Tuy nhiên nhóm tác giả ghi chú rõ rằng công trình được thực hiện độc lập và cùng thời điểm với nhóm Leviathan.

image - quochung.cyou PTIT

Các khái niệm

Token

Khi ta gõ câu hỏi vào ChatGPT hay Claude, model không đọc từng chữ cái hay từng từ. Nó đọc token, là những mảnh văn bản có kích thước không đều nhau.

Ví dụ, câu tiếng Anh:

"The capital of France is Paris."

Sau khi qua tokenizer (một bộ chia chữ thành token), có thể trở thành:

["The", " capital", " of", " France", " is", " Paris", "."]

Mỗi token được gán một ID số duy nhất đại diện cho mảnh văn bản đó. Ví dụ " Paris" có ID 12437 và "." có ID 13

Với tiếng Việt, tokenizer có thể hoạt động khác một chút

"Hôm nay trời rất đẹp"
→ ["Hôm", " nay", " tr", "ời", " rất", " đẹp"]

Một từ tiếng Việt có dấu có thể bị chia thành 2-3 token. Đây là lý do model xử lý tiếng Việt thường tốn thêm token hơn tiếng Anh cho cùng một nội dung.

Forward pass

Khi model nhận một chuỗi token, nó sẽ chạy chuỗi này qua hàng chục lớp (layers) mạng neural tính toán nối tiếp nhau. Quá trình này được gọi là forward pass.

Input: "Hôm nay trời rất"

Forward pass qua 32 layer của model...

Output: bảng điểm cho ~100.000 token trong vocabulary
  "đẹp"    45%
  "nóng"   25%
  "lạnh"   20%
  "xanh"    3%
  ...       7%

Bảng điểm này được gọi là probability distribution (phân phối xác suất). Nó cho biết model đánh giá token nào có khả năng đứng tiếp theo cao nhất cùng với tỷ lệ phần trăm tương ứng.

Tổng tất cả xác suất luôn bằng 100%. Thay vì khẳng định một đáp án đúng, model chỉ đưa ra khả năng phần trăm cho từng trường hợp, như 45% cho từ “đẹp”, 25% cho từ “nóng”, hoặc 20% cho từ “lạnh”.

Chọn token: greedy vs sampling

Sau khi có bảng xác suất, cần một quy tắc chọn token nào sẽ được dùng.

Greedy: chọn token có xác suất cao nhất.

"đẹp" 45%  ← luôn chọn cái này
"nóng" 25%
"lạnh" 20%

Với cùng một input, phương pháp greedy luôn cho ra output giống hệt nhau. Đây là cách tiếp cận deterministic và dễ dự đoán.

Sampling: chọn ngẫu nhiên theo tỷ lệ xác suất.

45% trường hợp → chọn "đẹp"
25% trường hợp → chọn "nóng"
20% trường hợp → chọn "lạnh"

Việc này giống như tung một con xúc xắc 100 mặt với 45 mặt ghi “đẹp”, 25 mặt “nóng” và 20 mặt “lạnh”. Mỗi lần tung sẽ cho ra kết quả khác nhau nhưng vẫn bám sát tỷ lệ tổng thể.

Sampling được dùng khi muốn output đa dạng, để cùng một câu hỏi mỗi lần trả lời hơi khác nhau. Nhiều chatbot dùng sampling (hoặc sampling với temperature thấp) để câu trả lời bớt máy móc.

Sự phân biệt này quan trọng vì speculative decoding phải bảo đảm output cuối cùng khớp hoàn toàn với target model, bất kể model dùng greedy hay sampling. Việc này khá đơn giản với greedy nhưng lại đòi hỏi một thuật toán phức tạp hơn nhiều đối với sampling, chi tiết sẽ được nói rõ ở phần tiếp.

Autoregressive

Cách làm ban đầu của các LLM Model là autoregressive, hay sinh token tuần tự, nghĩa là token phía sau luôn phụ thuộc vào token phía trước.

Ví dụ sinh 3 token:

Lần 1:
  Input:  "The capital of France is"
  Model chạy forward pass...
  Output: " Paris"

Lần 2:
  Input:  "The capital of France is Paris"
  Model chạy forward pass...
  Output: "."

Lần 3:
  Input:  "The capital of France is Paris."
  Model chạy forward pass...
  Output: <END>

Bởi vì rõ ràng theo một cách suy nghĩ đơn giản, các từ tiếp theo ta muốn nghĩ ra nên có sự phụ thuộc vào từ trước đó.

Sự phụ thuộc này tạo ra một chuỗi tuần tự bắt buộc:

Token 1 → cần biết token 1 để tính token 2
Token 2 → cần biết token 2 để tính token 3
Token 3 → cần biết token 3 để tính token 4
...

Vấn đề thời gian

Một model có 70B tham số (parameters) đồng nghĩa với việc hệ thống phải đọc 70 tỷ con số trọng số từ bộ nhớ GPU mỗi lần chạy.

Để sinh một token mới, GPU phải:

  1. Đọc 70 tỷ trọng số từ bộ nhớ
  2. Thực hiện phép nhân ma trận với token đầu vào
  3. Lặp lại qua 32 layers
  4. Ra một bảng xác suất

GPU hiện đại như H100 có sức mạnh tính toán khổng lồ, lên tới hàng trăm nghìn tỷ phép tính mỗi giây. Phần lớn khả năng này bị lãng phí khi hệ thống chỉ xử lý một token. Sau khi đọc xong 70 tỷ trọng số, GPU chỉ áp dụng chúng cho một vị trí rồi bỏ qua, sau đó lại phải đọc lại ngần ấy trọng số cho vị trí tiếp theo.

Giống như ta mở quyển từ điển 70 tỷ trang, đọc hết, chỉ để tra một từ. Rồi đóng sách, mở lại, đọc hết 70 tỷ trang, tra một từ nữa.

Quá trình này được gọi là trạng thái memory-bound, nơi tốc độ hệ thống bị giới hạn bởi tốc độ đọc bộ nhớ thay vì tốc độ tính toán. GPU lúc này rơi vào trạng thái “đói việc” vì khả năng tính toán dư thừa nhưng dữ liệu không được nạp đủ nhanh.

Đây chính là kẽ hở mà speculative decoding khai thác.

image 1 - quochung.cyou PTIT

Hidden state

Mỗi token đi qua một layer của Transformer sẽ tạo ra một vector số chứa khoảng 4.096 hoặc 8.192 giá trị. Có thể hiểu đơn giản dãy số này mô phỏng mức độ hiểu của model về toàn bộ bối cảnh tại thời điểm này. Có thể đọc thêm bài sau:

Token "is" đi qua:
  Layer 1  → hidden state h¹  (4096 số)
  Layer 2  → hidden state h²  (4096 số)
  ...
  Layer 32 → hidden state h³² (4096 số)

Mỗi layer tinh chỉnh hidden state, thêm vào nó sự hiểu biết sâu hơn. Có thể là layer đầu có thể nắm cú pháp, layer giữa hiểu ngữ nghĩa, layer cuối tổng hợp toàn bộ. Hidden state cuối cùng là h³² sẽ đi qua lớp chuyển đổi LM head để biến vector 4096 số thành bảng xác suất cho khoảng 100.000 token:

h³² (4096 số)
     ↓
  LM head
     ↓
Bảng xác suất cho mọi token tiếp theo
  " Paris"   80%
  " Lyon"     5%
  " located"  3%
  ...

Ý tưởng cốt lõi “Đoán trước, kiểm tra sau” (draft-verify)

Đến đây, chúng ta đã biết nguyên nhân LLM sinh token chậm là do hệ thống phải chạy model lớn cho từng token một, trong khi GPU lại đang lãng phí phần lớn sức mạnh tính toán.

Speculative decoding được xây dựng dựa trên một nhận xét đơn giản nhưng cực kỳ hiệu quả:

Rất nhiều token tiếp theo thực ra rất dễ đoán, ví dụ như dấu chấm, dấu phẩy, dấu ngoặc, xuống dòng, cú pháp JSON, các cụm từ thông dụng, hoặc phần lặp lại từ prompt. Model lớn không cần phải vất vả tính toán cho từng token hiển nhiên như vậy.

Từ đó ta có một hệ thống sơ khai speculative decoding bao gồm hai thành phần:

Draft model (drafter): Model nhỏ hơn nhiều, ví dụ loại 1B parameters. Nó nhanh hơn dù chất lượng kém hơn. Nhiệm vụ của nó chỉ là đề xuất các token dự tuyển (candidate) và hoàn toàn không có quyền quyết định output cuối cùng.

Target model (model lớn): Đây là model ta thật sự muốn dùng, ví dụ loại 70B parameters. Nó cho ra chất lượng cao nhưng chậm. Điểm mấu chốt là output cuối cùng gửi cho người dùng bắt buộc phải giống hệt output của model này.

Một vòng speculative decoding

Ta hãy thử đi qua lại ví dụ ban đầu

Prompt: "The capital of France is"

Giả sử target model (70B) sẽ sinh ra: " Paris" "." <END>

Bước 1: Drafter đề xuất 4 candidate

Draft model nhỏ (1B) chạy autoregressive, nghĩa là nó cũng phải sinh từng token một giống như target:

Draft pass 1: "The capital of France is"          → " Paris"
Draft pass 2: "The capital of France is Paris"     → "."
Draft pass 3: "The capital of France is Paris."    → " It"
Draft pass 4: "The capital of France is Paris. It" → " is"

Dù có 4 draft pass, thời gian xử lý sẽ nhanh hơn vì model nhỏ.

Những token này mới chỉ là các candidate nội bộ và chưa token nào được gửi cho người dùng.

Candidate: [" Paris", ".", " It", " is"]

Bước 2: Target kiểm tra cả 4 candidate trong MỘT pass

Target model sẽ nhận toàn bộ chuỗi candidate:

Input: ["The", " capital", " of", " France", " is",
        " Paris", ".", " It", " is"]
                  └── 4 candidate ──┘

Sau đó, target chạy một forward pass duy nhất trên toàn bộ input để tính xác suất cho từng vị trí:

  • The capital of France is
  • The capital of France is Paris
  • The capital of France is Paris.
  • The capital of France is Paris. It

Lúc này ta có thể chạy song song, với việc giả định rằng: nếu model sẽ sinh ra cả đoạn như vậy thì token tiếp theo sẽ là gì

Sau "The capital of France is"              → target chọn " Paris"
Sau "The capital of France is Paris"        → target chọn "."
Sau "The capital of France is Paris."       → target chọn <END>
Sau "The capital of France is Paris. It"    → target chọn khác

Bước 3: So sánh từ trái sang phải

Vị trí  Draft       Target       Kết quả
  1     " Paris"    " Paris"     ✓ Accept
  2     "."         "."          ✓ Accept
  3     " It"       <END>        ✗ Reject (dùng <END> từ target)
  4     " is"       —            ✗ Discard (không kiểm tra)

Tại vị trí số 3, draft đề xuất ” It” nhưng target lại yêu cầu <END>. Vì draft sai nên hệ thống sẽ bỏ ” It” và sử dụng <END> từ target. Token thứ 4 là ” is” cũng bị loại bỏ do nó được tính toán dựa trên ” It”, mà ” It” thì đã bị loại.

Kết quả vòng này:

Output: " Paris" "." <END>

Kết quả này hoàn toàn khớp với output mà target tự sinh ra. Giả sử với tốc độ là

Một target pass (70B): ~20 ms Một draft pass (1B): ~0.5 ms

Về mặt thời gian:

Cách thường:  3 target pass = 3 × 20 = 60 ms
Speculative:  4 draft pass + 1 target verify
              = 2 + 22 = 24 ms

Speedup: 60 / 24 ≈ 2.5×
image 2 - quochung.cyou PTIT

Tại sao target kiểm tra được nhiều vị trí trong một lần?

Chúng ta đã thống nhất rằng việc sinh token phải tuần tự vì token sau phụ thuộc vào token trước. Vậy target làm cách nào để kiểm tra 4 vị trí song song?

Sự khác biệt nằm ở hai khái niệm: sinh (generate) và chấm điểm (score/verify).

Khi sinh: Token tương lai chưa tồn tại. Model bắt buộc phải đoán token đầu tiên, sau đó mới có dữ liệu để đoán token thứ hai. Tính tuần tự này là thực tế và không thể phá vỡ.

Khi chấm điểm: Drafter đã cung cấp sẵn cả 4 candidate. Target không cần phải tìm kiếm chúng nữa. Việc của nó chỉ là tính xem nếu đây là token tiếp theo thì xác suất sẽ là bao nhiêu.

Mạng Transformer có một cơ chế mang tên causal mask. Trong cùng một forward pass, cơ chế này hoạt động như sau:

Vị trí " Paris" chỉ thấy: prefix (prefix ở đây là cả câu trước đó - The capital ....)
Vị trí "."      chỉ thấy: prefix + " Paris"
Vị trí " It"    chỉ thấy: prefix + " Paris" + "."
Vị trí " is"    chỉ thấy: prefix + " Paris" + "." + " It"

Mỗi vị trí chỉ có thể nhìn được các vị trí trước nó mà không thể nhìn các vị trí sau. Nhờ cơ chế này, kết quả tính toán tại mỗi vị trí hoàn toàn trùng khớp với việc chạy riêng lẻ từng vị trí một.

Hơn nữa, do toàn bộ input đã có giá trị cụ thể từ các candidate có sẵn, GPU có thể tính toán tất cả các vị trí song song trong cùng một phép nhân ma trận lớn.

Đây chính xác là loại phép tính mà Transformer thực hiện khi xử lý prompt ban đầu trong giai đoạn gọi là prefill. Quá trình verification ở đây gần giống một đoạn prefill ngắn với 4 token thay vì hàng trăm token.

Thêm vào đó, do GPU đang trong trạng thái memory-bound khi giải mã một token, việc đọc 70 tỷ trọng số để xử lý 4 token không tốn kém hơn bao nhiêu so với việc xử lý 1 token. Trọng số đằng nào cũng đã được đọc lên, GPU chỉ việc dùng luôn cho cả 4 vị trí.

Chi phí verify 4 candidate ≈ chi phí sinh 1 token
(Dù không chính xác tuyệt đối nhưng cùng chung một bậc chi phí)

Dù vậy, cần lưu ý là nếu ” It” bị từ chối tại vị trí thứ 3, kết quả tại vị trí thứ 4 cũng sẽ tự động bị bỏ. Nguyên nhân là do vị trí 4 được tính toán với điều kiện ” It” tồn tại. Một khi ” It” đã bị loại bỏ, bối cảnh thực tế (prefix) đã thay đổi hoàn toàn.

image 3 - quochung.cyou PTIT

Draft model kém hơn target, vậy chất lượng output có giảm không?

Câu trả lời là không. Draft model 1B đúng là sẽ kém hơn target 70B và các đề xuất candidate của nó hoàn toàn có thể sai lệch. Tuy nhiên, output của draft không bao giờ được gửi thẳng cho người dùng.

Hãy tưởng tượng drafter là một thực tập sinh phụ trách viết bản nháp, còn target là tổng biên tập có quyền ký duyệt. Thực tập sinh có thể mắc lỗi, nhưng điều đó không thành vấn đề vì mọi văn bản đều phải qua tay tổng biên tập kiểm tra trước khi xuất bản.

Quy tắc áp dụng rất đơn giản:

Nếu draft token = token target chọn:
    dùng draft token (vì nó bằng target token)

Nếu draft token ≠ token target chọn:
    bỏ draft token
    dùng target token thay thế

Dù trong trường hợp nào đi nữa, token được hệ thống chốt (commit) luôn luôn giống hệt token mà target chọn.

Chuyện gì xảy ra nếu draft đoán sai ngay từ token đầu tiên?

Ví dụ draft đề xuất chuỗi candidate sau:

"Lyon" "is" "beautiful"

Khi target tiến hành kiểm tra vị trí đầu tiên:

Draft:  "Lyon"
Target: "Paris"
→ Reject "Lyon"
→ Discard "is" và "beautiful"
→ Output "Paris" từ target

Vòng lặp tiếp theo sẽ bắt đầu lại từ "...is Paris" để draft thử lại lần nữa.

Kết quả lúc này vẫn hoàn toàn giống với output của target. Tuy nhiên, vòng chạy vừa rồi không hề mang lại tốc độ nào, thậm chí nó còn chậm hơn so với mức baseline do hệ thống phải tốn thêm thời gian chạy draft.

Speculative = 2ms (draft) + 22ms (verify) = 24 ms
Baseline    = 20 ms (khi chỉ chạy 1 target pass)

→ Chậm hơn 4ms

Qua đó có thể thấy chất lượng của draft chỉ ảnh hưởng đến tốc độ, chứ không làm suy giảm chất lượng đầu ra:

Draft tốt  → chất lượng bằng target, tốc độ nhanh
Draft kém  → chất lượng bằng target, ít nhanh hơn hoặc thậm chí chậm hơn baseline

Tại sao model nhỏ vẫn hữu ích khi năng lực của nó kém hơn?

Từ “kém hơn” không đồng nghĩa với việc model nhỏ đoán sai mọi token. Khi phần tiền tố (prefix) đã rõ ràng, rất nhiều token thực tế cực kỳ dễ đoán trước:

Dấu chấm kết thúc một câu hoàn chỉnh
Dấu phẩy nối trong danh sách
Dấu ngoặc đóng nối tiếp sau ngoặc mở
Ký tự xuống dòng sau mỗi đoạn văn
Các thành phần cú pháp JSON như dấu hai chấm, ngoặc vuông, ngoặc nhọn
Các thành phần cú pháp code như dấu chấm phẩy, từ khóa return, dấu ngoặc nhọn đóng
Các cụm từ liên kết thông dụng như "in order to", "for example"
Những đoạn văn bản lặp lại từ prompt trong tác vụ chỉnh sửa code hoặc viết lại câu

Model lớn thực sự chỉ cần thiết để giải quyết những vị trí khó, ví dụ như:

Lựa chọn đáp án trong các bài toán reasoning logic
Thực hiện các phép tính toán học
Chốt tên riêng, số liệu cụ thể hay hành động chính xác
Quyết định cấu trúc câu ở những vị trí còn mơ hồ

Drafter không cần phải thông minh ngang ngửa target. Nhiệm vụ của nó chỉ là đoán trúng trùng khớp với target đủ thường xuyên. Khi tỷ lệ trúng đủ cao, lượng target pass tiết kiệm được sẽ bù đắp dư dả cho lượng chi phí dùng để chạy draft.

Modified rejection sampling

Phần trước đã giải thích trường hợp greedy, nơi target luôn chọn token có xác suất cao nhất nên việc so sánh rất đơn giản. Tuy nhiên, nhiều ứng dụng LLM thực tế lại dùng sampling. Đây là lúc speculative decoding thực sự bộc lộ sự phức tạp.

Vấn đề mới

Với phương pháp greedy, target chỉ có một token đúng duy nhất là token có xác suất cao nhất:

Target:  60% Paris, 30% London, 10% Rome
Greedy → luôn chọn Paris

Việc so sánh lúc này vô cùng dễ dàng:

Draft Paris  → accept
Draft London → reject, thay bằng Paris

Thế nhưng với sampling, target không khẳng định “đáp án là Paris”. Target yêu cầu một tỷ lệ phân bổ:

60% trường hợp phải ra Paris
30% trường hợp phải ra London
10% trường hợp phải ra Rome

Lúc này, cả Paris, London và Rome đều là những output hợp lệ. Hệ thống không có token “sai”, nó chỉ có tỷ lệ.

Vấn đề nảy sinh là draft model thường có phân phối khác với target:

              Target p     Draft q
Paris           60%          50%
London          30%          40%
Rome            10%          10%

Nếu ta lấy thẳng output của draft, phân phối sẽ là 50/40/10, hoàn toàn sai lệch so với phân phối mục tiêu 60/30/10 của target.

Ngược lại, nếu ta chỉ accept khi draft khớp với lựa chọn ưu tiên nhất của target (Paris), những lần draft đề xuất London sẽ luôn bị reject. Output cuối cùng sẽ biến thành 100% Paris. Như vậy, ta vô tình biến sampling thành greedy.

Bởi vậy, chúng bản cần một phương pháp cho phép tái sử dụng token từ draft mà vẫn giữ nguyên được phân phối của target.

Giải pháp

Thay vì phân tích một lần chạy đơn lẻ, bạn hãy thử tưởng tượng ta chạy hệ thống 1.000 lần với cùng một prefix để xem output tạo ra tỷ lệ gì.

Dựa trên phân phối q, draft sẽ đề xuất:

Paris:  ~500 lần  (vì q(Paris) = 50%)
London: ~400 lần  (vì q(London) = 40%)
Rome:   ~100 lần  (vì q(Rome) = 10%)

Trong khi đó, target muốn output cuối cùng bám sát phân phối p:

Paris:  ~600 lần  (vì p(Paris) = 60%)
London: ~300 lần  (vì p(London) = 30%)
Rome:   ~100 lần  (vì p(Rome) = 10%)

Hãy làm một phép so sánh trực tiếp:

Paris:
  Draft có 500, target cần 600
  → Draft THIẾU Paris → accept toàn bộ 500

London:
  Draft có 400, target chỉ cần 300
  → Draft THỪA 100 London → chỉ accept 300 trong số 400

Rome:
  Draft có 100, target cần 100
  → Vừa đủ → accept toàn bộ 100

Tỷ lệ accept cụ thể cho từng token được tính như sau:

Paris:  accept 500/500 = 100%
        tính: p/q = 60%/50% = 1.20 → min(1, 1.20) = 1 ✓

London: accept 300/400 = 75%
        tính: p/q = 30%/40% = 0.75 → min(1, 0.75) = 0.75 ✓

Rome:   accept 100/100 = 100%
        tính: p/q = 10%/10% = 1.00 → min(1, 1.00) = 1 ✓

Công thức tổng quát:

accept_probability(token) = min(1, p(token) / q(token))

Ý nghĩa trực tiếp của công thức này là:

  • Nếu draft đưa token ít hơn hoặc bằng lượng target cần, hệ thống sẽ nhận hết.
  • Nếu draft đưa token vượt quá mức cần thiết, hệ thống chỉ nhận đúng tỷ lệ mà target cần.

Khi reject thì lấy token từ đâu?

Sau bước accept, chúng ta có:

Paris:  500 (accept hết)
London: 300 (accept 300/400, reject 100)
Rome:   100 (accept hết)

Tổng accept: 900
Tổng reject: 100

Trong khi đó target cần:

Paris: 600 → còn thiếu 100
London: 300 → đã đủ
Rome: 100 → đã đủ

Như vậy, 100 trường hợp bị reject bắt buộc phải được dùng để bổ sung chính xác phần còn thiếu. Trong ví dụ này, toàn bộ chúng phải là Paris.

Nhiều người sẽ thắc mắc tại sao không reject rồi lấy mẫu (sample) lại từ target? Thử hình dung nếu 100 trường hợp reject lại được lấy mẫu từ target theo tỷ lệ 60/30/10, chúng ta sẽ có:

Thêm: Paris 60, London 30, Rome 10

Kết quả sẽ là:

Paris:  500 + 60 = 560  (cần 600 → sai)
London: 300 + 30 = 330  (cần 300 → sai)
Rome:   100 + 10 = 110  (cần 100 → sai)

Quá trình này sai lệch hoàn toàn vì London và Rome đã đủ số lượng (quota) nhưng hệ thống lại tự động bổ sung thêm.

Do đó, quá trình sửa lỗi (correction) phải được lấy từ phần target còn thiếu, hay còn gọi là residual distribution:

residual(token) = max(0, p(token) - q(token))

Paris:  max(0, 0.60 - 0.50) = 0.10
London: max(0, 0.30 - 0.40) = 0    ← đã đủ
Rome:   max(0, 0.10 - 0.10) = 0    ← đã đủ

Sau khi chuẩn hóa residual, tỷ lệ Paris sẽ là 100%. Mọi lượt reject đều được tự động sửa thành Paris.

Kết quả cuối cùng:

Paris:  500 + 100 = 600  ✓
London: 300              ✓
Rome:   100              ✓

Toàn bộ thuật toán (algorithm) cho một token

Thuật toán tổng thể sẽ diễn ra như sau:

1. Draft tạo distribution q
2. Sample candidate x từ q
3. Target tạo distribution p
4. Tính: accept_prob = min(1, p(x)/q(x))
5. Sinh random u trong khoảng [0,1]
6. Nếu u ≤ accept_prob:
       output x
   Nếu u > accept_prob:
       sample correction từ normalize(max(0, p-q))
       output correction

Với cách thiết kế này, dù đi qua nhánh accept hay reject thì output cuối cùng vẫn luôn giữ đúng phân phối p.

Áp dụng thuật toán

Giả sử draft sinh ra chuỗi 4 token:

D1, D2, D3, D4

Kèm theo phân phối draft tại từng vị trí:

q1, q2, q3, q4

Sau một lượt kiểm tra (pass), target trả về phân phối:

p1, p2, p3, p4

Quá trình kiểm tra sẽ tiến hành từ trái sang phải:

  • Vị trí 1: Tính khả năng accept của D1 bằng công thức min(1, p1(D1)/q1(D1)). Nếu hệ thống đồng ý accept, ta chuyển sang xét vị trí 2.
  • Vị trí 2: Vì D1 đã được accept, cả target và draft hiện đang xử lý trên cùng một prefix. D2 sẽ được tính tỷ lệ accept tương tự.
  • Vị trí 3: Nếu hệ thống reject D3, nó sẽ trích xuất sample correction từ residual, đồng thời tự động hủy cả D3 và D4, chính thức kết thúc vòng kiểm tra này.
  • Nếu cả 4 token đều được accept: Do lượt kiểm tra từ target vẫn còn lưu trữ kết quả phân phối sau D4, hệ thống có thể lấy thêm 1 bonus token trực tiếp từ target. Một vòng xử lý nhờ đó có thể tạo ra tối đa 5 token (4 từ draft cộng 1 bonus).

Ý tưởng phác thảo rồi kiểm duyệt (draft-then-verify) dành riêng cho phương pháp greedy đã từng xuất hiện trước đây. Một trong những công trình tiêu biểu là Blockwise Parallel Decoding của nhóm tác giả Stern, Shazeer, Uszkoreit tại hội nghị NeurIPS 2018.

Cách xử lý với greedy khá cơ bản:

Draft = target argmax → accept
Khác → reject

Tuy nhiên, điểm mới mẻ trong những bài báo năm 2022-2023 không chỉ nằm ở việc vận dụng model nhỏ để đề xuất token. Điểm đột phá thực sự chính là:

Chúng ta có thể tận dụng lại mẫu sinh ra từ model nhỏ, loại bỏ khi sai và bổ sung đúng mức tỷ lệ (probability) còn thiếu. Bằng cách đó, toàn bộ output vẫn giữ nguyên tính chuẩn xác so với phân phối của model lớn, kể cả khi dùng phương pháp lấy mẫu ngẫu nhiên (stochastic sampling).

Các biến thể

Phiên bản speculative decoding nguyên bản hoạt động tốt nhưng vẫn tồn tại nhiều giới hạn. Từ năm 2023 đến giữa năm 2026, hàng chục biến thể đã ra đời để khắc phục từng điểm yếu cụ thể.

Vấn đề 1: Model nhỏ phải tự hiểu lại toàn bộ context

Đây là vấn đề lớn nhất và nhóm biến thể giải quyết vấn đề này cũng đóng vai trò quan trọng nhất hiện tại, tiêu biểu là EAGLE.

Trong speculative decoding nguyên bản, draft model là một LLM nhỏ hoàn toàn độc lập. Việc nó phải tự đọc và hiểu lại toàn bộ bối cảnh thông qua các layer riêng gây lãng phí lớn về thời gian và bộ nhớ.

Vấn đề là target model đã chạy rồi. Nó đã xử lý prefix qua 32 layer và tạo ra các hidden state chứa đầy đủ thông tin context. Vậy tại sao drafter không tận dụng luôn nguồn thông tin đó thay vì phải tính toán lại từ đầu?

Đó chính là ý tưởng cốt lõi của EAGLE (Efficient Adaptive Generation via Leveraged hidden-state Estimation).

Cơ chế hoạt động của EAGLE

Hãy quay lại khái niệm hidden state ở Phần I. Khi target xử lý token "I" qua 32 layer, nó tạo ra một hidden state chuẩn xác h_I dưới dạng vector 4096 số chứa toàn bộ thông tin bối cảnh.

Target dùng h_I để dự đoán token tiếp theo:

h_I
 ↓
LM head
 ↓
" love" (80%), " am" (7%), " think" (5%), ...
 ↓
Chọn " love"

Thông thường, để biết token đứng ngay sau " love", target bắt buộc phải chạy " love" qua toàn bộ 32 layer:

embedding(" love") + KV cache("I")
        ↓
32 Transformer layers
        ↓
h_love (exact hidden state)
        ↓
LM head
        ↓
" coffee" (65%), " tea" (15%), ...

Bước này rất tốn kém, mất khoảng 20ms cho mỗi token.

EAGLE thay thế bước này bằng cách sử dụng:

h_I (exact hidden state target đã tạo)
+
embedding(" love") (token vừa được chọn)
        ↓
MỘT draft decoder layer nhỏ
        ↓
ĥ_love (bản ước lượng hidden state)
        ↓
Chính LM head của target
        ↓
candidate " coffee"

EAGLE tạo ra một vector ẩn gần đúng (ĥ_love), sau đó dùng chính LM head của target để biến vector đó thành xác suất dự đoán token.

Ký hiệu ĥ  dùng để chỉ bản ước lượng. EAGLE đoán hidden state mà target có khả năng sẽ tạo ra, dù độ chính xác không đạt mức 100%.

Tại sao EAGLE cần cả hidden state và token embedding?

Từ điểm xuất phát h_I, target có thể chọn ra nhiều token khác nhau:

"I love" (nếu chọn " love")
"I am"   (nếu chọn " am")
"I think" (nếu chọn " think")

Phần văn bản nối tiếp (continuation) trong tương lai phụ thuộc hoàn toàn vào việc token nào thực sự được chọn. Nếu chỉ cung cấp h_I cho EAGLE mà giấu đi token được chọn, hệ thống sẽ không biết phải rẽ theo nhánh nào.

h_I:              chứa thông tin "context trước token mới"
embedding(" love"): cho biết "token nào vừa thực sự được chọn"

EAGLE kết hợp cả hai yếu tố này để ước lượng hidden state tiếp theo một cách chuẩn xác hơn.

Cách EAGLE sinh ra chuỗi token

Sau bước đầu tiên để có được ĥ_love và candidate " coffee", EAGLE tiếp tục quy trình:

ĥ_love + embedding(" coffee")
        ↓
EAGLE decoder
        ↓
ĥ_coffee
        ↓
LM head
        ↓
candidate "."

Và tiếp tục với:

ĥ_coffee + embedding(".")
        ↓
EAGLE decoder
        ↓
ĥ_dot
        ↓
LM head
        ↓
candidate tiếp theo

EAGLE vẫn giữ bản chất autoregressive nên cần nhiều draft pass. Điểm khác biệt là mỗi pass chỉ chạy qua một draft layer nhỏ thay vì toàn bộ 32 target layer.

EAGLE pass 1 → ĥ_love   → " coffee"   (~0.3ms)
EAGLE pass 2 → ĥ_coffee → "."         (~0.3ms)
EAGLE pass 3 → ĥ_dot    → next        (~0.3ms)

ĥ_love chỉ là một bản ước lượng nên nó có thể khác với h_love thực tế.

Nếu sự khác biệt nằm trong phạm vi cho phép:

LM_head(h_love)  → " coffee"
LM_head(ĥ_love)  → " coffee"
→ Candidate đúng

Nếu sự khác biệt quá lớn:

LM_head(h_love)  → " coffee"
LM_head(ĥ_love)  → " tea"
→ Candidate sai → target verify bắt lỗi → reject " tea", dùng " coffee"

Giống như mọi biến thể speculative decoding khác, mọi lỗi sai đều sẽ bị bắt lại.

Từ EAGLE đến EAGLE-3: Loại bỏ feature regression

EAGLE nguyên bản đặt ra yêu cầu khá khắt khe:

draft output ĥ phải gần với exact target feature h

Cách làm này được gọi là feature regression vì nó ép drafter phải tạo ra một vector giống hệt target.

EAGLE-3 nhận ra rằng mục tiêu thực sự không phải là việc tái tạo chính xác hidden state. Mục tiêu duy nhất là:

LM head(draft state) → dự đoán đúng target token

Draft state không bị bắt buộc phải giống hệt exact target feature. Nó chỉ cần tạo ra đúng xác suất token khi đi qua LM head là đủ.

EAGLE-3 còn bổ sung một cải tiến khác. Thay vì chỉ sử dụng top-layer feature, nó thu thập hidden state từ nhiều layer khác nhau (low, mid, high) rồi kết hợp (fuse) lại:

low-layer feature   l
mid-layer feature   m
high-layer feature  h
        ↓ concatenate
FC projection
        ↓
fused feature g

Mỗi layer chứa một loại thông tin khác nhau. Ví dụ layer đầu nắm bắt cú pháp, layer giữa xử lý ngữ nghĩa và layer cuối có nhiệm vụ tổng hợp. EAGLE-3 tận dụng toàn bộ lượng thông tin này.

Vấn đề 2: EAGLE vẫn cần quá nhiều draft pass tuần tự

Dù mỗi pass của EAGLE-3 rất rẻ, việc sinh ra 7 draft token vẫn đòi hỏi 7 pass tuần tự:

pass 1 → D1
pass 2 → D2
...
pass 7 → D7

Để khắc phục, một hướng đi mới đã xuất hiện: tạo ra toàn bộ draft token trong một pass duy nhất thông qua parallel drafting.

Tại sao chúng ta có thể làm song song (parallel)?

Quay lại nhận xét ở Phần 2 cơ chế attention không ép buộc quá trình sinh text (generation) phải diễn ra tuần tự. Thứ thực sự tạo ra tính tuần tự bắt buộc là vòng lặp phản hồi (feedback loop):

model output → sample token → token embedding mới → model chạy tiếp

Parallel drafter loại bỏ hoàn toàn vòng lặp đó. Thay vì chờ đợi kết quả token D1 rồi mới bắt đầu tính D2, hệ thống đặt các biến tạm (placeholder) vào vị trí của D2:

Position 1: [anchor embedding, target context]  ← dữ liệu đã biết
Position 2: [MASK embedding, learned hidden]     ← biến tạm
Position 3: [MASK embedding, learned hidden]     ← biến tạm
Position 4: [MASK embedding, learned hidden]     ← biến tạm

Vector MASK embedding không phải là token tương lai thật mà chỉ là một dạng đại diện được drafter học hỏi trong quá trình huấn luyện.

Cả 4 vị trí này sẽ đi qua drafter cùng một lúc:

4 input positions
        ↓
Lightweight draft layers xử lý trong một forward pass
        ↓
4 output states
        ↓
LM head
        ↓
[D1, D2, D3, D4]

Dĩ nhiên, sự đánh đổi ở đây là D2 không thể biết D1 thực sự được chọn là gì vì D1 chưa được lấy mẫu (sample) khi D2 đang được tính. Token càng nằm xa anchor thì khả năng đoán sai càng cao.

Nhờ thiết kế này, mọi draft layer đều có thể truy cập thẳng vào target context chứ không chỉ riêng layer đầu tiên.

Vấn đề 3: Làm sao khi không muốn train drafter?

Các biến thể vừa nhắc tới (như EAGLE hay P-EAGLE) đều đòi hỏi phải huấn luyện drafter, hoặc ít nhất cũng cần một model nhỏ đi kèm như phiên bản gốc. Vậy nếu chúng ta không muốn huấn luyện thêm bất cứ thứ gì thì sao?

Phương pháp Prompt Lookup Decoding (PLD) hoặc n-gram matching ra đời nhằm tìm kiếm các đoạn văn bản (continuation) đã từng xuất hiện trong prompt ban đầu hoặc các output trước đó.

Lấy ví dụ, giả sử prompt chứa đoạn mã cũ sau:

function calculateTotal(items) {
    let total = 0;
    for (const item of items) {
        total += item.price;
    }
    return total;
}

Khi yêu cầu output sửa lại đoạn function này để thêm tính thuế (tax), hệ thống sẽ chủ động dò tìm phần token trùng lặp giữa output đang sinh và phần prompt. Sau đó, nó tự động đề xuất phần tiếp theo bởi vì thông thường output sẽ sao chép phần lớn nội dung code cũ.

prefix matching trong prompt/output history
        ↓
candidate continuation
        ↓
target verify

Hệ thống hoàn toàn không dùng neural drafter. Nhờ đó, máy chủ không tốn tài nguyên huấn luyện, không gánh thêm GPU weights và không tốn bộ nhớ.

Cách làm này tỏ ra đặc biệt hiệu quả trong các tác vụ như chỉnh sửa code, tóm tắt nội dung hay viết lại câu, nơi phần output chủ yếu dựa trên input.

Tuy nhiên, nó lại khá yếu trong các đoạn chat mở (open-ended) vì câu trả lời thường không liên quan nhiều đến input ban đầu

Vấn đề 4: Gánh nặng khi giữ thêm model riêng và hướng đi Self-speculative decoding

Thay vì phải gánh thêm một model nhỏ bên ngoài, một hướng tiếp cận khác là dùng chính target model nhưng chạy với số lượng layer ít hơn.

Với phương pháp LayerSkip, hệ thống dùng model 32 layer nhưng chỉ chạy draft bằng 8 layer đầu tiên:

Draft:
  Layer 1 → ... → Layer 8 → LM head → draft token

Verify:
  Lấy Hidden state từ layer 8 của draft tokens
  → Layer 9 → ... → Layer 32 → LM head → target prediction

Tuy nhiên, bản chất các hidden state ở layer 8 vốn không được thiết kế để chạy thẳng qua LM head. Vì vậy, LayerSkip phải thay đổi lại cả công thức huấn luyện:

  • Layer dropout: Trong lúc huấn luyện, hệ thống sẽ ngẫu nhiên bỏ qua một số layer. Việc này ép model phải học cách hoạt động ổn định kể cả khi thiếu vài layer ở khúc sau.
  • Early-exit loss: Bổ sung hàm loss tại các layer trung gian để LM head có thể quen với việc đọc hiểu hidden state từ nhiều độ sâu khác nhau chứ không chỉ dựa dẫm vào layer cuối.

WebAssembly Edge Model for learning English, built for low-end device

  • Ref: https://github.com/quochung-cyou/virulen

Virulen (VietLens) is a PWA English-learning app that lets learners in Vietnam point their camera at real-world objects and instantly see their English word, Vietnamese meaning, pronunciation, and example sentences.

Instead of memorizing words from Western textbooks, learners build a collection of cards from the everyday objects around them – plastic stools, rice cookers, street food, school furniture – the real Vietnam.

Novelty:

  • New dataset for Vietnam-specific objects (10k data) in unconstrained environment
  • The app running fully offline in mid-end devices via webapp (which allow both IOS/Android/and any devices with web browser) to run. Benchmark showcase it interface with 200-300ms latency.

Label Tool Build Around Edge Impulse: https://github.com/quochung-cyou/label-tool-edgeimpulse

1. Why This Exists

Tourism & communication

  • Vietnam welcomed 12.6M+ international visitors in 2023 (VNAT), but tourism is still concentrated in major cities.
  • Reports from World Bank, VNExpress, Tuoi Tre highlight that many rural destinations stay “off the map” because of communication and English barriers, not because they lack beauty or culture.

English education, but out-of-context

  • Vietnamese students start English in Grade 1 (MOET curriculum), yet textbooks and apps are dominated by urban/Western objects.
  • A 2022 analysis in Asia TEFL Journal found that over 80% of vocabulary objects in mainstream textbooks are generic or Western (e.g., sofa, burger, subway) rather than local Vietnamese items.
  • Research by Vietnamese educators shows textbook gaps for things tourists actually see:
    • cái chõ xôi (sticky rice steamer)
    • ghế nhựa (red plastic stool)
    • quán cóc (street stall)
    • mâm cơm (family meal tray)

The result is a visual and cultural mismatch: “Chair” in the book is a Western dining chair; “chair” in real life is a plastic stool or bamboo bench. Students memorize the word, but it doesn’t connect to their reality.

Gap in current apps

  • Language apps (Duolingo, Memrise, Babbel, Ling, etc.) teach generic vocab; no offline, Vietnam-specific scan-and-learn for local objects.
  • Global datasets and routines miss Vietnam‑specific foods, tools, and rural scenes.

Virulen / VietLens targets this exact gapOffline-first, locally trained object recognition + English learning, optimized for low-end Android devices in Vietnam.

2. How It Works

alt text

The large diagram below is the end‑to‑end pipeline that produces the on‑device model used in Virulen.
To make it easier to understand, we break it into four stages.

3.1 Web crawl & candidate discovery

alt text

This block corresponds to the left side of the diagram.

  • Search engines (Bing / Google)
    We query public search APIs to discover pages and images that are likely to contain Vietnam‑specific objects (street food, local tools, rural scenes, etc.).
  • Crawl module
    • Core Logic orchestrates crawling and filtering.
    • Multiple Workers download pages and images in parallel.
    • Output is a large pool of candidate images plus metadata.

This stage answers: “What images from the web might show the Vietnam objects we care about?”


3.2 Data acquisition for Viet‑specific objects

alt text

This block is the middle bottom of the diagram.

  • The crawl output is filtered into Vietnam bias object data – images that show Vietnamese contexts and artifacts we want the model to recognize.
  • data acquisition service:
    • Stores the raw images.
    • Groups them into samples (per object / per scene).
    • Prepares preview grids (as shown in the photo‑grid box in the diagram).

This stage answers: “Which of those images are actually useful for our Vietnam‑focused dataset?”


3.3 Labeling & human‑in‑the‑loop

alt text

This block is the bottom‑right.

  • Produce / Core Logic / Consumer
    • A small pipeline (shown with Kafka) feeds images into the labeling tools and collects labeled results.
    • Images flow through a Produce → Label → Consume cycle.
  • Labeling module (web UI)
    • Human labelers see each image (or grid of images).
    • They draw bounding boxes, assign class names, and validate / correct auto‑suggested labels.
  • Gemini fallback (bottom‑left box in the diagram)
    • A multimodal model (e.g. Gemini 2.0 Flash) can propose initial labels.
    • Humans confirm or fix them instead of labeling everything from scratch.

This stage answers: “How do we turn raw images into high‑quality labeled training data?”


3.4 Edge Impulse training & deployment

alt text

This block is the top pipeline and the central Edge Impulse logo.

  • Model pipeline in Edge Impulse Studio
    • Data ingestion: labeled samples are uploaded into Edge Impulse.
    • Feature generation: images are converted into feature vectors.
    • Model architecture & training:
      • A CNN / object‑detection network is configured (backbone, head, etc.).
      • Training, validation, and augmentation happen inside Edge Impulse Studio.
    • Quantization: the trained model is quantized for efficient on‑device inference (the “int8 model” box in the diagram).
  • Export to Virulen
    • Edge Impulse exports a standalone WebAssembly model bundle.
    • The bundle is placed under public/edge-impulse/ and loaded in the app by lib/edge-impulse-browser.ts.
    • ScanCamera uses this model to run real‑time detection directly in the browser.
  • Frontend framework: Next.js 16 App Router, React 19, TypeScript.
  • On-device AI:
    • lib/edge-impulse-browser.ts lazy-loads:
      • /edge-impulse/edge-impulse-standalone.js
      • /edge-impulse/run-impulse.js
    • ScanCamera (components/scan-camera.tsx):
      • Captures frames from getUserMedia video.
      • Packs pixels into feature vectors (lib/ei-image.ts).
      • Calls the Edge Impulse classifier and receives bounding-box detections.
      • Applies simple per-label non-max suppression to clean up overlapping boxes.
      • Maps detection labels to card definitions (lib/card-dictionary.ts → findCardByLabel).
  • State & storage:
    • User stats, streak, week progress, and collected cards are stored in localStorage (lib/storage.ts).
    • Cards are reconstructed from compact references + dictionary data to keep storage lightweight.
  • UI / UX:
    • Mobile-first layout (app/globals.css, shadcn-style components in components/ui).
    • Animated scan overlay and capture animation (components/detection-overlay.tsxcomponents/capture-card-animation.tsx).
    • Floating dock navigation (components/floating-dock.tsx).

3. What the App Does

Core experience

  • Scan objects with the camera
    • The app uses an Edge Impulse object-detection model loaded in the browser (lib/edge-impulse-browser.ts) to detect objects in real time.
    • Detections are mapped to curated word cards (lib/card-dictionary.json → lib/card-dictionary.ts).
  • Catch and collect vocabulary cards
    • Each recognized object becomes a Word Card (lib/card-types.tslib/word-data.ts):
      • English word
      • Vietnamese meaning
      • Phonetic / pronunciation
      • Example sentences
      • Category (e.g., household, food, school, transport)
      • User-captured images
  • Gamified dashboard (Home page)
    • Daily mission word / quest
    • Weekly progress heatmap (components/week-progress.tsx)
    • Streak, total time spent scanning, and recent scans (app/page.tsxlib/storage.ts).
  • Card collection & details
    • Browse all collected cards (app/cards/page.tsxcomponents/beautiful-card-collection.tsx).
    • View detail for each word: meaning, examples, images, and favorites.
  • Offline-friendly PWA
    • Next.js PWA setup (app/manifest.tsapp/layout.tsx + SwRegister) with:
      • start_url: "/virulen/"
      • display: "standalone"
    • Edge Impulse model & runtime served from static assets under public/edge-impulse/.
    • Designed to run on low-spec phones with no stable internet.

4. Tech Stack

  • Framework: Next.js 16 (App Router, TypeScript)
  • Language: TypeScript, React 19
  • Styling: Tailwind CSS 4, custom mobile-focused CSS, shadcn/ui components, Lucide icons
  • AI / CV: Edge Impulse WebAssembly classifier, custom Vietnam-focused dataset (served from public/edge-impulse)
  • Speech (optional): vosk-browser (script loaded in app/layout.tsx for browser speech recognition)
  • Storage: localStorage for cards, favorites, and stats
  • Animations: framer-motion, CSS animations

5. Getting Started

Prerequisites

  • Node.js ≥ 18
  • Package manager: pnpm (recommended), or npm.
  • A modern browser with camera support (for development).

Installation

# in the repo root
pnpm install
# or
npm install

Run in development

pnpm dev
# or
npm run dev

By default this runs on http://localhost:3000. Open it on a device with a camera (you can also use your laptop camera).

Note: In dev, base paths may differ from production (next.config.mjs uses basePath: "/virulen" and assetPrefix: "/virulen/" for static export).

Build & static export

This project is configured for static export:

pnpm build
# then
pnpm start   # Next.js standalone server

Or, if you run next export in your deployment pipeline, ensure you respect:

  • basePath: "/virulen"
  • assetPrefix: "/virulen/"
  • Static assets required for Edge Impulse under public/edge-impulse/.

6. Key Directories

  • app/
    • page.tsx – Home dashboard (stats, daily mission, quick actions, recent scans).
    • scan/page.tsx – Scan screen (integrates ScanCamera, current detections, “catch” animation).
    • cards/ – Card list and detail pages.
    • manifest.ts – PWA manifest.
    • layout.tsx – Root layout, fonts, PWA & speech scripts.
  • components/
    • scan-camera.tsx – Core camera + Edge Impulse pipeline.
    • detection-overlay.tsx – Renders bounding boxes.
    • beautiful-card-collection.tsxword-card-item.tsxword-card-modal.tsx – Collection UI.
    • floating-dock.tsxstats-card.tsxweek-progress.tsx – Navigation and dashboard UI.
    • audio-recorder.tsx – Voice features (for pronunciation practice and missions).
  • lib/
    • edge-impulse-browser.ts – Loads and instantiates the Edge Impulse classifier.
    • ei-image.ts – Packs camera frames into features for the model.
    • card-dictionary.json – Dictionary of all supported words and metadata.
    • card-dictionary.ts / card-types.ts – Card models and helper functions.
    • storage.ts – Local storage for cards, favorites, and user stats.
    • asset-path.ts – Base path helper for static assets.
  • public/edge-impulse/
    • Edge Impulse generated files (edge-impulse-standalone.jsrun-impulse.js, model assets).

7. Roadmap / Ideas

  • Richer Vietnam-specific dataset
    • Expand card-dictionary.json with more rural artifacts, foods, and tools.
    • Community-sourced images and labels from classrooms and local guides.
  • Education edition
    • Teacher dashboard: see which words a class has “caught”.
    • Thematic missions: “Market day”, “School day”, “Kitchen tour”.
  • Tourism bridge
    • Tourist mode: phrasebook + object scan for travelers.
    • Local mode: help locals explain cultural items to visitors (e.g., điếu càynón lábánh xèo).
  • Richer speech & pronunciation
    • Integrate vosk-browser fully for offline pronunciation practice and voice-based quizzes.
  • Data & research
    • Partner with educators and tourism experts to validate vocabulary lists.
    • Open data contributions (anonymized) to support further research on low-resource, domain-specific object recognition.

GOOSE 2D Fine-Grained Semantic Segmentation / ICRA 2026

  • Ref: https://github.com/quochung-cyou/goose-seg-icra2026

Approach to 64-class semantic segmentation on the GOOSE dataset. Final test score: 63.8 % composite mIoU on the ICRA 2026 Field Robotics Workshop Challenge.


Overview

The GOOSE (German Outdoor and Offroad Dataset) and its extension GOOSE-Ex contain images from three robotic platforms in unstructured outdoor environments. The task is pixel-level classification into 64 classes. Some classes are common (carroadsky). Others are narrow (tree_rootbarrelkick_scooter). A few barely exist in the data.

Two model architectures, three augmentation strategies, test-time augmentation, and a greedy rule-learning ensemble are included. The repo contains training scripts, logs, and visualizations.


The data

GOOSE dataset splits below:

SplitImagesLabelsCamera
goose_2d_train~24,000yeswindshield_vis
goose_2d_val~556yeswindshield_vis
gooseEx_2d_train~4,500yescamera_left
gooseEx_2d_val~192yescamera_left
Test set~361noboth

Labels are grayscale PNGs where pixel value = class ID (0..63). Class 0 is undefined and counts toward metrics. No ignore index.

Class imbalance

Class imbalance is the top challenge. forest alone covers 20.7 % of all pixels. skyasphalt, and low_grass together add another 36 %. Meanwhile pipe has roughly 1,852 pixels across the entire training set. barrel has 1,199. Several classes are so rare that models never learn them.

Class distribution

Rare class anatomy

Patches for the worst-performing classes (kick_scooterbarrier_tapepipetree_rootmotorcycle) are shown below. Most are tiny, occluded, or poorly lit.

Rare class grid

Methods

Models

Model 1: UPerHead with FlashInternImage-L (DCNv4 backbone)

FlashInternImage-L uses deformable convolutions v4. The UPerHead decoder fuses pyramid pooling with FPN-style features. An auxiliary FCN head on stage 3 provides extra gradient flow.

  • Backbone channels: 160, depths [5, 5, 22, 5]
  • Pretrained on ImageNet-22K → 1K at 384×384
  • Crop size: 2048×1024
  • Batch size: 2
  • 200,000 iterations, AdamW at 8e-4 with layer decay 0.94

Model 2: Mask2Former with the same backbone

Mask2Former uses a transformer decoder with 200 queries and a pixel decoder based on multi-scale deformable attention. Initialized from ADE20K weights (mask2former_flash_internimage_l_640_160k_ade20k_ss.pth) with manual handling of the 150 → 64 class mismatch. Training was slower per iteration (~2.1s vs ~0.9s) and stopped at ~47,000 iterations. Learning rate: 5e-5.

Augmentations

Standard MMSeg pipeline: random resize between 0.5x and 2.0x, random crop to 2048×1024, horizontal flip, photometric distortion, ImageNet normalization.

Copy-Paste: Instances of 17 rare classes extracted and pasted onto random target images with scaling from 0.2x to 4x. Two target images per source instance.

Copy-paste preview

Class weighting: Both models used ENet-style weights: 1 / log(1.02 + frequency), normalized and scaled to 64.

Test-time augmentation

Multi-scale inference at [0.75, 1.0, 1.25, 1.5] with horizontal flipping.

Ensemble

Model 1 (UPerHead) outperformed Model 2 on validation: 51.71 % vs 46.85 % mIoU. M2 still won specific classes. street_light, for instance: M1 got 13.1 % IoU while M2 got 46.8 %.

Tuned rule ensemble: A 3D histogram H[m1_pred, m2_pred, ground_truth] built over the full validation set. For every pixel group where M1 predicts class i and M2 predicts class c, the question is whether overriding M1 with M2 improves mIoU. An atom is accepted only if:

  • At least 500 pixels were in the group
  • M2 was significantly more correct than M1 (precision margin 0.05)
  • No single class dropped more than 0.005 IoU
  • The gain exceeded 5e-5 on the validation set

The greedy search accepted 18 atoms across 10 rules. Examples:

  • If M2 says building and M1 says obstacle or pole, trust M2
  • If M2 says curb and M1 says fencegravel, or low_grass, trust M2
  • If M2 says street_light and M1 says forest or pole, trust M2

M1 alone: 44.21 % mIoU on the tuning split. Tuned ensemble: 47.94 %. A +3.72 % gain from 18 pixel-level rules.


Training

Training ran on a single A100. M1 peaked around 58 GB memory. M2 was lighter at ~48 GB but slower.

Training curves

M1 converged to higher mIoU and stayed there. M2’s loss looked reasonable but validation metrics plateaued lower. Mask2Former likely needs more data, longer training, or a better initialization than the ADE20K transfer. The transformer decoder also consumes many iterations.


Results

Validation metrics

ApproachaAccmIoUmAcc
UPerHead (M1)87.19 %51.71 %61.41 %
Mask2Former (M2)84.52 %46.85 %60.23 %
Tuned ensemble87.19 %51.79 %61.41 %
Model comparison

The tuned ensemble edges out M1 by 0.08 % on validation. The real win is per-class. Some classes improved significantly.

Per-class IoU on validation

Top 30 classes by M1 IoU below. M1 dominates frequent classes like skyasphalt, and forest. M2 is competitive on street_lightrider, and bicycle.

Per-class IoU

The scatter plot below shows log frequency against IoU for both models. Rare classes cluster near zero. sky sits alone at the top right. barrel is an outlier with high IoU despite low frequency because it has a consistent visual signature (yellow cylinders).

Frequency vs IoU

Radar chart

16 diverse classes spanning the frequency spectrum. M1 covers more area overall, but M2 bulges on street_light and bicycle.

Radar chart

Precision vs recall

Most points sit below the diagonal, meaning recall is the bottleneck. The model finds the class when it is present, but misses many pixels. sky and barrel are the exceptions — high precision, high recall, easy classes.

Precision vs recall

Error analysis

Confusion matrices

Row-normalized confusion for the top 20 frequent classes. Dark diagonals = good recall. Off-diagonal heat shows misclassification patterns.

M1: 

M2: 

Common misclassifications: tree_crown → foresthigh_grass → low_grasswall → building. The model struggles with fine-grained vegetation boundaries and architectural edges.

Model disagreement

M1 and M2 agree on 86.77 % of pixels. When they disagree, M1 wins 96 % of the time. The 4 % where M2 wins is where the ensemble gains come from.

Disagreement stacked

Highest disagreement rates are on wallrockrider, and moss. These are ambiguous classes with fuzzy boundaries.

Disagreement rate

Ensemble gains

The waterfall chart below shows each accepted atom’s contribution to mIoU. Most atoms give small gains. A few give large gains — notably the debris → soil rule and the fence → curb rule.

Ensemble waterfall

Per-class IoU changes from the tuned rules. Biggest winners: curb (+55.3 %), debris (+23.5 %), street_light (+20.2 %). Some classes drop slightly, but the guard rails prevent any single class from dropping severely.

Tuned gains

Qualitative results

Validation samples

Image, ground truth, M1, M2. M2 is visibly noisier on vegetation and road boundaries.

Validation sample 1
Validation sample 2
Validation sample 3
Validation sample 4
Validation sample 5

Test samples

No ground truth for the test set: image → M1 → M2 → tuned ensemble. The tuned rules shift predictions: building edges get cleaner, curb appears where M1 predicted fencestreet_light appears where M1 predicted pole.

Test sample 1
Test sample 2
Test sample 3
Test sample 4
Test sample 5

Official test results

The numbers above are from local validation. The official challenge test set evaluation is below. Composite mIoU: 63.80 %.

Per-class mIoU on test set

IDClassmIoU (%)
0undefined25.56
1traffic_cone0.00
2snow67.67
3cobble87.26
4obstacle53.99
5leaves19.74
6street_light49.20
7bikeway0.00
8ego_vehicle91.97
9pedestrian_crossing0.00
10road_block71.51
11road_marking72.07
12car93.73
13bicycle70.06
14person86.77
15bus87.79
16forest70.40
17bush38.97
18moss1.27
19traffic_light70.61
20motorcycle42.61
21sidewalk66.88
22curb63.23
23asphalt92.28
24gravel31.68
25boom_barrier35.35
26rail_track78.25
27tree_crown53.52
28tree_trunk65.07
29debris25.62
30crops77.21
31soil61.59
32rider44.85
33animal30.41
34truck51.78
35on_rails84.62
36caravan80.90
37trailer27.69
38building85.81
39wall54.83
40rock20.05
41fence84.46
42guard_rail62.40
43bridge7.62
44tunnel0.00
45pole50.44
46traffic_sign70.61
47misc_sign70.84
48barrier_tape27.02
49kick_scooter1.44
50low_grass73.01
51high_grass59.68
52scenery_vegetation33.35
53sky97.54
54water51.23
55wire31.55
56outlier0.00
57heavy_machinery48.78
58container59.42
59hedge48.98
60barrel92.30
61pipe0.00
62tree_root0.00
63military_vehicle0.00

Per-category mIoU

CategorymIoU (%)
Animal30.41
Construction78.37
Human86.67
Object40.42
Road69.99
Sign72.07
Sky97.54
Terrain89.14
Vegetation93.39
Vehicle86.77
Water51.23

Overall

  • mIoU fine: 55.24 %
  • mIoU fine (coarse): 72.36 %
  • mIoU composite: 63.80 %

The test gap between validation and test is notable. Some classes improved (cobbleroad_blocktrailer), others collapsed (leavesmossrock). The test set likely has different scene distributions or lighting conditions. The 0 % classes remained 0 %. traffic_cone and pipe probably need external data or synthetic injection to improve.


Repo structure

FileWhat it does
train_segment_py.pyTrain UPerHead model. Full MMSeg config in Python.
train_mask2former_l.pyTrain Mask2Former. Handles ADE20K pretrained weights with class mismatch.
generate_submission.pyInference + submission packaging for UPerHead.
generate_submission_mask2former.pyInference + submission packaging for Mask2Former.
ensemble_submission_tuned.pyApply tuned_rules.py to test predictions.
tune_ensemble_v2.pyGreedy rule optimizer. Builds 3D histogram, selects atoms.
copypaste_augmentation.pyCopy-Paste augmentation for rare classes.
copypaste_config.pyConfig for Copy-Paste.

Setup & Installation

Steps to go from a fresh machine to running training or inference.

1. Hardware

ComponentMinimumRecommended
GPUNVIDIA A100 80 GBA100 80 GB or H100
GPU memory (train)~58 GB (UPerHead), ~48 GB (Mask2Former)80 GB
Host RAM64 GB128 GB
Disk space400 GB free500 GB+
CUDA capability>= 8.0 (Ampere)>= 8.0

Training ran on a single A100. The scripts are single-GPU. For inference only, a smaller GPU may work with reduced batch size or TTA disabled.

2. System dependencies

  • CUDA >= 11.7 with matching NVCC and cuDNN
  • GCC compatible with your CUDA (e.g. GCC 10–11 for CUDA 11.7)
  • Standard build tools: build-essentialgitwget

Check CUDA and NVCC:

nvidia-smi
nvcc --version

3. Python environment

Create the conda environment:

conda create -n dcnv4 python=3.10 -y
conda activate dcnv4

Install core deep-learning stack:

conda install pytorch torchvision pytorch-cuda=11.7 -c pytorch -c nvidia -y

Install OpenMMLab dependencies:

pip install -U openmim
mim install mmcv-full==1.5.0
mim install mmsegmentation==0.27.0
pip install timm==0.6.11 mmdet==2.28.1

Install remaining Python packages used by the scripts:

pip install opencv-python Pillow tqdm matplotlib scipy numpy pandas

4. Build the DCNv4 CUDA extension

The DCNv4 backbone requires a custom CUDA operator. It must be compiled from source: (The DCNv4 version in this repo is modified for compatibility with the current environment – A100 and newer cuda/python versions)

cd DCNv4/DCNv4_op
pip install -e .

If this fails, typical causes are:

  • CUDA_HOME not set: export CUDA_HOME=/usr/local/cuda
  • NVCC / GCC version mismatch
  • PyTorch CUDA version does not match system CUDA

Verify the build:

python -c "import DCNv4.ext; print('OK')"

5. Prepare the data

The GOOSE and GOOSE-Ex datasets are downloaded automatically by the preparation script. They need ~192 GB of disk space.

python prepare_combined_dataset.py

This creates the expected data/ tree:

data/
  goose_2d_train/
  goose_2d_val/
  gooseEx_2d_train/
  gooseEx_2d_val/
  goose_2d_train_copypaste/   # created by copypaste_augmentation.py
  goose_label_mapping.csv

7. Optional: pretrained weights

Pretrained backbones are downloaded automatically on first run from HuggingFace:

  • UPerHead backbone: flash_intern_image_l_22kto1k_384.pth
  • Mask2Former pretrained: mask2former_flash_internimage_l_640_160k_ade20k_ss.pth

To skip training and run inference only, download the best fine-tuned checkpoints and place them under checkpoints/goose_seg_dcnv4/ and checkpoints/goose_mask2former_l/.


Running things

The conda environment is dcnv4. Python 3.10, PyTorch, MMCV, MMSegmentation.

Train UPerHead:

conda run -n dcnv4 python train_segment_py.py > training.log 2>&1 &

Train Mask2Former:

conda run -n dcnv4 python train_mask2former_l.py > training_maskformer.log 2>&1 &

Generate validation predictions and submission:

conda run -n dcnv4 python generate_submission.py
conda run -n dcnv4 python generate_submission_mask2former.py

Tune the ensemble:

conda run -n dcnv4 python tune_ensemble_v2.py
conda run -n dcnv4 python ensemble_submission_tuned.py

Regenerate all README visuals:

conda run -n dcnv4 python generate_readme_visuals.py
conda run -n dcnv4 python generate_confusion_heatmap.py
conda run -n dcnv4 python generate_disagreement_chart.py
conda run -n dcnv4 python generate_pr_scatter.py
conda run -n dcnv4 python generate_test_side_by_sides.py

What worked and what didn’t

Worked:

  • UPerHead decoder. Simple, reliable, better mIoU than Mask2Former for this data.
  • Test-time augmentation. Reliable gains at no training cost.
  • Class weighting. Stabilized training on the long tail.
  • The tuned rule ensemble. +3.72 % mIoU from 18 atoms.

Didn’t work:

  • Mask2Former underperformed given its compute cost. Likely needs longer training or better hyperparameter tuning. The ADE20K initialization may not transfer well to outdoor offroad scenes.
  • Copy-Paste and oversampling helped a little, but could not fix the core issue: some classes have so few pixels that duplication does not create real signal.
  • 64 classes is too many for the data volume. Several classes (traffic_conepipetree_rootmilitary_vehiclekick_scooter) scored exactly 0.0 on test.

Not tried:

  • Hard example mining / OHEM
  • Boundary loss
  • Pseudo-labeling on the test set
  • Model distillation
  • 3D point cloud fusion (the dataset has LiDAR)
  • External datasets

[A.I System Programming] B4: Motivation của GPU

This entry is part 4 of 6 in the series A.I System Programming

Như đã thấy ở Phần 3, lịch sử máy tính đã chứng kiến một bước ngoặt vào những năm 2010: Sự sụp đổ của Định luật Moore về mặt tốc độ. Khi xung nhịp chạm ngưỡng 3.5 GHz và bài toán nhiệt năng trở thành ‘bức tường’ ngăn cản sự phát triển theo chiều dọc, thế giới công nghệ buộc phải chuyển mình sang chiều ngang.

Khi tốc độ đơn nhân không còn tăng, tính toán song song (Parallel Computing) không còn là một lựa chọn xa xỉ, mà là con đường duy nhất để duy trì đà tăng trưởng của công nghệ.

image 82 - quochung.cyou PTIT

Tính toán song song

Tính toán song song là việc thực thi nhiều thao tác tại một thời điểm duy nhất. Như đã thấy từ biểu đồ trên, Single Thread Performance (Hiệu năng đơn nhân), Frequency (Xung nhịp) của CPU đã không còn tăng đáng kể từ sau những năm 2010, thậm chí là giảm xuống/giữ nguyên để đạt được mục tiêu tiết kiệm điện hơn, nhiệt năng giảm đi. Sự thay đổi lớn nhất từ 2010 là số lượng nhân (Logical Core)

Tuy nhiên, việc các hệ thống máy tính được trang bị nhiều core hơn, mở ra khả nưang cho phép thực thi nhiều thao tác tại 1 thời điểm duy nhất, nhưng việc này không diễn ra tự động. Tức là từ góc nhìn của 1 lập trình viên, không phải cứ đưa chương trình vào các phần cứng nhiều nhân là chương trình sẽ chạy nhanh hơn, mà điều này còn cần chính lập trình viên đó hiểu và sửa đổi chương trình để có thể tận dụng khả năng của phần cứng.

  1. Nhìn chung đầu tiên, ta cần xác định liệu ứng dụng có tiềm năng/có khả năng tận dụng từ tính toán song song không?
  2. Tiềm năng song song, hay tính đồng thời (concurrency), có nghĩa là ta có thể xác nhận rằng việc thực hiện các thao tác theo bất kỳ thứ tự nào là an toàn khi các tài nguyên hệ thống trở nên sẵn dụng.

Và, với tính toán song song, có một yêu cầu bổ sung: các thao tác này phải xảy ra cùng một lúc. Để điều này thực hiện được, ta cũng phải tận dụng đúng cách các tài nguyên để thực thi các thao tác này một cách đồng thời.

Nghe có vẻ khá hàn lâm, ta có thể đi qua 1 ví dụ thực tế

Ví dụ thực tế

image 83 - quochung.cyou PTIT

Có thể thấy vô số ví dụ về xử lý song song ngay trong cuộc sống hàng ngày. Ví dụ là một hàng thanh toán tại siêu thị, mục tiêu là giúp khách hàng thanh toán nhanh chóng những món đồ họ muốn mua.

Việc này có thể được thực hiện bằng cách thuê nhiều thu ngân để xử lý (thanh toán) cho khách hàng từng người một. Trong trường hợp này, những thu ngân lành nghề có thể thực hiện quy trình thanh toán nhanh hơn để khách hàng rời đi sớm hơn.

Một chiến lược khác là sử dụng nhiều trạm tự thanh toán và cho phép khách hàng tự thực hiện quy trình. Chiến lược này đòi hỏi ít nguồn lực con người hơn từ siêu thị và có thể mở thêm nhiều làn để xử lý khách hàng. Quầy tự thanh toán thì có thể sẽ xử lý chậm hơn quầy thu ngân do khách hàng không quen với việc này, tuy nhiên tổng thể thì nhiều khách hàng hơn có thể thanh toán nhanh chóng nhờ tính song song được tăng cường, dẫn đến các hàng đợi ngắn hơn.

Chúng ta giải quyết các vấn đề tính toán bằng cách phát triển các thuật toán (algorithms): một tập hợp các bước để đạt được kết quả mong muốn.

  • Trong phép ẩn dụ về siêu thị, quy trình thanh toán chính là thuật toán.
  • Trong trường hợp này, nó bao gồm việc:
    • B1: dỡ đồ khỏi giỏ
    • B2: quét mã các món đồ để lấy giá
    • B3: thanh toán tiền.
  • Thuật toán này là tuần tự (sequential/serial); nó phải tuân theo đúng thứ tự này. Rõ ràng ta không thể vừa bảo khách hàng hãy thanh toán tiền, nếu ta chưa dỡ đồ ra để quét mã kiểm tra giá.

Nếu có hàng trăm khách hàng cần thực hiện nhiệm vụ này, thuật toán thanh toán cho nhiều khách hàng sẽ chứa đựng một tính song song có thể tận dụng được. Về mặt lý thuyết, không có sự phụ thuộc nào giữa bất kỳ hai khách hàng nào đang thực hiện quy trình thanh toán. Bằng cách sử dụng nhiều làn thanh toán hoặc các trạm tự thanh toán, các siêu thị đã bộc lộ tính song song (expose parallelism), từ đó tăng tốc độ khách hàng mua hàng và rời khỏi cửa hàng. Mỗi lựa chọn trong cách chúng ta triển khai tính song song này đều dẫn đến các chi phí và lợi ích khác nhau.

Định nghĩa: Tính toán song song (Parallel computing) là việc thực hành xác định và bộc lộ tính song song trong các thuật toán, thể hiện điều này trong phần mềm của chúng ta, và hiểu rõ các chi phí, lợi ích cũng như những giới hạn của cách triển khai đã chọn.

Tính năng phần cứng

Vì có rất nhiều tính năng phần cứng song song khác nhau, điều này đặt ra những thách thức mới cho lập trình viên. Một trong số đó là siêu phân luồng (hyperthreading), được giới thiệu bởi Intel. Việc có hai hàng đợi lệnh xen kẽ công việc vào các đơn vị logic phần cứng cho phép một lõi vật lý hiển thị như hai lõi đối với hệ điều hành (OS).

Bộ xử lý vector (Vector processors) là một tính năng phần cứng khác bắt đầu xuất hiện trong các bộ vi xử lý phổ thông vào khoảng năm 2000. Chúng thực thi nhiều lệnh cùng một lúc. Độ rộng tính bằng bit của bộ xử lý vector (còn gọi là đơn vị vector – vector unit) xác định số lượng lệnh thực thi đồng thời. Ví dụ, một đơn vị vector rộng 256-bit có thể thực thi bốn lệnh 64-bit (số thực dấu phẩy động độ chính xác kép – doubles) hoặc tám lệnh 32-bit (độ chính xác đơn – single-precision) cùng một lúc.

Có thể tạm bỏ qua cách mà các công nghệ này hoạt động, từ một phép tính, ta thử xem xét một CPU 16 lõi có siêu phân luồng và một đơn vị vector rộng 256-bit. Một chương trình tuần tự (serial) chỉ sử dụng một lõi duy nhất và không sử dụng vector hóa sẽ chỉ tận dụng được 0,8% khả năng xử lý lý thuyết của bộ vi xử lý này!

16 cores × 2 hyperthreads × (256 bit-wide vector unit)/(64-bit double) = 128-way parallelism

image 84 - quochung.cyou PTIT

Hiệu quả năng lượng: Làm được nhiều hơn với ít tài nguyên hơn

Một trong những lĩnh vực tác động mới của tính toán song song là hiệu quả năng lượng. Trên các thiết bị cầm tay, tính song song giúp ứng dụng chạy nhanh hơn, cho phép thiết bị quay lại chế độ ngủ (sleep mode) sớm hơn. Nó cũng cho phép sử dụng các bộ vi xử lý chậm hơn nhưng có tính song song cao hơn, vốn tiêu thụ ít điện năng hơn.

Công thức ước tính mức tiêu thụ năng lượng của ứng dụng:

P=(N Bộ xử lý)×(R Watt/Bộ xử lý)×(T giờ)P = (N \text{ Bộ xử lý}) \times (R \text{ Watt/Bộ xử lý}) \times (T \text{ giờ})

Trong đó: P là năng lượng tiêu thụ, N là số bộ xử lý, R là công suất thiết kế nhiệt (TDP), và T là thời gian chạy.

Ví dụ:

  • Dùng CPU: 20 bộ xử lý Xeon (120W) chạy trong 24 giờ tiêu tốn 57,60 kWh.
  • Dùng GPU: 4 GPU NVIDIA V100 (300W) chạy trong 24 giờ tiêu tốn 28,80 kWh. Dù thời gian chạy bằng nhau, phiên bản dùng GPU chỉ tiêu tốn một nửa chi phí năng lượng.

Các định luật cơ bản của tính toán song song

Trong tính toán tuần tự, mọi thao tác đều nhanh lên khi tần số xung nhịp tăng. Ngược lại, với tính toán song song, chúng ta phải sửa đổi ứng dụng để khai thác phần cứng.

Định luật Amdahl

Định luật Amdahl (đề xuất bởi Gene Amdahl năm 1967) mô tả sự tăng tốc của một vấn đề có kích thước cố định khi số lượng bộ xử lý tăng lên:

Speedup(N)=1S+PN\text{Speedup}(N) = \frac{1}{S + \frac{P}{N}}

Trong đó: P là phần mã có thể song song hóa, S là phần mã tuần tự (S + P = 1), và N là số lượng bộ xử lý.

Ví dụ một biểu đồ trục hoành là số lượng nhân và trục tung là tốc độ tăng lên. Trong mong muốn, khi ta đi từ 1 nhân lên 32 nhân, ta có thể tăng tốc độ lên đúng 32 lần. Tuy nhiên nếu chỉ cần với 10% mã không thể song song hoá, tức là có 90% mã song song hoá được thôi, thì speedup cũng đã chỉ còn là khoảng ~8 ở 32 nhân (so với 32 lần tăng tốc)

image 85 - quochung.cyou PTIT

Hãy tưởng tượng một quy trình làm giày gồm:

  • Thiết kế mẫu (Tuần tự): 10 giờ (Chỉ có 1 nhà thiết kế chính có thể quyết định kiểu dáng).
  • Gia công và đóng gói (Song song): 90 giờ.

Áp dụng công thức: P = 0.9 (90%), S = 0.1 (10%).

  • Với 2 bộ xử lý (N=2): Speedup = 1.82 lần.
  • Với 10 bộ xử lý (N=10): Speedup = 5.26 lần.
  • Với vô hạn bộ xử lý: Speedup = 10 lần.

Dù có trang bị hệ thống máy móc khổng lồ (tăng N lên cực lớn), ta cũng không bao giờ tăng tốc quy trình sản xuất lên quá 10 lần. 10 giờ thiết kế ban đầu là “vùng bất khả xâm phạm”.

Định luật Gustafson-Barsis

Gustafson và Barsis (1988) chỉ ra rằng: trong thực tế, khi có thêm nhiều bộ xử lý, chúng ta thường tăng kích thước vấn đề lên. Nếu kích thước vấn đề tăng tỷ lệ thuận với số lượng bộ xử lý, sự tăng tốc được tính bằng:

SpeedUp(N)=NS×(N1)\text{SpeedUp}(N) = N – S \times (N – 1)

Kết quả là một vấn đề lớn hơn có thể được giải quyết trong cùng một khoảng thời gian bằng cách sử dụng nhiều bộ xử lý hơn. Kịch bản này được gọi là mở rộng yếu (weak scaling).

image 86 - quochung.cyou PTIT

Định nghĩa: Weak scaling đại diện cho thời gian tìm ra lời giải so với số lượng bộ xử lý cho một kích thước vấn đề cố định trên mỗi bộ xử lý.

Giả sử ta có 1 giờ để dọn dẹp và ta có một số lượng nhân công tùy ý.

  • Phần tuần tự (S): Lập kế hoạch phân công công việc (mất 5 phút). Dù có bao nhiêu người, vẫn phải mất 5 phút này để nói cho mọi người biết phải làm gì.
  • Phần song song (P): Việc lau chùi thực tế.

Phân tích:

  1. Với 1 người: Trong 1 giờ, ta dành 5 phút lập kế hoạch và 55 phút còn lại để lau xong 1 căn phòng.
  2. Với 10 người: vẫn dành 5 phút lập kế hoạch. Nhưng trong 55 phút còn lại, 10 người này có thể lau sạch 10 căn phòng.

Kết luận: Ta không làm sạch 1 căn phòng nhanh gấp 10 lần (vì vẫn vướng 5 phút họp), nhưng ta đã giải quyết được một khối lượng công việc lớn gấp nhiều lần trong cùng một quỹ thời gian 1 giờ.

So sánh Mở rộng Mạnh (Strong Scaling) và Mở rộng Yếu (Weak Scaling)
  • Mở rộng mạnh (Strong scaling): Giữ nguyên tổng kích thước bài toán và chia nhỏ nó ra cho nhiều bộ xử lý hơn. Mục tiêu chính là giảm thời gian tính toán.
  • Mở rộng yếu (Weak scaling): Giữ nguyên kích thước bài toán trên mỗi bộ xử lý (mật độ không đổi), dẫn đến tổng kích thước bài toán tăng lên khi thêm bộ xử lý. Điều này giúp tận dụng thêm bộ nhớ của các bộ xử lý mới để giải quyết vấn đề lớn hơn.
image 87 - quochung.cyou PTIT

Trong thực tế, cả hai đều quan trọng vì chúng giải quyết các kịch bản khác nhau. Thuật ngữ khả năng mở rộng (scalability) thường được dùng để chỉ việc liệu có thể thêm tính song song vào phần cứng hoặc phần mềm hay không, và liệu có giới hạn tổng thể cho sự cải thiện đó hay không.

Khả năng mở rộng bộ nhớ (Memory Scaling)

Mặc dù đa số mọi người quan tâm vào thời gian chạy, nhưng khả năng mở rộng bộ nhớ thường quan trọng hơn.

Giả sử ta vận hành một hệ thống siêu thị gồm 4 chi nhánh (tương đương 4 bộ xử lý – Proc). Để hệ thống hoạt động, mỗi chi nhánh cần quản lý hai loại dữ liệu sau:

  • Loại 1: Danh sách giá sản phẩm (Bảng giá chung)
    • Mọi chi nhánh đều phải bán cùng một mức giá. Khi khách hàng quét mã vạch, máy tính tại chi nhánh đó phải biết giá ngay lập tức mà không cần hỏi chi nhánh khác.
    • Cách lưu trữ: Ta phải chép một bản danh sách giá đầy đủ vào máy tính của tất cả 4 chi nhánh.
    • Đặc điểm: Nếu danh sách giá tăng lên (từ 1.000 mặt hàng lên 1.000.000 mặt hàng), thì máy tính tại mỗi chi nhánh đều phải tăng dung lượng lưu trữ tương ứng để chứa hết đống dữ liệu đó.
  • Loại 2: Danh sách khách hàng thân thiết
    • Hệ thống có 4.000 khách hàng. Để giảm tải, ta chia ra: chi nhánh A giữ thông tin 1.000 người đầu tiên, chi nhánh B giữ 1.000 người tiếp theo, tương tự cho C và D.
    • Cách lưu trữ: Dữ liệu được chia nhỏ và phân chia cho các chi nhánh.
    • Đặc điểm: Nếu số lượng khách hàng tăng lên 16.000 người, ta chỉ cần mở thêm 12 chi nhánh nữa (tổng 16 chi nhánh). Lúc này, mỗi máy tính chi nhánh vẫn chỉ phải quản lý đúng 1.000 khách hàng như cũ.

Từ ví dụ trên, ta chuyển dịch sang các thuật ngữ chuyên môn trong hình ảnh:

  • Mảng sao chép (Replicated Array – R) – Tương ứng với “Bảng giá chung”
    • Định nghĩa: Là tập dữ liệu mà mỗi bộ xử lý (Proc) bắt buộc phải giữ một bản sao toàn phần.
    • Tại sao phải thế? Trong tính toán song song, có những dữ liệu mà mọi bộ xử lý đều cần truy cập liên tục (ví dụ: hằng số vật lý, bản đồ địa hình, bảng tra cứu). Việc sao chép giúp Proc lấy dữ liệu ngay lập tức từ bộ nhớ cục bộ (Local Memory), không phải chờ đợi truyền tin qua mạng giữa các Proc khác, giúp tăng tốc độ xử lý.
  • Mảng phân tán (Distributed Array – D) – Tương ứng với “Danh sách khách hàng”
    • Định nghĩa: Là tập dữ liệu khổng lồ được chia nhỏ. Mỗi Proc chỉ giữ một phần (segment).
    • Tại sao phải thế? Để xử lý các bài toán cực lớn (Big Data) mà một máy đơn lẻ không bao giờ chứa nổi. Ta tận dụng tổng dung lượng RAM của nhiều máy cộng lại.
image 88 - quochung.cyou PTIT

Hãy nhìn lại thông số trong hình:

  1. Ở 1 Proc: Ta có mảng R (100MB) và mảng D (100MB). Tổng cộng tốn 200MB RAM.
  2. Ở 4 Proc (Quy mô bài toán tăng gấp 4):
    • Mảng D được chia cho 4 máy, nên mỗi máy vẫn chỉ gánh 100MB (Tổng 400MB). Đây là tin tốt.
    • Vấn đề nằm ở mảng R: Vì tính chất sao chép, khi tổng dữ liệu R tăng lên 400MB, thì từng máy một đều phải gánh đủ 400MB.
    • Kết quả: Một máy bây giờ phải chứa 400MB (R) + 100MB (D) = 500MB.

Mảng D (Phân tán): Khi quy mô bài toán tăng gấp 4 (từ 100 MB lên 400 MB tổng cộng) và số Proc tăng lên 4, lượng dữ liệu mảng D mà mỗi Proc phải xử lý vẫn giữ nguyên là 100 MB. Đây là trạng thái mở rộng lý tưởng.

Mảng R (Sao chép): Vì tính chất phải sao chép toàn bộ, khi kích thước bài toán tăng lên 400 MB, mọi Proc đều phải chứa đủ 400 MB.

Nếu máy chỉ có 450MB RAM, thì coi như bây giờ ta vẫn luôn có một giới hạn trên cho việc tăng số lượng process là các dữ liệu bắt buộc phải copy tại mọi máy. Dù ta có mua thêm 100 hay 1000 máy nữa cũng vô dụng, vì mỗi cái máy đơn lẻ đó đều không đủ RAM để chứa mảng R đang phình to.

  • Thời gian chạy (Runtime): Nếu thuật toán dở, ta thêm máy thì nó chạy nhanh hơn hoặc chậm đi một chút, nhưng nó vẫn chạy được.
  • Bộ nhớ (Memory): Nếu thiết kế mảng sao chép (R) quá nhiều, khi bài toán lớn đến một mức nhất định, nó sẽ không thể chạy được vì vượt ngưỡng RAM vật lý của từng nút xử lý.

Tính toán song song hoạt động như thế nào?

Tính toán song song đòi hỏi sự kết hợp giữa hiểu biết về phần cứng, phần mềm và tính song song để phát triển ứng dụng. Với một số khái niệm như (CPU/Process/Thread/…) nếu bạn chưa biết, phần này sẽ được đề cập sơ lược qua ở các phần sau khi đi sâu hơn.

Ta thấy rằng giữa mã nguồn và phần cứng, ứng dụng phải đi qua các lớp bổ sung, bao gồm trình biên dịch (compiler)hệ điều hành (OS).

image 89 - quochung.cyou PTIT

Chúng ta phân loại các phương pháp tiếp cận song song có thể thực hiện thành:

  1. Song song hóa dựa trên tiến trình (Process-based): Mỗi tiến trình có không gian bộ nhớ riêng.
  2. Song song hóa dựa trên luồng (Thread-based): Các luồng chia sẻ chung bộ nhớ.
  3. Vector hóa (Vectorization): Thực hiện một lệnh trên nhiều dữ liệu.
  4. Xử lý dòng (Stream processing): Thường gắn liền với GPU.

Chúng ta sẽ sử dụng phương pháp song song hóa dữ liệu (data parallel) trên một lưới không gian hai chiều (2D). Ví dụ minh họa là hình ảnh 2D của núi lửa Krakatau

Để hiểu tại sao chúng ta cần những khái niệm này, hãy đi từ bài toán thực tế: Dự báo sóng thần sau khi núi lửa Krakatau phun trào.

image 90 - quochung.cyou PTIT
Bước 1: Rời rạc hóa vấn đề thành các ô hoặc phần tử nhỏ hơn

Trước khi tính toán chi tiết, ta phải chia nhỏ miền vấn đề thành các mảnh nhỏ, quá trình này gọi là rời rạc hóa (discretization). Trong xử lý ảnh, đây chính là các pixel. Trong mô phỏng, chúng được gọi là các ô (cells) hoặc phần tử (elements).

image 91 - quochung.cyou PTIT

Nước biển là một khối liền mạch. Nhưng máy tính chỉ có thể lưu trữ dữ liệu dưới dạng các con số riêng biệt trong bộ nhớ (RAM).

  • Việc cần làm: Ta đặt một lưới (grid) lên bản đồ vùng biển Krakatau. Mỗi ô vuông (ô đơn vị) sẽ đại diện cho một vị trí.
  • Dữ liệu lưu trữ: Tại mỗi ô, ta lưu một con số cụ thể như: Độ cao mực nước hiện tại.
    • Ô gần núi lửa: 10 mét (đang có sóng lớn).
    • Ô xa núi lửa: 0 mét (nước đang lặng).
Bước 2: Xác định hạt nhân tính toán (kernel) hoặc thao tác trên mỗi phần tử

Hãy tưởng tượng tại giây thứ 1, ô A có sóng cao 10m, ô B bên cạnh có sóng cao 0m.

  • Câu hỏi: Ở giây thứ 2, chuyện gì xảy ra với ô B?
  • Thực tế: Nước từ ô A sẽ tràn sang ô B. Độ cao nước ở ô B sẽ tăng lên.

Nếu ta chỉ tính toán “cô lập” (chỉ nhìn vào ô B mà không nhìn xung quanh), máy tính sẽ thấy ô B đang là 0m và mãi mãi là 0m. Nó không hề biết có một con sóng cực lớn ngay bên cạnh sắp ập đến.

Để biết tương lai của một vị trí (ô trung tâm), ta bắt buộc phải xem xét tình trạng của các vị trí xung quanh (lân cận). Đây chính là sự tương tác vật lý.

Các phép tính trên dữ liệu rời rạc này thường ở dạng toán tử khuôn mẫu (stencil operation). Nó được gọi như vậy vì nó liên quan đến một mẫu các ô lân cận để tính toán giá trị mới cho ô trung tâm.

image 92 - quochung.cyou PTIT

Mô phỏng sự thay đổi (Wave Propagation): Trong bài toán Krakatau, việc dùng Stencil giúp máy tính tính toán sự lan truyền. Sóng từ ô này truyền sang ô kia qua từng bước thời gian.

  • Bước 1: Tính toán dựa trên lân cận.
  • Bước 2: Cập nhật giá trị mới.
  • Bước 3: Lặp lại (Sóng sẽ di chuyển dần trên màn hình).
Bước 3: Vector hóa để xử lý nhiều đơn vị dữ liệu cùng lúc

Chúng ta bắt đầu giới thiệu tính song song bằng cách nhìn vào Vector hóa (Vectorization). Một số bộ vi xử lý có khả năng thao tác trên nhiều mảnh dữ liệu cùng một lúc; khả năng này được gọi là các thao tác vector.

image 93 - quochung.cyou PTIT
Bước 4: Luồng (Threads) để triển khai nhiều lộ trình tính toán

Vì hầu hết các CPU ngày nay có ít nhất 4 lõi xử lý, chúng ta sử dụng đa luồng (threading) để vận hành các lõi đồng thời trên bốn hàng cùng một lúc.

image 94 - quochung.cyou PTIT
Bước 5: Tiến trình (Processes) để trải rộng tính toán sang các không gian bộ nhớ riêng biệt

Chúng ta có thể chia nhỏ công việc hơn nữa giữa các bộ vi xử lý trên hai máy tính để bàn, thường được gọi là các nút (nodes) trong xử lý song song. Khi công việc được chia tách giữa các nút, không gian bộ nhớ cho mỗi nút là riêng biệt và độc lập.

image 95 - quochung.cyou PTIT

Ngay cả với kịch bản phần cứng khiêm tốn này, tiềm năng tăng tốc đã là 32 lần.

Mô hình phần cứng cho các hệ thống song song

Để hiểu cách tính toán song song vận hành, chúng ta cần nắm rõ các thành phần phần cứng:

  • DRAM (Dynamic Random Access Memory): Bộ nhớ lưu trữ thông tin/dữ liệu.
  • Lõi tính toán (Core): Thực hiện các phép tính số học và logic.
  • Bộ xử lý (Processor/CPU): Được trang bị nhiều lõi.
  • Thiết bị tăng tốc (Accelerator): Thường là GPU, với hàng nghìn lõi và không gian bộ nhớ riêng.
  • Nút tính toán (Compute Node): Sự kết hợp của CPU, DRAM và thiết bị tăng tốc (ví dụ: một thùng máy đơn lẻ).
  • Mạng kết nối (Interconnect): Liên kết các nút với nhau.

Kiến trúc bộ nhớ phân tán (Distributed memory): Cross Node

image 96 - quochung.cyou PTIT

Một trong những hướng tiếp cận đầu tiên và có khả năng mở rộng cao là một hệ thống bộ nhớ phân tán. Mỗi CPU thì sẽ có bộ nhớ riêng (DRAM) và kết nối với CPU khác. Có thể thấy kiến trúc này cho phép ta thêm nhiều node CPU hơn không giới hạn, do các memory của CPU đều độc lập.

Tradeoff của cách tiếp cận này là lập trình viên phía trên có thể phải tự quản lý, phân chia vùng nhớ bên dưới ngay từ đầu.

Kiến trúc bộ nhớ chia sẻ (Shared memory): On Node

01_16

Một hướng tiếp cận khác là kết nối 2 CPU trực tiếp vào 1 vùng nhớ chung. Cách này cho phép nhiều CPU có chung một vùng địa chỉ bộ nhớ, giúp lập trình đơn giản hơn. Hiển nhiên nó cũng đi kèm tradeoff như rủi ro về xung đột bộ nhớ, ví dụ như 2 chương trình trên 2 CPU tranh chấp tài nguyên trên cùng 1 địa chỉ, ảnh hưởng đến độ chính xác hay hiệu năng. Việc đồng bộ các thao tác truy cập giá trị của bộ nhớ giữa các CPU có thể sẽ khá phức tạp và tốn kém.

Vector units: Nhiều thao tác trong cùng một instruction

Như đã nói ở đầu bài, ta không thể đơn giản là tăng tần số xung nhịp (clock frequency) của bộ vi xử lý để đạt được năng suất xử lý (throughput) cao hơn như cách ta đã làm trong quá khứ nữa. Trở ngại lớn nhất trong việc tăng tần số xung nhịp CPU là việc này đòi hỏi nhiều điện năng hơn và sinh ra nhiều nhiệt hơn.

1. Khái niệm Xung nhịp (Clock Frequency) và Chu kỳ (Cycle): Mọi tính toán trong CPU được điều phối bởi một bộ dao động thạch anh. Mỗi “nhịp” (tick) là một cơ hội để các bóng bán dẫn (transistor) chuyển trạng thái (đóng/mở) để xử lý dữ liệu.

  • Vấn đề: Để tăng xung nhịp (ví dụ từ 2GHz lên 5GHz), các electron phải di chuyển nhanh hơn và các transistor phải đóng mở nhanh hơn.

2. Công thức Năng lượng tiêu thụ (The Power Wall):

Dù đó là một trung tâm siêu máy tính (HPC) với những giới hạn về hệ thống đường dây điện lắp đặt, hay là chiếc điện thoại di động của ta với dung lượng pin hữu hạn, tất cả các thiết bị ngày nay đều đối mặt với những giới hạn về năng lượng.

Điện năng tiêu thụ của một chip bán dẫn (P) tỷ lệ thuận với điện dung (C), bình phương điện áp (V^2) và tần số xung nhịp (f):

PCV2fP \approx C \cdot V^2 \cdot f

Khi ta tăng tần số (f), ta buộc phải tăng điện áp (V) để duy trì tính ổn định của tín hiệu điện. Vì V được bình phương, lượng nhiệt sinh ra tăng theo hàm mũ. Đến một ngưỡng nhất định, chúng ta không thể tản nhiệt kịp cho chip mà không làm nó nóng chảy. Đây gọi là Bức tường năng lượng (Power Wall).

Kết luận: Vì không thể làm cho một “nhịp” chạy nhanh hơn mãi mãi, kiến trúc sư máy tính buộc phải tìm cách làm được nhiều việc hơn trong cùng một nhịp.

Thay vì tăng tần số xung nhịp, tại sao ta không thực hiện nhiều hơn một hoạt động (operation) trong mỗi chu kỳ (cycle)? Đây chính là ý tưởng cốt lõi đằng sau cơ chế vector hóa trên nhiều dòng bộ vi xử lý hiện nay.

1. Xử lý Vô hướng (Scalar Processing – SISD): Trong kiến trúc truyền thống (Single Instruction, Single Data – SISD), mỗi chỉ thị (Instruction) chỉ tác động lên một cặp dữ liệu duy nhất.

  • Cơ chế: Nếu ta muốn cộng 8 cặp số, CPU phải thực hiện 8 lần: Lấy lệnh cộng -> Lấy dữ liệu A1, B1 -> Cộng -> Lưu kết quả -> Lặp lại 8 lần.
  • Chi phí: Mỗi lần thực hiện, CPU tốn năng lượng để “giải mã chỉ thị” (Instruction Decoding) và điều phối dòng dữ liệu. Với 8 phép cộng, ta tốn 8 lần chi phí giải mã.

Xử lý Vector (Vector Processing – SIMD): Vectorization dựa trên mô hình SIMD (Single Instruction, Multiple Data) – Một chỉ thị duy nhất điều khiển nhiều luồng dữ liệu cùng lúc.

  • Cấu tạo phần cứng: Thay vì một bộ logic toán học (ALU – Arithmetic Logic Unit) hẹp, CPU trang bị các Vector Units có các thanh ghi (Registers) rất rộng (128-bit, 256-bit hoặc 512-bit).
  • Cơ chế thực thi: Thay vì nạp một số 32-bit vào thanh ghi, CPU nạp một “mảng” gồm nhiều số 32-bit vào thanh ghi rộng này. Khi lệnh ADD (Cộng) được phát ra, Vector Unit sẽ kích hoạt nhiều ALU song song để cộng toàn bộ các cặp số đó trong đúng một chu kỳ xung nhịp.

Việc viết mã để tận dụng SIMD rất phức tạp vì nó phụ thuộc vào kiến trúc tập lệnh của từng loại CPU (như AVX-512 của Intel hay NEON của ARM).

  • Compiler Analysis: Trình biên dịch tự động phân tích các vòng lặp (loop) và cố gắng chuyển chúng thành lệnh Vector.
  • Pragmas/Directives: Là các chỉ dẫn (ví dụ: #pragma omp simd) có thể được chèn vào mã nguồn để ra lệnh cho trình biên dịch: “Tôi xác nhận đoạn mã này an toàn, hãy vector hóa nó”. Nếu không có các chỉ dẫn này, trình biên dịch thường chọn phương án an toàn là chạy tuần tự để tránh sai sót dữ liệu.

Nếu Vector tốt như vậy, tại sao chúng ta vẫn cần Scalar và tại sao không chạy mọi thứ song song trên vector?

Sự phụ thuộc dữ liệu (Data Dependency):

Cơ chế Vector chỉ hoạt động khi các phép tính độc lập với nhau.

  • Nếu ta tính: A = B + C và D = A + E. Ta không thể dùng Vector để tính A và D cùng lúc vì giá trị của D phụ thuộc vào kết quả của A.

Cấu trúc dữ liệu không liên tục (Non-contiguous Memory): Để Vector Unit hoạt động hiệu quả, dữ liệu phải nằm sát nhau trong bộ nhớ (Memory) để nạp vào thanh ghi rộng một cách nhanh chóng. Nếu dữ liệu nằm rải rác, CPU mất nhiều thời gian để thu thập (Gather), làm triệt tiêu lợi thế về tốc độ.

Chi phí phần cứng và Độ phức tạp: Mở rộng Vector Unit (ví dụ lên 2048-bit) đòi hỏi một diện tích chip khổng lồ cho các thanh ghi và các đường dẫn dữ liệu. Nó cũng làm cho bộ tập lệnh (ISA) trở nên cực kỳ phức tạp cho các trình biên dịch (Compiler) để tối ưu hóa.

Hầu hết các CPU và GPU hiện đại đều có khả năng vector hóa hoặc các cơ chế hoạt động tương đương. Lượng dữ liệu được xử lý trong một chu kỳ xung nhịp được gọi là độ dài vector (vector length). Độ dài này phụ thuộc trực tiếp vào kích thước vật lý của các đơn vị vector có trên bộ vi xử lý.

Hiện nay, độ dài vector phổ biến nhất là 256-bit. Nếu dữ liệu đã được rời rạc hóa là các số thực dấu phẩy động 64-bit (kiểu double), thì ta có thể thực hiện đồng thời bốn phép toán dấu phẩy động dưới dạng một hoạt động vector đơn nhất (256 / 64 = 4).

image 97 - quochung.cyou PTIT

Các đơn vị phần cứng vector sẽ:

  1. Nạp (load) một khối dữ liệu vào cùng một lúc.
  2. Thực hiện đồng thời một hoạt động duy nhất trên toàn bộ khối dữ liệu đó.
  3. Sau đó lưu trữ (store) kết quả cuối cùng.
Thiết bị tăng tốc: Bộ vi xử lý bổ sung chuyên dụng

Thiết bị tăng tốc (accelerator device) là một thành phần phần cứng rời rạc được thiết kế để thực thi các tác vụ cụ thể với tốc độ cao. Thiết bị tăng tốc phổ biến nhất hiện nay là GPU (Graphics Processing Unit – Đơn vị xử lý đồ họa). Khi được sử dụng cho mục đích tính toán thuần túy thay vì hiển thị hình ảnh, thiết bị này đôi khi được gọi là GPGPU (General-Purpose Graphics Processing Unit – Đơn vị xử lý đồ họa đa mục đích).

Cấu tạo và hiệu năng của GPU

GPU chứa rất nhiều lõi xử lý nhỏ, được gọi là các bộ đa xử lý luồng (Streaming Multiprocessors – SMs). Mặc dù cấu trúc của một SM đơn giản hơn so với một lõi CPU, nhưng tổng hợp lại, các SM cung cấp một sức mạnh xử lý khổng lồ. Thông thường, ta sẽ tìm thấy một GPU tích hợp (integrated GPU) có kích thước nhỏ nằm ngay bên trong CPU.

GPU rời và chi phí kết nối

Hầu hết các máy tính hiện đại còn sở hữu một GPU rời (discrete GPU) riêng biệt, kết nối với CPU thông qua bus PCI (Peripheral Component Interface). Việc sử dụng bus này làm phát sinh chi phí kết nối giữa CPU và GPU (communication cost) đối với dữ liệu và các chỉ lệnh. Tuy nhiên, các card đồ họa rời thường mạnh mẽ hơn nhiều so với các đơn vị tích hợp sẵn, do chúng được thiết kế cho một mục đích rõ ràng hơn, và có nhiều không gian phát triển hơn.

image 98 - quochung.cyou PTIT

Trong các hệ thống cao cấp, các nhà sản xuất đã phát triển các công nghệ kết nối riêng để giảm thiểu chi phí truyền tải dữ liệu này, ví dụ:

  • NVIDIA sử dụng công nghệ NVLink.
  • AMD Radeon sử dụng công nghệ Infinity Fabric.

Bây giờ, ta hãy kết hợp tất cả các kiến trúc phần cứng khác nhau đã nêu vào một mô hình thống nhất. Trong mô hình này, hai nút (nodes) mỗi nút sở hữu hai CPU cùng chia sẻ bộ nhớ DRAM.

image 99 - quochung.cyou PTIT
Đặc điểm của mô hình
  • Mỗi CPU là một bộ vi xử lý đa nhân (dual-core) tích hợp sẵn GPU.
  • Một GPU rời được kết nối thông qua bus PCI vào một trong các CPU.
  • Mặc dù các CPU dùng chung bộ nhớ chính, nhưng chúng thường nằm trong các vùng Truy cập bộ nhớ không đồng nhất (Non-Uniform Memory Access – NUMA) khác nhau. Điều này có nghĩa là việc một CPU truy cập vào bộ nhớ của CPU thứ hai sẽ tiêu tốn nhiều tài nguyên (đắt đỏ) hơn so với việc truy cập vào bộ nhớ cục bộ của chính nó.

Tầng hệ điều hành

Phần mềm dành cho tính toán song song nhất thiết phải dựa trên cấu trúc phần cứng bên dưới, tuy nhiên nó vẫn là một thực thể tách biệt với phần cứng. Hệ điều hành (OS) đóng vai trò là giao diện trung gian kết nối giữa hai thực thể này.

Ta cần lưu ý rằng các hoạt động song song không tự nhiên phát sinh; thay vào đó, mã nguồn phải chỉ định rõ cách thức song song hóa công việc. Điều này thực hiện thông qua việc:

  • Khởi tạo các tiến trình (processes) hoặc các luồng (threads).
  • Chuyển giao (offloading) dữ liệu, công việc và chỉ lệnh sang một thiết bị tính toán chuyên dụng.
  • Thực hiện thao tác trên các khối dữ liệu cùng một lúc.

Trước hết ta nên xác định các thành phần có thể song song hóa, quyết định kỹ thuật tối ưu nhất để vận hành song song. Dưới đây là bốn kỹ thuật phổ biến nhất:

  • Song song hóa dựa trên tiến trình (Process-based parallelization): Chuyển tin nhắn (Message passing).
  • Song song hóa dựa trên luồng (Thread-based parallelization): Chia sẻ dữ liệu qua bộ nhớ.
  • Vector hóa (Vectorization): Thực hiện nhiều thao tác với một chỉ lệnh duy nhất. <- Đã được nêu qua ở trên
  • Xử lý luồng (Stream processing): Thông qua các bộ vi xử lý chuyên dụng.

Song song hóa dựa trên tiến trình: Cơ chế chuyển tin nhắn (Message Passing)

Đơn vị thực thi: Tiến trình (Process)

Khi ta chạy một chương trình, Hệ điều hành (OS) tạo ra một Tiến trình. Một tiến trình không chỉ là mã nguồn; nó là một thực thể bao gồm:

  • Address Space (Không gian địa chỉ): Một dải bộ nhớ RAM mà OS cấp riêng cho tiến trình đó.
  • Registers & Program Counter: Các thanh ghi vật lý trên CPU lưu trữ trạng thái hiện tại của lệnh đang chạy.
  • Instruction Pipeline: Luồng các lệnh mã máy đang chờ được CPU xử lý.
Cơ chế cô lập bộ nhớ (Memory Isolation)

Trong các hệ điều hành hiện đại, Tiến trình A không thể đọc hoặc ghi vào bộ nhớ của Tiến trình B. Đây là một tính năng bảo mật và ổn định. Nếu Tiến trình A bị lỗi (crash), nó không làm ảnh hưởng đến dữ liệu của Tiến trình B.

Vấn đề: Nếu ta muốn chia một bài toán lớn (ví dụ: nhân hai ma trận khổng lồ) cho 10 tiến trình cùng làm, làm sao Tiến trình 1 gửi kết quả bán thành phẩm cho Tiến trình 2 khi bộ nhớ của chúng hoàn toàn tách biệt?

Giải pháp: Cần một cơ chế để “copy” dữ liệu từ không gian bộ nhớ này sang không gian bộ nhớ khác. Đó chính là lý do Message Passing (Chuyển tin nhắn) ra đời.

Tại sao không cho các tiến trình dùng chung bộ nhớ? Vì giới hạn vật lý của phần cứng.

  • Shared Memory (Bộ nhớ dùng chung): Nhiều nhân CPU cùng cắm vào một thanh RAM. Giới hạn là số lượng khe cắm RAM và băng thông đường truyền trên bo mạch chủ.
  • Distributed Memory (Bộ nhớ phân tán): Đây là kiến trúc của các siêu máy tính hoặc cụm máy chủ (Cluster). Mỗi “Nút” (Node) là một máy tính hoàn chỉnh có CPU và RAM riêng. Các nút này nối với nhau qua mạng (Ethernet hoặc InfiniBand).

Trong kiến trúc phân tán, CPU ở Node A tuyệt đối không có cách nào chạm tới thanh RAM ở Node B về mặt vật lý. Cách duy nhất là đóng gói dữ liệu thành một Tin nhắn (Message) và gửi qua dây dẫn mạng.

image 100 - quochung.cyou PTIT
Khái niệm Ranks và Không gian bộ nhớ

Trong mô hình này, ứng dụng của ta sẽ khởi tạo các tiến trình riêng biệt, được gọi là các ranks trong thuật ngữ chuyển tin nhắn. Mỗi tiến trình này có không gian bộ nhớ riêng và đường ống chỉ lệnh (instruction pipeline) riêng biệt.

Khi chạy một ứng dụng song song dùng MPI, ta khởi tạo N bản sao của cùng một chương trình.

  • Để phân biệt chúng, MPI gán cho mỗi bản sao một con số định danh duy nhất, gọi là Rank, bắt đầu từ 0, 1, 2, …, N-1.
  • Dựa vào Rank, lập trình viên viết logic: “Nếu tôi là Rank 0, tôi sẽ điều phối; nếu tôi là Rank 1, tôi sẽ tính toán phần dữ liệu này”.
Không gian Người dùng (User Space) vs. Nhân (Kernel Space)

Quá trình gửi tin nhắn diễn ra qua hai tầng:

  1. User Space: Nơi mã ứng dụng của ta chạy. Ta gọi hàm MPI_Send(dữ liệu).
  2. Kernel Space: Nhân của hệ điều hành quản lý phần cứng (Card mạng).
  3. Cơ chế: Dữ liệu được copy từ User Space xuống Kernel Space, sau đó Kernel đẩy dữ liệu qua Driver card mạng để sang máy khác.
Phân biệt Tiến trình (Phần mềm) và Lõi/Core (Phần cứng)

Thực tế 1 tiến trình không luôn chạy trên 1 lõi cố định.

  • Core (Lõi): Là một mạch điện vật lý thực hiện tính toán.
  • Scheduler (Trình điều phối của OS): Là một thuật toán trong nhân OS. Nó quyết định tiến trình nào được sử dụng lõi nào tại thời điểm nào.

Hiện tượng Context Switching (Chuyển đổi ngữ cảnh): Nếu ta có 4 lõi nhưng chạy 8 tiến trình, OS sẽ cho mỗi tiến trình chạy khoảng vài miligiây, sau đó tạm dừng (lưu trạng thái vào RAM), và nạp tiến trình khác vào lõi. Việc này diễn ra liên tục, tạo cảm giác 8 tiến trình chạy song song, nhưng thực tế chúng đang “chia sẻ” thời gian của lõi.

image 101 - quochung.cyou PTIT

Trong tính toán hiệu năng cao (HPC), người ta thường dùng kỹ thuật Binding (Ràng buộc): Ép chặt Rank 0 vào Core 0, Rank 1 vào Core 1 để tránh lãng phí thời gian chuyển đổi ngữ cảnh của OS.

Tính toán Phân tán (Distributed Computing) vs. Tính toán Song song (Parallel Computing)

Mặc dù cả hai đều dùng nhiều thực thể tính toán, chúng khác nhau về mức độ kết nối (Coupling).

Đặc điểmTính toán Song song (MPI)Tính toán Phân tán (P2P, Web)
Mục tiêuTối ưu tốc độ hoàn thành 1 tác vụ đơn lẻ.Tối ưu độ tin cậy và khả năng phục vụ từ xa.
Giao tiếpTightly-coupled (Kết nối chặt): Các tiến trình phụ thuộc nhau rất lớn, tốc độ mạng cực nhanh (micro-giây).Loosely-coupled (Kết nối lỏng): Các tiến trình độc lập hơn, có thể nằm cách xa nhau địa lý, chịu được độ trễ mạng cao.
Cơ chếSử dụng thư viện chuyên dụng như MPI.Sử dụng các giao thức hệ thống như RPC (Remote Procedure Call) hoặc IPC (Inter-process communication) qua mạng.

Ví dụ: Trong dự án, máy tính nhận một khối dữ liệu, xử lý trong 1 tiếng, sau đó gửi kết quả về máy chủ. Nếu máy tắt đột ngột, hệ thống vẫn chạy bình thường. Trong MPI, nếu một Rank bị lỗi, thường toàn bộ ứng dụng song song sẽ dừng lại để đảm bảo tính toàn vẹn dữ liệu.

Song song hóa dựa trên luồng: Chia sẻ dữ liệu qua bộ nhớ

Khi ta chạy một chương trình (process), hệ điều hành cấp cho nó một không gian bộ nhớ riêng biệt (Address Space). Không gian này chứa mã lệnh, dữ liệu và các tài nguyên khác.

Việc khởi tạo một Tiến trình mới rất tốn kém về tài nguyên hệ thống vì hệ điều hành phải cấp phát một vùng nhớ hoàn toàn độc lập. Luồng ra đời để giải quyết vấn đề này. Một luồng là một đơn vị thực thi nhỏ nhất nằm bên trong một tiến trình.

  • Con trỏ chỉ lệnh (Instruction Pointer – IP): Đây là một thanh ghi trong CPU lưu trữ địa chỉ của lệnh tiếp theo sẽ được thực thi. Trong một tiến trình đơn luồng, chỉ có một IP. Để thực hiện song song, ta khởi tạo nhiều IP chạy đồng thời trên các nhân CPU khác nhau, mỗi IP đại diện cho một luồng.
  • Cơ chế chia sẻ bộ nhớ: Vì tất cả các luồng thuộc cùng một tiến trình, chúng nhìn thấy chung một không gian địa chỉ. Điều này có nghĩa là luồng A có thể ghi một giá trị vào biến x và luồng B có thể đọc giá trị đó ngay lập tức mà không cần qua các bước chuyển giao dữ liệu phức tạp. Tuy nhiên điều này cũng tạo ra 1 vấn đề mới.
image 102 - quochung.cyou PTIT

Khi hai luồng cùng truy cập và sửa đổi một vùng nhớ đồng thời, kết quả cuối cùng phụ thuộc vào việc luồng nào chạy nhanh hơn (Race Condition). Đây là lỗi logic cực kỳ khó phát hiện. Để xử lý, thông thường cần tới các cơ chế đồng bộ hóa (như locks hoặc barriers) để đảm bảo tại một thời điểm chỉ một luồng được thay đổi dữ liệu quan trọng.

Xử lý luồng thông qua bộ vi xử lý chuyên dụng (GPU)

Xử lý luồng (Stream processing) là một khái niệm về luồng dữ liệu (dataflow), trong đó một luồng dữ liệu được xử lý bởi một bộ vi xử lý chuyên dụng có cấu tạo đơn giản hơn. Kỹ thuật này từ lâu đã được sử dụng trong tính toán nhúng, sau đó được điều chỉnh để kết xuất các tập hợp vật thể hình học lớn cho màn hình máy tính trong một bộ vi xử lý chuyên dụng là GPU.

Từ xử lý đồ họa đến GPGPU

Các GPU này được thiết kế với một tập hợp rộng các phép toán số học và nhiều bộ đa xử lý luồng (SMs) để xử lý dữ liệu hình học song song. Vậy cụ thể GPU đã làm gì để thực hiện tốt hơn?

Đường ống lệnh (Instruction Pipeline)

Trước hết xem xét ở kiến trúc CPU, một lệnh (instruction) đi vào CPU được chia thành các giai đoạn (Stages):

  1. Fetch (Lấy lệnh): Lấy lệnh từ bộ nhớ.
  2. Decode (Giải mã): Hiểu lệnh đó yêu cầu làm gì.
  3. Execute (Thực thi): ALU thực hiện phép toán.
  4. Write-back (Ghi kết quả): Lưu kết quả vào thanh ghi.

Các mạch này có thực hiện việc nối đầu mạch. Ta thấy nếu chạy theo tuần tự:

  • Chu kỳ 1: Mạch A chạy. Mạch B, C, D đứng im (Idle).
  • Chu kỳ 2: Mạch B chạy. Mạch A, C, D đứng im.
  • … Cứ thế, tại một thời điểm chỉ có 25% phần cứng CPU hoạt động.

Để tối ưu (Pipelining): Khi lệnh số 1 chuyển sang Mạch B, CPU lập tức đẩy lệnh số 2 vào Mạch A. Mục tiêu là để tất cả các mạch A, B, C, D đều có việc để làm trong mỗi chu kỳ xung nhịp.

Hiện tượng này xảy ra khi một mạch ở phía sau không thể thực hiện việc của mình vì nó thiếu thông tin từ mạch phía trước. Tuy nhiên tại 1 số thời điểm, hiện tượng Stall có thể xảy ra

Giả sử ta có 2 lệnh:

  1. Lệnh 1: X = 5 + 5
  2. Lệnh 2: Y = X * 2

Tiến trình trong đường ống:

  • Chu kỳ 1: Mạch A lấy Lệnh 1.
  • Chu kỳ 2: Mạch B giải mã Lệnh 1 | Mạch A lấy Lệnh 2.
  • Chu kỳ 3: Mạch C tính 5+5 cho Lệnh 1 | Mạch B giải mã Lệnh 2.
    • Vấn đề: Mạch B giải mã Lệnh 2 và thấy nó cần giá trị X. Nhưng lúc này Mạch C mới đang tính X, chưa có kết quả cuối cùng.
  • Chu kỳ 4: Mạch B (giải mã Lệnh 2) buộc phải phát tín hiệu “Dừng”. Mạch A cũng phải dừng theo vì không có chỗ để đẩy lệnh mới vào.
    • Kết quả: Mạch C và D vẫn chạy nốt Lệnh 1, nhưng Mạch A và B bị Trống (Stall). CPU tốn một chu kỳ xung nhịp mà không tạo ra kết quả hữu ích nào

Giả sử có lệnh điều kiện: Nếu (A > B) thì nhảy đến Lệnh X, nếu không thì chạy tiếp Lệnh Y.

  • Tại giai đoạn Fetch (Mạch A), CPU nạp lệnh tiếp theo theo thứ tự mặc định là Lệnh Y.
  • Nhưng phải đợi 2 chu kỳ sau, khi lệnh Nếu (A > B) đi đến giai đoạn Execute (Mạch C), CPU mới biết kết quả là đúng hay sai.

Để tối ưu, CPU sử dụng một bộ phận gọi là Branch Predictor (dựa trên lịch sử các lần chạy trước) để “đoán” xem nhánh nào sẽ được chọn và nạp sẵn các lệnh của nhánh đó vào đường ống. Nếu đoán sai, CPU buộc phải hủy toàn bộ các lệnh đã nạp nhầm, xóa sạch đường ống (Pipeline Flush) và bắt đầu lại từ điểm rẽ nhánh đúng.

Ngoài ra CPU cũng thực hiện Out-of-order Execution (OoOE – Thực thi ngoài tuần tự). Trong một chương trình, các lệnh thường có sự phụ thuộc lẫn nhau.

  • Xét 2 lệnh:
    1. A = Lấy dữ liệu từ RAM (Mất 200 nhịp)
    2. B = A + 5 (Phụ thuộc vào A)
    3. C = 10 * 2 (Không phụ thuộc vào A hay B) Nếu chạy tuần tự, lệnh số 3 phải đợi lệnh số 1 và 2 hoàn thành, dù nó chẳng liên quan gì. CPU sẽ đứng im chờ dữ liệu từ RAM.
  • Cơ chế OoOE: CPU có một bộ đệm gọi là Instruction Window thực hiện quét các lệnh sắp tới, phân tích xem lệnh nào đã có đủ dữ liệu đầu vào. Trong ví dụ trên, CPU thấy lệnh số 3 đã sẵn sàng, nó sẽ đẩy lệnh 3 lên thực thi trước trong khi lệnh 1 vẫn đang chờ RAM. Sau khi có kết quả, nó sẽ sắp xếp lại (Reorder Buffer) để đảm bảo kết quả cuối cùng đúng như thứ tự.

Ngược lại, GPU được thiết kế cho Parallelism (Song song hóa), không phải Latency (Độ trễ).

GPU sử dụng cơ chế Hardware Multithreading. Nếu một nhóm nhân (thread) đang chờ dữ liệu từ RAM, GPU ngay lập tức tráo một nhóm nhân khác vào để tính toán. Nó không cố gắng làm một lệnh chạy nhanh hơn, nó chỉ cố gắng làm nhiều lệnh nhất có thể cùng lúc.

  • Kiến trúc CPU: Dành 1 phần diện tích chip cho ALU tính toán, phần còn lại dành cho Control Logic (Branch Prediction, OoOE) và Cache
  • Kiến trúc GPU: Loại bỏ hầu hết Branch Prediction và OoOE. Thay vào đó, GPU dùng diện tích đó để tập trung cho các nhân tính toán ALU
image 103 - quochung.cyou PTIT

Dữ liệu và hạt nhân tính toán (kernel) được hiển thị là được chuyển giao (offloaded) qua bus PCI sang GPU để tính toán. GPU vẫn còn hạn chế về chức năng so với CPU, nhưng ở những nơi có thể sử dụng các chức năng chuyên dụng này, chúng cung cấp khả năng tính toán phi thường với yêu cầu năng lượng thấp hơn. Các bộ vi xử lý chuyên dụng khác cũng thuộc danh mục này, mặc dù ta sẽ tập trung chủ yếu vào GPU trong các cuộc thảo luận của mình.

Phân loại các cách tiếp cận song song

Trong quá trình tìm hiểu về tính toán song song, ta sẽ bắt gặp các từ viết tắt như SIMD (một chỉ lệnh, nhiều dữ liệu) và MIMD (nhiều chỉ lệnh, nhiều dữ liệu). Những thuật ngữ này thuộc về các danh mục kiến trúc máy tính do Michael Flynn đề xuất vào năm 1966, được biết đến với tên gọi Phân loại của Flynn (Flynn’s Taxonomy).

Hệ thống phân loại này giúp ta nhìn nhận khả năng mà có thể song song hóa trong các kiến trúc theo những cách khác nhau. Việc phân loại dựa trên việc chia nhỏ các chỉ lệnh (instructions) và dữ liệu (data) thành các hoạt động tuần tự hoặc song song

image 104 - quochung.cyou PTIT
  • SISD (Single Instruction, Single Data): Kiến trúc tuần tự truyền thống, một chỉ lệnh xử lý một dữ liệu.
  • MISD (Multiple Instruction, Single Data): Trường hợp có nhiều chuỗi chỉ lệnh cùng xử lý một luồng dữ liệu duy nhất. Đây không phải là một kiến trúc phổ biến; ví dụ điển hình nhất là việc tính toán dư thừa (redundant computation) trên cùng một dữ liệu. Phương pháp này được sử dụng trong các hệ thống đòi hỏi khả năng chịu lỗi cực cao như bộ điều khiển tàu vũ trụ. Do tàu vũ trụ hoạt động trong môi trường bức xạ cao, chúng thường chạy hai bản sao của mỗi phép tính và so sánh kết quả đầu ra của cả hai.
  • SIMD (Single Instruction, Multiple Data): Vector hóa là ví dụ điển hình, trong đó cùng một chỉ lệnh được thực hiện trên nhiều dữ liệu khác nhau. Một biến thể của SIMD là SIMT (Single Instruction, Multi-Thread – Một chỉ lệnh, nhiều luồng), thường được dùng để mô tả các nhóm làm việc (work groups) của GPU.
  • MIMD (Multiple Instruction, Multiple Data): Danh mục cuối cùng có tính song song ở cả chỉ lệnh và dữ liệu. Danh mục này mô tả các kiến trúc song song đa lõi, chiếm đa số trong các hệ thống song song quy mô lớn hiện nay.

Cần lưu ý rằng mặc dù hệ thống phân loại này rất hữu ích, một số kiến trúc và thuật ngữ toán (algorithms) không khớp hoàn toàn vào một danh mục nhất định. Sự hữu dụng của nó nằm ở việc giúp ta nhận diện các đặc điểm của từng loại, chẳng hạn như danh mục SIMD thường gặp khó khăn với các câu lệnh điều kiện (conditionals). Nguyên nhân là vì mỗi mục dữ liệu có thể muốn thực thi một khối mã khác nhau, nhưng tất cả các luồng lại bắt buộc phải thực thi cùng một chỉ lệnh.

Các chiến lược song song

Ở ví dụ trước đó về núi lửa, ta đã xem xét việc song song hóa dữ liệu cho các ô lưới (cells) hoặc điểm ảnh (pixels). Tuy nhiên, song song hóa dữ liệu còn có thể áp dụng cho các hạt (particles) và các đối tượng dữ liệu khác.

Song song hóa dữ liệu (Data Parallelism)

Đây là cách tiếp cận phổ biến nhất và thường là đơn giản nhất. Về cơ bản, mỗi tiến trình thực thi cùng một chương trình nhưng vận hành trên một tập con dữ liệu duy nhất, như được minh họa:

image 105 - quochung.cyou PTIT

Ưu điểm của song song hóa dữ liệu là khả năng mở rộng tốt khi kích thước vấn đề và số lượng bộ vi xử lý tăng lên.

Song song hóa tác vụ (Task Parallelism)

Một cách tiếp cận khác là song song hóa tác vụ. Các chiến lược này bao gồm:

  • Điều khiển chính – Main-worker): Một bộ vi xử lý đóng vai trò điều phối, lập lịch và phân phối các tác vụ cho tất cả các bộ vi xử lý worker. Mỗi worker sẽ kiểm tra mục công việc tiếp theo ngay khi nó trả lại kết quả của tác vụ đã hoàn thành trước đó.
  • Đường ống (Pipeline): Chiến lược này được sử dụng trong các bộ vi xử lý siêu cấp (superscalar processors), nơi các phép tính địa chỉ và số nguyên được thực hiện bằng một đơn vị logic riêng biệt thay vì dùng bộ xử lý số thực dấu phẩy động, cho phép các phép tính này được thực hiện song song.
  • Tiếp sức/Dây chuyền (Bucket-brigade): Sử dụng mỗi bộ vi xử lý để vận hành và biến đổi dữ liệu trong một chuỗi các hoạt động liên tiếp.

Ta cũng có thể kết hợp các chiến lược song song khác nhau để khai thác mức độ song song hóa lớn hơn cho ứng dụng.

Tìm hiểu Convex function

Về mặt hình học, một tập hợp được gọi là Convex (Lồi) nếu bạn lấy bất kỳ hai điểm nào nằm trong tập hợp đó, đoạn thẳng nối chúng cũng phải nằm hoàn toàn bên trong tập hợp đó.

Tại sao ta cần định nghĩa này? Bởi vì nó đại diện cho sự “không có lỗ hổng”“không có điểm lõm”

Trước tiên, hãy thử xem một bài toán nhỏ hơn khác như sau

Cho một số thực [latex]\lambda[/latex] sao cho [latex]0 \le \lambda \le 1[/latex] và hai điểm [latex]x = 2, y = 7[/latex]

image 12 - quochung.cyou PTIT

Khi đó mọi điểm trên đoạn thẳng nối [latex]x[/latex] và [latex]y[/latex] có dạng

[latex]z=(1−λ)x+λy.[/latex]

Nếu [latex]\lambda[/latex] gần 0 hơn, thì nó sẽ ở gần bên x hơn, và ngược lại nếu gần 1 hơn, thì điểm đó sẽ gần y hơn

image 13 - quochung.cyou PTIT

Tiếp theo, cho một hàm số có hình dạng như sau, với hai điểm [latex]x[/latex] và [latex]y[/latex], ta nối và thể hiện vị trí của [latex]f(x)[/latex] và [latex]f(y)[/latex]

image 14 - quochung.cyou PTIT

Đặt hai điểm [latex]A[/latex] và [latex]B[/latex] có toạ độ [latex](x,f(x))[/latex], [latex](y,f(y))[/latex]

image 15 - quochung.cyou PTIT

Lúc này thực hiện nối 2 điểm [latex]A[/latex] và [latex]B[/latex]

image 16 - quochung.cyou PTIT

Như vừa nói qua việc

Khi đó mọi điểm trên đoạn thẳng nối [latex]x[/latex] và [latex]y[/latex] có dạng

[latex]z=(1−λ)x+λy.[/latex]

Ta nhận thấy, lúc này với đoạn thẳng nối từ [latex]A[/latex] tới [latex]B[/latex], từ phần vừa suy ra ở trên, ta có thể biểu diễn độ cao các điểm nằm trên đoạn thẳng này như sau

[latex]z=(1−λ)f(x)+λf(y)[/latex]

Như vậy với [latex]λ=0[/latex], thì độ cao của điểm chính bằng f(x) hay điểm A, và nếu [latex]λ=1[/latex], thì độ cao của điểm chính bằng f(y) hay điểm B, giá trị sẽ giảm dần từ A -> B theo phương trình

image 18 - quochung.cyou PTIT

Lúc này lấy một điểm [latex]T[/latex] và số [latex]z[/latex], ta có, z nằm trên đoạn thẳng nối từ [latex]x[/latex] tới [latex]y[/latex], vậy z có thể biểu diễn bằng

[latex]z=(1−λ)x+λy[/latex]

cũng như

image 20 - quochung.cyou PTIT

[latex]f(z)=f((1−λ)x+λy)[/latex]

Ta thấy, độ cao [latex]f(z)[/latex] nằm ở dưới độ cao điểm [latex]T[/latex]

Hay

f(λx+(1λ)y)<λf(x)+(1λ)f(y)f(\lambda x + (1 – \lambda)y) < \lambda f(x) + (1 – \lambda)f(y)

Lúc này, ta đến với định nghĩa hàm lồi được phát biểu như sau

Định nghĩa hàm lồi (Convex function)

Một hàm số [latex]f(x)[/latex] được gọi là hàm lồi nếu bạn lấy 2 điểm bất kỳ trên đồ thị, nối chúng lại bằng một đoạn thẳng (dây cung), thì đoạn thẳng đó luôn nằm phía trên hoặc trùng với phần đường cong của đồ thị nằm giữa hai điểm đó.

Công thức toán học (Đại số):

f(λx+(1λ)yĐiểm trên trục hoành)λf(x)+(1λ)f(y)Điểm trên đoạn thẳngf(\underbrace{\lambda x + (1 – \lambda)y}_{\text{Điểm trên trục hoành}}) \le \underbrace{\lambda f(x) + (1 – \lambda)f(y)}_{\text{Điểm trên đoạn thẳng}}

(Với [latex]\lambda[/latex] nằm trong khoảng [latex][0, 1][/latex]).

  • Hình dáng: Giống cái cốc ([latex]\cup[/latex]) hoặc cái bát.
  • Ngược lại: Nếu đoạn thẳng nằm dưới, ta có hàm lõm (concave), hình cái mũ ([latex]\cap[/latex]).

Ý nghĩa

image 23 - quochung.cyou PTIT

Nếu một đồ thị có 2 cái đáy (2 cực tiểu), thì bắt buộc ở giữa 2 cái đáy đó phải có một cái đỉnh đồi (cực đại) nhô lên ngăn cách chúng. -> Đồ thị sẽ lượn xuống, rồi lượn lên (tạo đỉnh), rồi lại lượn xuống.

Hãy tập trung vào cái đỉnh đồi nằm giữa đó.

  1. Chọn một điểm [latex]A[/latex] nằm bên sườn trái của ngọn đồi.
  2. Chọn một điểm [latex]B[/latex] nằm bên sườn phải của ngọn đồi.
  3. Nối dây từ [latex]A[/latex] và [latex]B[/latex]
image 24 - quochung.cyou PTIT

Theo thực tế hình vẽ: Vì [latex]A[/latex] và [latex]B[/latex] nằm ở hai bên sườn dốc, còn đỉnh đồi nhô lên ở giữa, nên sợi dây [latex]AB[/latex] sẽ đâm xuyên qua quả đồi. Phần đỉnh đồi sẽ nằm cao hơn sợi dây [latex]AB[/latex]

Theo định nghĩa hàm lồi: Định nghĩa bắt buộc sợi dây [latex]AB[/latex] phải luôn nằm cao hơn hoặc bằng mọi điểm của đồ thị ở giữa [latex]A[/latex] và [latex]B[/latex]

Vì sự tồn tại của một “đỉnh đồi” (ngăn cách 2 cái đáy) vi phạm định nghĩa “sợi dây nằm trên”, nên:

  1. Hàm lồi tuyệt đối không được phép có đỉnh đồi nào (không có cực đại địa phương).
  2. Nếu không có đỉnh đồi nào ngăn cách, thì không thể có 2 cái đáy riêng biệt.
  3. Hệ quả: Nó chỉ có thể đi xuống rồi đi lên duy nhất 1 lần (hình chữ U hoặc V). [latex]\rightarrow[/latex] Chỉ có 1 đáy duy nhất.

Khi bạn muốn tìm giải pháp tốt nhất (chi phí thấp nhất, lợi nhuận cao nhất, sai số nhỏ nhất), bạn đang đi tìm “đáy” của một đồ thị. Với các hàm bình thường (không lồi), đồ thị giống như một dãy núi nhấp nhô. Bạn có thể leo xuống một thung lũng (cực tiểu địa phương – Local Minimum) và tưởng đó là nơi thấp nhất, nhưng thực ra bên cạnh còn có một cái hực sâu hơn (Global Minimum) mà bạn không thấy vì bị ngọn núi che khuất.

image 22 - quochung.cyou PTIT

Attempt to finetune SLM for solving RCA in 5G network data

Part I: Introduction & The RCA Problem Space

Modern mobile networks are complex systems requiring high reliability. However, despite monitoring, faults like hardware failures or software misconfigurations occur. While detecting a fault is straightforward, the main challenge is Root Cause Analysis (RCA) — finding the root cause of the symptoms to help engineers fix them.

This project replicates the paper (Reasoning Language Models for Root Cause Analysis in 5G Wireless Networks) https://arxiv.org/pdf/2507.21974

The Complexity of 5G O&M

Traditionally, RCA relied on expert-defined logical frameworks or “fault trees”. However, these methods don’t scale well with 5G network complexity. While standard machine learning (Decision Trees, SVMs, Neural Networks) has been used, these models often lack the interpretability and reasoning needed for critical infrastructure.

Defining the Objective: RCA as Probabilistic Inference

We treat RCA as a probabilistic inference task. Formally, the goal is to identify the most probable cause from a set of potential root causes , given:

  • ****: Network engineering parameters.
  • ****: User plane observations.
  • ****: Observed symptoms.

The objective is to solve for:

c^=argmaxcCp(c|U,Yt,st)

This mathematical formulation provides a framework, but modeling these intricate dependencies in real-world data is notoriously difficult. This implementation explores how domain-adapted, reasoning-enhanced Large Language Models (LLMs) can bridge this gap by providing structured, multi-step diagnostic explanations.

The TeleLogs Framework

To benchmark these capabilities, we utilize TeleLogs, a curated dataset of network troubleshooting scenarios with expert-level annotations. TeleLogs simulates a realistic 5G environment where a User Equipment (UE) moves through a region covered by multiple Base Stations (BSs), providing full visibility into network configurations and performance drops.


Part II: Dataset Architecture & Diagnostic Parameters

To perform effective RCA, the model must synthesize three distinct data streams: configuration parameters, time-series observations, and defined symptoms.

Symptom Definition: The 600 Mbps Threshold

In this study, diagnostic scenarios are centered around a specific symptom (st): a significant degradation in downlink throughput where the performance falls below 600 Mbps. This drop serves as the trigger for the analysis, requiring the model to identify if the cause is environmental, a misconfiguration, or a mobility issue.

Network Engineering Parameters (U)

The dataset provides a comprehensive view of the network topology through static configuration parameters. Key parameters used in our analysis include:

ParameterDescription
gNodeB ID / Cell IDUnique identifiers for the base station and cell.
Mechanical/Digital TiltThe vertical angle of the antenna, essential for coverage analysis.
Mechanical/Digital AzimuthThe horizontal direction of the antenna.
Beam ScenarioSpecific beamforming configurations that determine vertical beamwidth.
Height & PCIPhysical height of the antenna and the Physical Cell ID.
Beam ScenarioSpecific beamforming configurations that determine vertical beamwidth.

User Plane Drive Test Data (Yt)

This represents the dynamic interaction between the user and the network. The model analyzes the following time-series indicators:

  • Throughput (DL): The primary indicator of service quality.
  • RSRP & SINR: Measure the signal strength and quality of the serving cell.
  • Neighboring RSRP: Signal strength from the top-k neighbor cells, used to detect interference or handover opportunities.
  • Resource Blocks (PRBs): The amount of radio resources allocated to the user.

The Ground Truth: Root Cause Classes (C1–C8)

The implementation evaluates the model’s ability to classify symptoms into one of eight distinct categories:

  • C1: Excessive downtilt causing weak coverage.
  • C2: Over-shooting coverage (distance > 1 km).
  • C3: Better performance available on a neighboring cell.
  • C4: Interference from non-colocated co-frequency cells.
  • C5: PCI Mod 30 conflict causing reference signal overlap.
  • C6: Performance degradation due to frequent handovers.
  • C7: Misconfigured handover thresholds.
  • C8: Insufficient PRB allocation.

Part III: Baseline Evaluation

Before fine-tuning, I established a performance baseline using Qwen2.5-1.5B-Instruct.

The Benchmarking Protocol

I ran a zero-shot inference on the TeleLogs Phase 1 test dataset, which contains 864 scenarios. The model was prompted using the standard template to analyze user-plane data and site engineering parameters, then choose the most likely root cause from the eight predefined classes.

Initial Findings

The baseline results showed the model was mostly guessing based on simple patterns:

  • Total Accuracy10.53% (91/864 correct).
  • The C1 Bias: The model correctly identified C1 (Excessive Downtilt) 42.59% of the time but used it as a “catch-all” for errors. In the top 10 most frequent errors, the model predicted C1 for other classes (C6, C8, C7, C2, C3, C4, and C5) a total of 312 times.
  • Instruction Following: While the model often produced the correct format (the boxed answer), it lacked the underlying logic to connect the RSRP/SINR drops to specific mobility or interference issues.
  • Placeholders: For many classes (C2, C4, C5, C8), the accuracy hovered near 3%, indicating that the base model could not differentiate between various types of signal degradation.
analysis report baseline - quochung.cyou PTIT
- quochung.cyou PTIT

Part IV: Synthetic Data Generation Phase 1 — Reasoning Traces

To improve reasoning, I started the first phase of synthetic data generation. The goal was to create a dataset that demonstrates the Chain-of-Thought (CoT) required for RCA.

The Teacher Model: Qwen3-32B

I utilized Qwen3-32B as the high-reasoning agent. Its significantly larger parameter count and inherent reasoning capabilities allowed it to act as the “expert engineer”. Unlike the 1.5B model, the 32B model can synthesize the relationship between (Engineering Parameters) and (User Observations) more effectively.

CoT Prompting Strategy

I implemented a customized prompting strategy designed to guide the model through a structured diagnostic trajectory. The prompt forced the model to:

  1. Analyze Data: Explicitly list the throughput drops and serving cell changes.
  2. Eliminate Unlikely Causes: Systematically rule out causes—for example, ruling out C2 (distance) if the serving cell is far away.
  3. Validate via Reflection: Check for specific conflicts, such as the PCI Mod 30 check for interference.

Data Acquisition

Through this multi-agent pipeline, I harvested an initial set of 138 samples. Each sample consisted of the original network log paired with a detailed reasoning trace leading to the correct ground-truth answer. This dataset was intended to teach the 1.5B model how to think, rather than just what to predict.


Part V: Experiment 1 The First LoRA Attempt

With 138 high-quality reasoning traces, I initiated the first fine-tuning stage. The goal was to align the Qwen2.5-1.5B-Instruct model with the structured diagnostic patterns found in the synthetic dataset. Following the general recommendations for domain adaptation in the provided LoRA research , I established a baseline configuration to mirror the paper’s parameters where possible.

Configuration and Training Setup

  • LoRA Hyperparameters: r = 32, alpha = 32, dropout = 0.1
  • Learning Rate: 1e-6
  • Epochs: 10.
  • Targeting: Applied LoRA across all linear layers (Query, Key, Value, Projection, and MLP) to maximize the model’s capacity to absorb the new domain knowledge.

The Regression Reality: 8.91% Accuracy

The results were a stark contrast to expectations. Instead of improving upon the baseline, the model’s performance dropped to 8.91% (77/864 correct).

  • Placeholder Issues: A significant issue was the increase in “placeholder” predictions. In the top 10 error categories, 7 were instances where the model outputted a placeholder rather than a valid root cause.
  • Reduced Instruction Following: The model lost some ability to follow basic formatting instructions. Focusing on the reasoning traces caused the model to struggle with the required \boxed{} format.
  • Word Count Increase: The mean word count increased from 245 to 651 words, often containing circular logic without a valid conclusion.

Hypothesis: Sample Sparsity and Overfitting

My hypothesis for this failure was sample sparsity. 138 samples are likely insufficient for a 1.5B model to learn both the complex 5G domain rules and the specific “Elimination-based” or “Contradiction-based” prompting strategies used in the dataset. The model likely overfitted to the specific noise of those 138 examples rather than learning the underlying causal relationships.

analysis report 77 - quochung.cyou PTIT
- quochung.cyou PTIT

Part VI: Experiment 2 Scaling Sample Size

To address the sparsity issue identified in Experiment 1, I moved to scale the training data. If 138 samples caused overfitting, perhaps a larger, more diverse dataset would force the model to generalize the reasoning steps.

The 300-Sample Expansion

I re-ran the synthetic generation pipeline with the Qwen3-32B teacher model to reach 300 samples. I also ensured a more balanced distribution across the 8 root cause classes to prevent the model from defaulting to “C1” as it did in the baseline.

Results: Marginal Recovery (9.61%)

Training the 1.5B model on 300 samples yielded a slight recovery but remained below the zero-shot baseline:

  • Total Accuracy: 9.61% (83/864).
  • Improved Class Recognition: Accuracy for C2 (18.52%) and C3 (19.44%) saw noticeable jumps compared to the near-zero baseline performance.
  • Persistent Failures: The “Placeholder” issue remained rampant. Truth C3 and C6 both saw 28 placeholder errors.
  • Word Count Increase: The mean word count increased to 1318 words, with some responses reaching 12,023 tokens.

Thinking: The Depth vs. Clarity Trade-off

This experiment showed that raw reasoning traces led to verbosity. Without synthesizing these traces, the model produced overly long responses.


Part VII: Synthetic Data Generation Phase 2

Experiment 2 showed that more raw reasoning traces were insufficient. The model mimicked the teacher’s verbosity without learning the logic. To fix this, I updated the synthetic data generation strategy.

The Theoretical Shift: From Traces to Correction

Instead of just saving any correct answer, I implemented a “Reflective Teacher” loop using Qwen3-32B. This approach mirrors the “Aggregator” concept in the paper, designed to synthesize concise, structured explanations.

The Reflection Loop

I introduced a two-tier evaluation for every synthetic sample:

  1. Initial Attempt: The teacher model performs RCA on the raw data.
  2. The “Correction” Prompt: * If Correct: The model is instructed to condense the reasoning into a specific “RCA Reasoning Format” (Data Analysis → Root Cause Analysis → Identification), without give it answer to prevent leakage.
  • If Incorrect: I forced a Self-Reflection phase. The system prompt would tell the model its assumption was wrong and demand it identify exactly where the logic failed (e.g., “I ignored the PCI Mod 30 check”). This reflection was then added back to the prompt to generate a “perfected” reasoning trace.

Reasoning Optimization

This new pipeline produced 300 samples of refined data. The reasoning traces were focused on key steps, such as checking the distance for C2 or the PRB count for C8.


Part VIII: Experiment 3 Training on Error-Correction Traces

With the 300 “reflected” samples, I reset the training. This experiment was the first true test of whether teaching a model how it failed was more effective than just showing it how to succeed.

Training on this densified dataset finally yielded an increase in accuracy:

  • Total Accuracy: 12.04% (104/864). While modest, this officially surpassed the zero-shot baseline (10.53%).
  • Mean Word Count: A massive drop from 1318 to 33.5 words. The model stopped hallucinating long, circular paths and started focusing on the final answer.

Class-Specific Breakthrough: The C1 Dominance

  • C1 Accuracy: 73.15% (79/108). This was a significant improvement.
  • Thinking: By learning the rules for C1, the model became proficient at identifying coverage-related drops.
  • Over-correction: However, the model predicted C1 for almost every other class (e.g., Truth C8 → Pred C1: 85 times).

Analysis: The Learning Capacity Gap

Experiment 3 proved that Data Quality > Data Quantity. However, it also revealed that at a low rank (r=32), the 1.5B model was struggling to hold more than one complex rule at a time. It had “mastered” C1 but was overriding its knowledge of other causes to do so. This set the stage for our exploration into LoRA hyperparameters.

analysis report 104 - quochung.cyou PTIT
- quochung.cyou PTIT

Part IX: Theoretical Intermission LoRA Hyperparameter Optimization

The jump to 12% accuracy in Experiment 3 was a proof-of-concept for my self-reflection dataset, but the heavy “C1 bias” suggested the model’s update capacity was saturated.

Raschka’s Principles & The Alpha Heuristic

  • The Alpha Scaling: A common rule of thumb is setting alpha (the scaling factor) to twice the value of r (the rank), effectively alpha = 2 * r. This ensures the influence of the LoRA weights is balanced against the pre-trained weights.
  • Layer Coverage: To maximize performance, LoRA should be applied across all layers, including projection and MLP layers, not just the Key and Value matrices. This increases the number of trainable parameters, which is vital for domain-specific tasks like 5G RCA where the model must learn entirely new technical correlations.

The Capacity Problem: Rank vs. Knowledge

In a 1.5B model, a rank of 32 only updates a small fraction of parameters. This might not provide enough capacity to store 8 distinct 5G diagnostic rules. If the rank is too small, the model may only capture dominant patterns like C1 (Downtilt) and miss others.


Part X: Experiment 4 Expanding the Rank (r=128)

Armed with the theory that our model lacked the “memory” to differentiate between classes, I pushed the rank significantly higher.

The Setup: Boosting Rank and Alpha

  • LoRA Config: r = 128, a = 64
  • Methodology: I maintained the 300 “reflected” samples from the previous phase but allowed the model more degrees of freedom to store the learned weights.

Accuracy: 16.32%

- quochung.cyou PTIT
analysis report 141 - quochung.cyou PTIT

Expanding the rank improved performance:

  • Total Accuracy: 16.32% (141/864).
  • Diversification: The C1 bias decreased. The model’s recognition of C4 (Neighbor interference) jumped to 29.63%, and C7 (Handover thresholds) reached 21.30%.
  • Word Count Consistency: The mean word count remained stable at 960.63 words, indicating that higher rank did not necessarily mean more rambling, but rather more precise reasoning.

Analysis: Reducing Categorical Confusion

In the error logs, we saw a shift. Instead of predicting C1 for everything, the model began confusing similar signal-based issues. For example, Truth C3 (Neighbor throughput) was often confused with C4 (Neighbor interference). This shows progress — the model now understands the problem involves “Neighboring Cells” but is still fine-tuning the specific logic that distinguishes interference from a handover opportunity.


Part XI: Scaling the Dataset 2000 Samples

Recognizing that the 1.5B model’s improvement was driven by better data and increased rank, I increased the training set size. However, scaling synthetic data requires a strict “Teacher-Student” hierarchy to prevent errors.

The “Major Voting” Strategy

I utilized a Qwen2.5-7B-Instruct model, which had better baseline performance, to act as a secondary filter.

  • The Process: For each question, the 7B model generated multiple reasoning trajectories.
  • The Filter: Only samples where the 7B model reached the correct answer via majority voting were added.
  • The Result: This method produced a dataset of 2000 high-quality reasoning traces.

Response-Only Training (SFT Optimization)

To further improve efficiency and focus, I pivoted to training on responses only.

  • The Goal: By masking the loss for the system and user prompts, the model focuses its entire learning capacity on the reasoning steps and the final \boxed{} identification.
  • Alignment: This technique prevents the model from wasting parameter updates on memorizing the structure of the input logs and engineering tables, ensuring it prioritizes the causal logic instead.

Part XII: Experiment 5 High-Rank Performance (r=256)

The final phase of my implementation involved pushing the LoRA rank to the maximum sustainable level for a 1.5B model while utilizing the massive 2000-sample dataset. This experiment aimed to replicate the “Reasoning LLM” performance gains described in the paper.

Final Configuration

  • LoRA Hyperparameters: Rank 256, Alpha 128.
  • Data Density: 2000 “major-voted” samples from the reflection pipeline.
  • Optimization: AdamW optimizer with a cosine learning rate scheduler, applied to all linear layers as suggested by Raschka’s research.

Final Result: 21.41% Accuracy

This iteration achieved the highest performance:

  • Total Accuracy21.41% (185/864 correct).
  • Balanced Learning: The “C1 bias” was significantly mitigated. Accuracy for C7 (Handover Thresholds) reached 37.96%, and C8 (PRB Allocation) surged to 23.15%.
  • PCI Mod 30 Success: The model finally began correctly identifying C6 (PCI Conflict) at a 29.63% rate, proving it had successfully encoded the mathematical relationship between PCI values and reference signal overlap.

Analysis: The Impact of Scale

The jump from 16.32% to 21.41% demonstrates that for a 1.5B model, the combination of dataset density and rank capacity is significant. By providing 2000 examples, the model found common causal themes across different logs, resulting in better generalization rather than rote memorization.

analysis report 185 - quochung.cyou PTIT
- quochung.cyou PTIT

radar performance shift - quochung.cyou PTIT
- quochung.cyou PTIT

A simple extension that fixes my browser chaos

I have a love-hate relationship with browser tabs. I need a lot of them to work, but once I pass the 30-tab mark, my browser bar becomes useless.

Google Chrome actually experimented with an auto-grouping feature a while back, but they removed it. I tried finding alternatives on the Chrome Web Store, but they all had the same problem: They were lazy.

Most existing extensions group tabs based on the domain, even they calling LLM to group it. If they see youtube.com, they dump it in a “YouTube” folder. This is useless for me. If I have 5 tabs open for “Lofi Music” and 5 tabs open for “Python Tutorials,” those shouldn’t be in the same group. One is Work, the other is Background Noise.

I realized that to actually organize tabs, the software needs to read the page, not just the URL. So I spent my free time building Group Tab AI.

How it actually works

image 8 - quochung.cyou PTIT

I didn’t want to over-engineer this, but I needed it to be smart. When you click the button, the extension doesn’t just look at the link. It injects a script to grab the “context” of the page, the H1 title, the meta description, and a snippet of the body text.

It sends that data to an LLM (I set it up to work with either OpenAI or Gemini). Because it reads the content, it can tell that a GitHub page for a “React Library” is different from a GitHub page for “Tracking Issues.”

I’m using Google Gemini 2.0 Flash for this mostly, with the thinkingBudget set to 0. It’s fast enough that by the time I blink, the tabs are sorted.

I spent nights tweaking prompts to make it focus on tasks, not domains with extra context from the website contents along with careful prompt to let them reasoning and choose. For example, if you’re a dev, it might make groups like “Bug Hunting” or “API Docs.” Designers get “Mockups” or “Inspo.” It works for anyone,

students with class notes, marketers with campaigns.

image 10 - quochung.cyou PTIT

The feature I actually wanted: It learns

This is the part I’m most proud of. I know AI isn’t perfect. It’s going to mess up. It might group a design blog under “Development” instead of “Inspiration.”

Usually, with AI tools, you just have to live with the bad output. But I built a Learning System into this.

  1. If the AI groups something wrong, I manually move the tab to the right group.
  2. The extension records that move.
  3. After I’ve corrected it a few times, I can click a button to “Analyze Behavior.”
  4. The system looks at my corrections and rewrites its own system prompt.

Next time I run it, it knows: “Oh, he likes to keep his ‘Localhost’ tabs separate from his ‘Production’ tabs,” because it updated its own instructions based on my manual fixes.

The Tech Stack

For the frontend devs out there, I built this using Plasmo. It’s basically the Next.js of browser extensions, makes working with React and TypeScript in a chrome-extension environment actually bearable.

Everything is local. Your API keys are stored in your browser, and the learning data (your grouping habits) stays on your machine.

Try it out

It’s open source (GPL-3.0). I built it because I needed it, but if you’re tired of domain-based grouping that doesn’t actually help, give it a shot.

https://github.com/quochung-cyou/group-tab-ai-extension

Releases: https://github.com/quochung-cyou/group-tab-ai-extension/releases/

Kaggle Multi Local Module Project Python

image - quochung.cyou PTIT

Kaggle’s setup is amazing for quick experiments but not great when you start treating your work like an actual project. You get one main notebook. That’s it.

If you want to use your own .py files, you basically have to zip them up, upload them as a dataset, and then import from that path. It’s clunky and hard to maintain. Change one line of code? You need to re-upload the dataset again.

I saw this frustration all over the web, in Kaggle forums, Stack Overflow threads, even Reddit. Everyone was hacking their way around it, trying things like chained kernels or huge notebooks with thousands of lines of code. Nobody seemed happy with it.

So I went down the rabbit hole. Read a bunch of Medium posts, watched YouTube tutorials, skimmed corporate engineering blogs. I noticed a pattern: real ML pipelines in the wild are automated. They have CI/CD. They deploy cleanly. But for personal Kaggle projects, nobody had built something simple and usable.

That’s when it clicked, I could write a small tool that did the boring part for me. A script that could take my local project, package it up neatly, and push it to Kaggle as a dataset and a runnable notebook automatically.

Building kaggle-auto-deploy

The idea was simple:

  • Collect all project files.
  • Upload them as a Kaggle dataset.
  • Auto-generate a notebook that sets everything up and runs main.py.
  • Push it, version it, done.

So that’s what I built. A small CLI tool:

python kaggle_deploy.py ./my_project

For example, I used it on a small housing price predictor I’d built.

Behind the scenes, it ties into Git too, so every deployment matches a commit. No more “which version did I upload again?” moments.

Repository: https://github.com/quochung-cyou/kaggle-auto-deploy

Sample: https://www.kaggle.com/datasets/quochungcyou/housing-price-predictor-files https://www.kaggle.com/code/quochungcyou/multi-local-module-project-python-run-sample

Overview

This solution automatically converts any multi-file Python project into a Kaggle-compatible format by:

  • Analyzing your project structure and dependencies
  • Creating a Kaggle dataset containing all your project files
  • Generating a Kaggle notebook that automatically downloads and runs your project
  • Uploading everything to Kaggle via API

Guide

You can try command below to try deploy the sample housing price predictor project:

python kaggle_deploy.py ./housing_price_predictor
alt text
alt text

If you modify the code and redeploy again, you may need use Check Update option to update the dataset and notebook.

alt text

Prerequisites

  1. Install Kaggle API
pip install kaggle
  1. Configure Kaggle Credentials
    • Option A: API Token File
      • Go to https://www.kaggle.com/account
      • Click “Create New API Token”
      • Download kaggle.json
      • Place it in:
        • Linux/Mac: ~/.kaggle/kaggle.json
        • Windows: C:\Users{username}.kaggle\kaggle.json
    • Option B: Environment Variablesexport KAGGLE_USERNAME=”your-username” export KAGGLE_KEY=”your-api-key”
  2. Set Permissions (Linux/Mac)
chmod 600 ~/.kaggle/kaggle.json

Installation & Setup

  1. Download the Deployer Script
git clone https://github.com/yourusername/kaggle-auto-deploy.git
cd kaggle-auto-deploy
  1. Make it Executable (Linux/Mac)
chmod +x kaggle_deployer.py
  1. Optional: Add to PATH (Linux/Mac)
# Add to ~/.bashrc or ~/.zshrc
export PATH="$PATH:/path/to/kaggle_deployer"

Usage

Basic Usage

python kaggle_deployer.py /path/to/your/project

Sample Project

This repository includes a sample project called housing_price_predictor that demonstrates how to structure a multi-file Python project for deployment to Kaggle.

python kaggle_deploy.py ./housing_price_predictor

Project Structure

housing_price_predictor/
├── main.py          # Entry point
├── data_loader.py   # Data loading and preprocessing
├── model.py         # Model training and evaluation
├── utils/
│   └── helpers.py   # Utility functions
├── data/
│   └── housing.csv  # Sample data
└── requirements.txt # Dependencies

Running the Sample Project

# Run locally
cd housing_price_predictor
python main.py

# Deploy to Kaggle
python kaggle_deployer.py ./housing_price_predictor

[SWE học A.I] Tìm hiểu về Attention và Transformer

This entry is part 8 of 8 in the series SWE Học A.I

Word Embedding

Để xây dựng các hệ thống có thể hỗ trợ dịch thuậtsinh văn bản, trước hết chúng ta phải biến đổi văn bản thành một dạng mà máy tính có thể xử lý được. Như thường lệ, mọi thứ cần được chuyển thành con số. Có hai cách phổ biến để làm điều này:


1. Dựa trên ký tự (Character-based)

Trong cách tiếp cận này, ta đánh số tất cả các ký hiệu có thể xuất hiện trong văn bản.

  • Bảng mã ký tự toàn diện nhất hiện nay là Unicode.
  • Phiên bản mới nhất, Unicode 13.0.0 (2020), bao phủ 154 ngôn ngữ viết của con người và liệt kê tới 143.859 ký tự khác nhau (Unicode Consortium, 2020).
  • Mỗi ký hiệu trong bất kỳ hệ thống chữ viết nào đều có thể được gán một số duy nhất, từ 0 đến xấp xỉ 144.000.

Ví dụ, trong chương này, để đơn giản, ta chỉ xét 89 ký tự phổ biến nhất trong tiếng Anh (chữ cái, số, dấu câu, khoảng trắng).

Ví dụ nhỏ:

  • Văn bản: "AI is cool!"
  • Với bảng ký tự chỉ gồm 89 ký hiệu, ta có thể ánh xạ:
    • "A" → 0, "I" → 8, " " (dấu cách) → 26, "s" → 45, "c" → 12, "o" → 22, "l" → 19, "!" → 75
  • Câu "AI is cool!" sẽ được biểu diễn thành một dãy số: [0, 8, 26, 8, 45, 26, 12, 22, 22, 19, 75].

2. Dựa trên từ (Word-based)

Trong cách này, ta đánh số tất cả các từ có thể xuất hiện.

  • Nếu tính tất cả ngôn ngữ trên thế giới, đây là một nhiệm vụ khổng lồ.
  • Ngay cả với tiếng Anh, không có con số chính xác về tổng số từ.
  • Các từ điển hiện đại của tiếng Anh thường có khoảng 300.000 mục từ (Dictionary.com, 2020).

Giả sử ta duyệt qua toàn bộ từ điển và gán cho mỗi từ một số duy nhất, bắt đầu từ 0. Danh sách các từ cùng số hiệu của chúng sẽ tạo thành từ vựng (vocabulary).

Ví dụ nhỏ:

  • Một từ điển mini gồm:
    • {"I": 0, "like": 1, "AI": 2, "because": 3, "it": 4, "is": 5, "fun": 6}
  • Câu "I like AI" sẽ được biểu diễn thành [0, 1, 2].
  • Câu "AI is fun" sẽ là [2, 5, 6].

Với mong muốn cải tiến cách biểu diễn từ thay vì chỉ gán mỗi từ một con số, với mục tiêu là có thể:

  • So sánh từ (tìm từ có ý nghĩa gần giống nhau).
  • Kết hợp từ (trung hòa hoặc biến đổi ý nghĩa giữa hai từ).
  • Thao tác trên không gian vector để tìm ra mối quan hệ ngữ nghĩa tiềm ẩn.

Đây chính là nền tảng để phát triển attention và sau đó là transformer.

Kỹ thuật này gọi là word embedding (hoặc token embedding, khi áp dụng cho token nói chung).

Ví dụ

Giả sử Hôm nay đạo diễn muốn quay cảnh một nhóm động vật đuổi theo nhân vật chính. Văn phòng gửi cho đoàn làm phim một biểu đồ như sau, trục ngang là tốc độ tối đa, trục dọc là cân nặng trung bình của từng loài

image 26 - quochung.cyou PTIT

Nhưng bản in bị lỗi và khi được đưa tới đoàn làm phim thì không có nhãn trục, chỉ còn các điểm động vật nằm trong không gian 2D. Vì vậy đoàn làm phim không biết trục ngang và trục dọc đang biểu diễn cho việc gì.

Đạo diễn nói:

“Tôi muốn trong cảnh quay sẽ là một con ngựa đuổi theo nhân vật chính. Tôi cảm giác được ngựa sẽ phù hợp. Chúng là sự lựa chọn hoàn hảo, đừng mang gì khác tới đây hết.”

image 27 - quochung.cyou PTIT


Đoàn làm phim gật đầu, đưa ngựa vào trường quay. Nhưng chỉ vài phút sau, đạo diễn đã đập tay xuống ghế, quát lớn:

“Không được! Ngựa chạy thì nhanh đấy, nhưng trông chúng quá nhạy, cứ bồn chồn như… cáo ấy. Tôi không muốn thứ gì giống cáo. Hãy mang tới cho tôi một loài vẫn mạnh mẽ như ngựa, nhưng phải bớt cái chất ‘cáo’ đi.”

Đoàn làm phim đứng sững. Trời ạ, “ngựa trừ đi cáo” thì là cái gì? Nhưng rồi anh nhớ tới tấm biểu đồ mà văn phòng gửi. Nếu coi mỗi con vật là một mũi tên trong không gian này, thì việc đạo diễn yêu cầu chẳng khác nào một phép toán vector.

Anh lấy mũi tên của ngựa, trừ đi mũi tên của cáo. Kết quả? Một con… lười khổng lồ (giant sloth).

image 28 - quochung.cyou PTIT

Đoàn làm phim ngập ngừng:

“Thưa đạo diễn, nếu bỏ đi cái tính nhanh nhẹn của cáo khỏi ngựa, chúng ta sẽ có… lười khổng lồ.”

Đạo diễn nhìn rồi hất ly latte xuống đất:

“Không! Lười thì đẹp đấy, nhưng chậm như rùa! Tôi cần thứ gì đó nhanh hơn, phải thật nhanh! Giống như… chim chẳng hạn.”

Anh lại quay vào bảng, lần này cộng thêm vector chim vào kết quả vừa rồi. Và thật bất ngờ, mũi tên mới chỉ thẳng tới… gấu nâu.

image 29 - quochung.cyou PTIT

Đạo diễn khoanh tay, hất tóc, ánh mắt sáng lên:

“Tuyệt. Cuối cùng cũng đúng ý tôi: mạnh mẽ như ngựa, không lắt nhắt như cáo, lại nhanh như chim chạy. Mang gấu tới đây đi.”

Và thế là cảnh rượt đuổi được quay với một đàn gấu nâu hung hãn. Bộ phim sau này trở thành bom tấn, được ca ngợi hết lời.

Kết luận

Có hai yếu tố then chốt trong câu chuyện này. Yếu tố thứ nhất là các loài vật trên biểu đồ của chúng ta đã được sắp xếp theo một cách hữu ích, dù chúng ta không biết cách đó là gì, hay các trục thể hiện điều gì về dữ liệu.

Điểm then chốt thứ hai là cuối cùng chúng ta không cần nhãn trục. Chúng ta có thể định hướng trên biểu đồ chỉ bằng cách cộng và trừ các mũi tên trỏ tới các phần tử trên biểu đồ. Tức là, chúng ta không cố tìm một “con ngựa chậm hơn.” Thay vào đó, ta thao tác trực tiếp với các con vật trên biểu đồ, và các thuộc tính của chúng đi theo một cách ngầm định. Nếu loại bỏ đi tính nhanh nhẹn của cáo khỏi một con vật lớn như ngựa, ta sẽ thu được một con vật to và chậm.

Điều này liên quan gì đến xử lý ngôn ngữ?

Nhúng từ (Embedding Words)

Để áp dụng những gì vừa thấy cho từ ngữ, ta thay các con vật bằng các từ. Và thay vì chỉ dùng hai trục, ta sẽ đặt các từ vào một không gian có hàng trăm chiều.

Chúng ta thực hiện điều này bằng một thuật toán tự động tìm ra ý nghĩa của mỗi trục trong không gian khi đặt từng từ vào vị trí phù hợp. Thay vì gán cho mỗi từ một số đơn lẻ, thuật toán gán cho mỗi từ cả một danh sách số đó là tọa độ của từ trong một không gian lớn.

Ví dụ: từ “Hưng” biểu diễn là [0, 0.5, 1, 50, 4, …]

image 30 - quochung.cyou PTIT

Thuật toán này gọi là embedder, và quá trình đó gọi là nhúng từ vào không gian embedding, tạo ra các word embeddings.

Bộ nhúng tự tìm cách xây dựng không gian và xác định tọa độ của mỗi từ sao cho nó nằm gần những từ tương tự. Ví dụ, nếu nó thấy nhiều câu bắt đầu bằng “I just drank some …”, thì danh từ xuất hiện sau cụm đó sẽ được hiểu là một loại đồ uống và được đặt gần các từ chỉ đồ uống khác. Nếu nó thấy nhiều câu như “I just ate a red …”, thì từ tiếp theo được hiểu là thứ có màu đỏ và có thể ăn được, nên được đặt gần những thứ vừa đỏ vừa có thể ăn được.

Tương tự với hàng chục, thậm chí hàng trăm mối quan hệ khác, cả rõ ràng lẫn tinh tế. Bởi vì không gian có rất nhiều chiều và các trục có thể mang ý nghĩa phức tạp tùy ý, một từ có thể thuộc đồng thời nhiều cụm dựa trên những đặc tính dường như không liên quan.

Điểm hay khi huấn luyện một bộ nhúng trong không gian có hàng trăm (hoặc thậm chí hàng nghìn) chiều là nó có thể sử dụng không gian hiệu quả hơn khả năng trực giác của con người, cho phép biểu diễn đồng thời một số lượng lớn các mối quan hệ.

Những phép “số học từ” vừa nêu là minh chứng thú vị cho không gian nhúng, nhưng quan trọng hơn, chúng cho phép ta thực hiện có ý nghĩa các phép toán trên từ như so sánh, phóng to/thu nhỏ, và cộng trừ tất cả đều hữu ích cho các thuật toán được bàn tới trong chương này.

image 32 - quochung.cyou PTIT

Hình trên trình bày sáu bộ gồm bốn từ liên quan mà chúng ta đưa vào một bộ nhúng tiêu chuẩn. Mức độ tương đồng giữa embedding của hai từ càng lớn thì cặp từ đó càng có điểm cao, nên giao điểm tương ứng càng tối. Đồ thị đối xứng quanh đường chéo từ trên trái xuống dưới phải, vì thứ tự so sánh hai từ không ảnh hưởng kết quả.

Từ hình ta thấy mỗi từ khớp với chính nó mạnh nhất và cũng khớp với những từ liên quan hơn là với những từ không liên quan. Vì ta đặt các từ có liên quan cạnh nhau, đồ thị hiện ra những khối nhỏ thể hiện tương đồng. Tuy nhiên có vài điều thú vị: ví dụ vì sao fish lại khớp hơn mức trung bình với chocolatecoffee, hay vì sao blue lại ghi điểm tốt với caramel? Đây có thể là những hiện tượng phát sinh từ dữ liệu huấn luyện cụ thể mà bộ nhúng dùng.

Các thức uống cà phê và hương vị ghi điểm mạnh với nhau, có lẽ vì người ta thường gọi cà phê kèm các siro hương vị đó. Ngoài ra còn thấy manh mối về mối quan hệ giữa màu sắc và hương vị.

ELMo

Word embeddings là một bước tiến lớn so với việc gán số nguyên cho từ. Nhưng dù mạnh, cách tạo embedding như trên vẫn có một vấn đề: sắc thái ngữ nghĩa (nuance).

Nhiều ngôn ngữ có từ có nhiều nghĩa nhưng viết và phát âm giống nhau. Nếu muốn hiểu đúng nghĩa từ, ta cần phân biệt các nghĩa đó. Một cách là gán cho mỗi nghĩa của một từ một embedding riêng. Ví dụ cupcake chỉ có một nghĩa nên có một embedding; nhưng train có hai nghĩa thực sự khác nhau — một là danh từ (“I rode on a train”) (train là tàu), một là động từ (“I like to train dogs”) (train là huấn luyện), nên cần hai embedding riêng.

Những từ như vậy đặt ra hai thách thức. Thứ nhất, ta phải tạo embedding riêng cho từng nghĩa. Thứ hai, ta phải chọn embedding đúng khi những từ này xuất hiện làm input.

Việc giải quyết hai thách thức này đòi hỏi phải xét ngữ cảnh của mỗi từ. Thuật toán đầu tiên làm việc này ở quy mô lớn gọi là Embeddings from Language Models, hay tên thân mật ELMo (Peters et al. 2018), trùng tên một nhân vật Muppet trong chương trình thiếu nhi Sesame Street. Người ta nói ELMo sinh ra các contextualized word embeddings (embedding phụ thuộc ngữ cảnh).

Attention

Ta đã thấy cách cải thiện chất lượng dịch thuật bằng việc xét đến toàn bộ các từ trong câu. Nhưng khi dịch một từ cụ thể, lại có thể thấy, không phải từ nào trong câu cũng quan trọng, thậm chí nhiều từ còn không liên quan.

Ví dụ: giả sử ta dịch câu:
“I saw a big dog eat his dinner.” – Tôi thấy một chú chó lớn đang ăn

  • Khi dịch từ dog (chó), ta gần như chẳng quan tâm gì đến từ saw. (nhìn thấy)
  • Nhưng để dịch đúng đại từ sở hữu his, ta cần kết nối nó với cụm big dog (chú chó lớn)
  • Nếu ta có thể xác định, với mỗi từ trong đầu vào, những từ nào thực sự ảnh hưởng đến việc dịch, thì ta có thể tập trung xử lý chỉ những từ đó, còn bỏ qua phần còn lại. Điều này tiết kiệm rất nhiều bộ nhớthời gian tính toán.
  • Và nếu ta làm điều này theo cách không phụ thuộc vào việc xử lý tuần tự (word-by-word), thì ta còn có thể tính toán song song.

Thuật toán thực hiện ý tưởng đó gọi là attention, hay cụ thể hơn là self-attention (Bahdanau, Cho & Bengio 2016; Sutskever, Vinyals & Le 2014; Cho et al. 2014).

Attention giúp mô hình tập trung tài nguyên vào những phần quan trọng nhất của đầu vào.

QKV – Query, Key, Value

Phiên bản attention hiện đại thường dựa trên một kỹ thuật gọi là query, key, value (QKV).

Ba từ này vốn xuất phát từ lĩnh vực cơ sở dữ liệu (database), nên khi đưa sang ngữ cảnh NLP có vẻ hơi trừu tượng. Vì vậy, ta sẽ dùng một phép ẩn dụ trực quan để giải thích, rồi sau đó kết nối lại với Q–K–V.

Ví dụ: Mua sơn

Giả sử anh cần mua sơn. Người ta chỉ cho anh mô tả:
“màu vàng nhạt pha một chút cam đậm.”

Ở thị trấn chỉ có một cửa hàng sơn duy nhất. Người bán hàng mới vào nghề, không rành từng màu. Anh và nhân viên đều hiểu rằng để ra màu mong muốn, anh phải pha trộn nhiều loại sơn có sẵn. Nhưng vấn đề là: chọn loại nào, và mỗi loại bao nhiêu?

Người bán đề nghị:

  • “Anh hãy so sánh mô tả của mình với tên trên nhãn từng hộp sơn. Hộp nào khớp nhiều thì múc nhiều, khớp ít thì múc ít. Rồi mình trộn tất cả lại.”

Ví dụ: so với mô tả “màu vàng nhạt pha một chút cam đậm.” (“light yellow with a bit of dark orange”)

  • “Sunny Yellow” (Vàng cam) → khớp nhiều, vừa có vàng và cam.
  • “Orange Crush” → khá khớp
  • “Lunch with Teal” cũng lọt vào một chút, chỉ vì trùng từ “with”.
image 33 - quochung.cyou PTIT

Trong câu chuyện này có 3 thành phần chính:

  1. Yêu cầu (request): “light yellow with a bit of dark orange.”
  2. Mô tả (description): tên trên nhãn mỗi hộp, như “Sunny Yellow”, “Mellow Blue”…
  3. Nội dung (content): bản thân màu sơn trong hộp.

Quy trình: ta so sánh yêu cầu với từng mô tả → mức độ khớp càng cao → càng lấy nhiều nội dung từ hộp đó.

Đây chính là QKV:

  • Cho một yêu cầu, so sánh nó với mô tả của từng mục, rồi trộn nội dung theo mức độ phù hợp.

Trong ngôn ngữ cơ sở dữ liệu:

  • Query (Q): yêu cầu tìm kiếm (tương ứng với “request”).
  • Key (K): khóa định danh, mô tả của từng mục (tương ứng với “description”).
  • Value (V): giá trị thật sự được lưu trữ (tương ứng với “content”).

Cơ sở dữ liệu so sánh query với key để tính điểm khớp, rồi dùng điểm đó quyết định lấy bao nhiêu phần của value.

➡️ Vậy:

  • Request ↔ Query (Q)
  • Description ↔ Key (K)
  • Content ↔ Value (V)

Self-Attention

Giả sử ta có một câu gồm 5 từ đầu vào. Mỗi từ được biểu diễn bằng một vector (embedding). Ta có ba “hộp” neural network nho nhỏ (thường chỉ là các fully connected layer 1 tầng) để biến đổi vector đó thành:

  • Q (query)
  • K (key)
  • V (value)
image 34 - quochung.cyou PTIT

Ví dụ: với từ dog (thứ ta cần dịch):

  • Một mạng (màu đỏ) biến vector “dog” thành Q.
  • Từ dinner được biến thành K (qua mạng xanh) và V (qua mạng xanh lá).

Ta so sánh Q của dog với K của dinner bằng một hàm tính điểm (scoring function, ký hiệu S trong vòng tròn). Hàm này trả về một số từ 0 → 1:

  • Khớp nhiều → điểm cao.
  • Khớp ít → điểm thấp.

Sau đó, ta lấy V của dinner, nhân với điểm này (scaling). Kết quả: giá trị dinner đóng góp nhiều hay ít vào đầu ra của dog tùy mức độ khớp.

Mở rộng ra toàn câu

Trong thực tế, không chỉ so sánh dog ↔ dinner, mà dog được so sánh với tất cả từ trong câu (kể cả chính nó).

Mỗi từ trong câu sẽ đóng góp vào kết quả cuối của dog, với trọng số do điểm khớp quyết định.

image 35 - quochung.cyou PTIT

Lưu ý:

  1. Ta chỉ có 3 mạng duy nhất (Q, K, V), và áp dụng chúng cho tất cả từ → tiết kiệm tính toán.
  2. Sau khi có điểm khớp, ta áp dụng softmax để chuẩn hóa, tránh số quá to/nhỏ, đồng thời làm nổi bật các khớp mạnh.
  3. Cuối cùng, ta cộng tất cả giá trị V đã được scale lại → ra vector mới của dog.
  4. Lặp quy trình này đồng thời cho mọi từ trong câu
image 36 - quochung.cyou PTIT

Embedding đóng vai trò gì?

Hàm scoring sẽ coi hai vector giống nhau → điểm cao. Vậy để hoạt động đúng, các từ có ý nghĩa gần nhau phải được nhúng gần nhau trong không gian embedding.

Ví dụ: trong như cách làm onehot encoding, ta gán số cho từ theo thứ tự (keep = 1003, flint = 1004). Nếu dùng số này so sánh thì keep và flint lại cực kỳ giống nhau (sai hoàn toàn).

Với embedding:

  • keep sẽ gần retain, hold, reserve.
  • Cách xa hẳn flint, dinosaur.

Nhờ embedding, scoring mới có ý nghĩa.

Q/KV Attention

Trong self-attention, Q, K, V đều sinh ra từ cùng một input.

Nhưng có biến thể khác:

  • Q đến từ một nguồn khác, còn K và V vẫn từ input gốc.

Giống như ví dụ mua sơn:

  • Query đến từ khách hàng.
  • Key & Value đến từ cửa hàng.

Đây gọi là Q/KV attention.

  • Thường dùng trong mạng encoder–decoder (ví dụ seq2seq):
    • Q từ encoder.
    • K, V từ decoder.
image 38 - quochung.cyou PTIT

Multi-Head Attention

Ý tưởng của attention là xác định những từ nào có sự tương đồng và tạo ra một phép trộn hữu ích từ chúng. Nhưng “giống nhau” có thể được định nghĩa theo rất nhiều tiêu chí khác nhau.

Ví dụ: ta có thể xem các danh từ là giống nhau, hoặc các màu sắc, hoặc các khái niệm không gian như “lên – xuống”, hoặc các khái niệm thời gian như “hôm qua – ngày mai”. Vậy lựa chọn nào là tốt nhất?

Thực ra không có một câu trả lời duy nhất. Trên thực tế, ta thường muốn so sánh các từ theo nhiều tiêu chí cùng lúc.

  • Khi viết ca từ bài hát, ta có thể muốn đánh giá cao những cặp từ có nghĩa gần giống nhau, có vần cuối giống nhau, có số âm tiết bằng nhau, và thậm chí có nhịp điệu trong trọng âm giống nhau.
  • Nhưng khi viết về thể thao, ta có thể muốn coi những cầu thủ cùng đội, cùng vị trí là “giống nhau”.

Để chấm điểm các từ theo nhiều tiêu chí, ta chỉ cần chạy nhiều mạng attention độc lập cùng lúc. Mỗi mạng như vậy gọi là một head (một “đầu chú ý”). Mỗi head được khởi tạo độc lập, và ta hy vọng rằng trong quá trình huấn luyện, mỗi head sẽ học được một cách riêng để so sánh các đầu vào, theo những tiêu chí hữu ích và khác biệt so với các head khác. Nếu muốn, ta còn có thể thêm cơ chế để ép các head tập trung vào các khía cạnh khác nhau của đầu vào. Cơ chế này gọi là multi-head attention

image 37 - quochung.cyou PTIT

Mỗi head là một mạng attention độc lập. Càng nhiều head, thì mô hình càng có khả năng tập trung vào nhiều khía cạnh khác nhau của dữ liệu đầu vào.

Hình trên minh họa một lớp multi-head attention. Như hình vẽ, ta thường kết hợp đầu ra của tất cả các head thành một danh sách, rồi cho qua một lớp fully connected duy nhất. Nhờ vậy, đầu ra của toàn bộ lớp multi-head có cùng kích thước với đầu vào, điều này cho phép ta dễ dàng xếp chồng nhiều lớp multi-head liên tiếp.

Layer Icons (Biểu tượng các lớp Attention)

  • (a) Self-attention.
  • (b) Multi-head self-attention.
  • (c) Q/KV attention.
  • (d) Multi-head Q/KV attention.
image 39 - quochung.cyou PTIT

(Multi-head attention được vẽ như một khối 3D nhỏ, gợi ý rằng nó là “một chồng” các mạng attention. Với Q/KV attention, trong biểu tượng kim cương có thêm một vạch nhỏ để chỉ đầu vào Q, còn K và V đi vào từ một phía khác.)

Transformers

Mục tiêu của ta là xây dựng một mô hình dịch máy không dựa vào RNN, mà dựa vào attention networks. Ý tưởng chủ chốt: các lớp attention sẽ học cách biến đổi đầu vào thành bản dịch, dựa trên mối quan hệ giữa các từ.

Cách tiếp cận này lần đầu xuất hiện trong bài báo nổi tiếng “Attention Is All You Need” (Vaswani et al., 2017). Tác giả gọi mô hình này là transformer (cái tên hơi “mập mờ” nhưng giờ đã thành chuẩn trong lĩnh vực). Mô hình transformer hoạt động hiệu quả đến mức đã mở ra một thế hệ mới của các mô hình ngôn ngữ, vừa huấn luyện song song dễ dàng, vừa vượt trội hơn RNN trong nhiều tác vụ khác nhau.

Transformer sử dụng thêm ba ý tưởng nữa:

Skip Connections

Ý tưởng đầu tiên gọi là residual connection hay skip connection (He et al., 2015). Nguồn cảm hứng của nó là giảm lượng công việc mà một lớp mạng sâu phải thực hiện.

Một phép so sánh:
Giả sử ta vẽ một bức chân dung bằng sơn acrylic trên một tấm vải. Sau nhiều tuần, bức tranh hoàn thành. Người mẫu xem và nói: “Tôi thích rồi, nhưng tiếc là hôm đó tôi đeo nhẫn sai, lẽ ra phải là cái nhẫn khác. Anh có thể sửa không?”

  • Cách 1: mời người mẫu quay lại, vẽ lại toàn bộ bức tranh từ đầu. Tốn thời gian và công sức khủng khiếp.
  • Cách 2: chỉ cần vẽ chồng cái nhẫn mới lên cái cũ. Nhanh gọn hơn nhiều.

Một lớp trong mạng sâu cũng vậy. Nó nhận tensor đầu vào, xử lý, và tạo ra tensor đầu ra. Nhưng nếu chỉ cần thay đổi một phần nhỏ, thì thật lãng phí nếu lớp đó phải xử lý toàn bộ. Giống như việc vẽ lại cả bức tranh chỉ để thay cái nhẫn. Thay vào đó, lớp chỉ cần tính toán phần thay đổi cần thiết, rồi cộng nó với đầu vào ban đầu để ra đầu ra cuối cùng.

Ý tưởng này hoạt động tuyệt vời trong deep learning:

  • giúp các lớp nhỏ gọn hơn, nhanh hơn,
  • cải thiện truyền gradient trong backpropagation,
  • cho phép huấn luyện hiệu quả các mạng có hàng chục hoặc hàng trăm lớp.
image 40 - quochung.cyou PTIT

Đường nối thêm đưa input đến nút cộng gọi là skip connection (nối tắt), hay residual connection (nối tàn dư, vì nó cộng thêm “phần dư”).

Transformer dùng skip connections không chỉ để tăng hiệu quả, mà còn để theo dõi vị trí của từng phần tử trong đầu vào (sẽ thấy ở phần positional encoding).

Norm-Add

Ý tưởng thứ hai là một rút gọn ký hiệu, thường gọi là norm-add.

Trong transformer, ta thường áp dụng bước chuẩn hóa gọi là layer normalization (layer norm) lên đầu ra của một lớp (Vaswani et al., 2017). Đây là một dạng regularization giống như dropout hay batchnorm, giúp chống overfitting bằng cách giữ cho các giá trị trong mạng không quá lớn hoặc quá nhỏ. Layer norm học cách điều chỉnh giá trị đầu ra của lớp để có dạng gần giống một phân phối chuẩn (mean = 0, std = 1).

Một cách thông dụng: đặt layer norm ngay trước bước cộng của skip connection Vì hai bước này luôn đi kèm nhau, nên người ta gộp chúng thành một khái niệm gọi là norm-add. Biểu tượng norm-add là sự kết hợp của biểu tượng chuẩn hóa và cộng.

image 41 - quochung.cyou PTIT

Có nhiều biến thể khác (đặt norm trước lớp, hoặc sau bước cộng), nhưng nhìn chung hiệu quả tương đương.

Positional Encoding (Mã hóa vị trí)

Ý tưởng thứ ba giải quyết một vấn đề phát sinh khi ta bỏ RNN ra khỏi hệ thống: ta mất thông tin về vị trí của từng từ trong câu.

Trong RNN, thông tin này có sẵn, vì từ đi vào theo thứ tự và trạng thái ẩn có thể ghi nhớ vị trí. Nhưng với attention, nhiều từ bị trộn lẫn vào nhau, vậy làm sao biết từ nào ở chỗ nào?

Giải pháp: thêm thông tin vị trí của mỗi từ trực tiếp vào vector biểu diễn của từ đó. Cách làm này gọi là positional encoding.

  • Cách đơn giản: nối thêm vài bit vào cuối mỗi từ để lưu vị trí. Nhưng cách này tốn bộ nhớ và khó xử lý khi câu quá dài (ví dụ câu càng dài hơn thì số bit cần để lưu vị trí càng tốn). (ảnh trái)
image 42 - quochung.cyou PTIT
  • Cách tốt hơn: dùng một hàm toán học để biến chỉ số vị trí thành một vector đặc trưng (cùng chiều với embedding). Ví dụ: nếu embedding dài 128, thì mỗi vị trí được ánh xạ thành một vector 128 chiều riêng biệt. (ảnh phải)

Thay vì nối thêm vector này, ta cộng trực tiếp nó vào embedding của từ Cách này gọn gàng hơn, không cần thêm bits đặc biệt. Phương pháp này gọi là positional embedding, vì nó giống embedding từ vựng. Biểu tượng minh họa có dạng sóng sin, vì hàm thường dùng chính là sine wave encoding (Vaswani et al., 2017).

Nghe có vẻ lạ: cộng vị trí vào embedding sẽ thay đổi vector của từ. Vậy liệu thông tin vị trí có bị “hòa tan” mất trong quá trình attention không?

Hóa ra là không:

  • Hàm positional embedding chỉ tác động lên một vài bit nhất định (Vaswani et al., 2017; Kazemnejad, 2019).
  • Transformer học được cách phân biệt thông tin nội dung và thông tin vị trí.

Quan trọng hơn: nhờ có skip connections, positional embedding không bao giờ bị mất hoàn toàn. Sau mỗi tầng xử lý, nó lại được cộng thêm vào, luôn đi cùng với từ.

image 43 - quochung.cyou PTIT

Ghép các mảnh ghép

Giờ thì ta đã có đủ nguyên liệu để dựng nên một transformer. Và để tiện theo dõi, ta vẫn sẽ dùng bài toán dịch từ ngữ ở mức độ từng từ làm ví dụ minh họa xuyên suốt.

Điều cần lưu ý trước tiên: cái tên transformer ngày nay được dùng cho một họ mạng nơ-ron rất rộng, tất cả đều lấy cảm hứng từ kiến trúc trong bài báo gốc Attention Is All You Need (Vaswani et al. 2017). Ở đây, ta sẽ không đi vào từng biến thể, mà chỉ bám vào một “phiên bản chung”, một bộ khung chuẩn.

Cấu trúc tổng thể

image 44 - quochung.cyou PTIT
  • Ở bên trái là encoder (mã hóa),
  • ở giữa có một cây cầu dữ liệu,
  • và bên phải là decoder (giải mã).

Một câu đầu vào sẽ đi qua encoder, được xử lý và “chưng cất” thành thông tin. Rồi decoder nhận dòng thông tin đó, dần dần tạo ra câu dịch mới.

Nếu bạn đã quen với seq2seq dùng RNN thì bố cục này trông có chút quen: có phần encoder, phần decoder, và thông tin chảy từ trái sang phải. Nhưng điểm khác biệt then chốt: không còn bất kỳ (recurrent cell) nào nữa. Tất cả được thay bằng các lớp attention.

Trong sơ đồ:

  • E tượng trưng cho một encoder block,
  • D là một decoder block,
  • các nét đứt cho thấy nhiều block giống hệt nhau được lặp lại.

Ở đầu vào, cả encoder và decoder đều bắt đầu bằng word embedding (biểu diễn từ thành vector) cộng thêm positional embedding (mã hóa vị trí từ). Ở đầu ra của decoder, ta có một lớp fully connected + softmax để dự đoán từ kế tiếp.

Decoder lại còn có một vòng lặp: mỗi từ mà nó sinh ra sẽ được đưa trở lại vào chính nó, để sinh tiếp từ sau. Đó là cơ chế autoregressive.

Encoder block

image 45 - quochung.cyou PTIT
  1. Lớp đầu tiên: multi-head self-attention (ví dụ 8 head). Vì đây là self-attention, nên queries, keys, values đều được rút ra từ chính input của block.
    • Lớp này được bao quanh bởi skip connection + layer norm (norm-add), vừa để giữ số liệu “gọn gàng” (phân phối Gaussian đẹp), vừa để bảo toàn thông tin vị trí.
  2. Lớp kế tiếp: một cặp feed-forward pointwise (thường hiểu như 2 convolution 1×1).
    • Lớp đầu có activation ReLU, lớp sau thì không.
    • Chức năng: tinh chỉnh đầu ra từ attention, loại bỏ dư thừa, giữ lại phần cốt lõi.
    • Như thường lệ, cả cụm này cũng được gói trong skip connection + norm-add.

Vậy encoder block = attention → feed-forward → norm-add bao quanh.

Decoder block

Tiếp theo là decoder block. Bố cục hơi giống encoder block, nhưng có thêm một bước attention đặc biệt.

image 46 - quochung.cyou PTIT
  1. Bước đầu tiên: multi-head self-attention (giống encoder).
    • Input chính là các từ mà decoder đã sinh ra cho đến nay. Nếu mới bắt đầu, thì chỉ có token [START].
    • Nhờ self-attention, hệ thống học cách xem từ nào liên quan từ nào trong dãy đầu ra tạm thời này.
    • Vẫn có skip connection + norm-add bao quanh.
    • Nhưng ở đây có thêm một chi tiết: masking (hình tam giác nhỏ trong sơ đồ).
  2. Bước thứ hai: multi-head Q/KV attention.
    • Query (Q) đến từ output của self-attention trước đó.
    • Keys và Values đến từ tất cả các encoder block (nguyên câu nguồn đã được mã hóa).
    • Nhờ thế, decoder biết cách “nhìn sang” câu gốc để chọn thông tin nào cần dùng khi dịch tiếp.
    • Bước này cũng được bọc skip connection + norm-add.
  3. Cuối cùng: một cặp convolution 1×1 (giống encoder block).

Ghép lại thành Transformer

Khi ráp mọi mảnh, ta có sơ đồ:

  • Nhiều encoder block chồng nhau bên trái.
  • Nhiều decoder block chồng nhau bên phải.
  • Các lớp convolution 1×1 thường được ký hiệu bằng 2 hộp nhỏ xếp đôi.
  • Nét đứt = lặp lại nhiều lần.
image 47 - quochung.cyou PTIT

Vấn đề song song & Masking

Một ưu điểm tuyệt vời của attention là: xử lý song song. Dù ta đưa vào 5 từ hay 500 từ, lớp attention vẫn chạy trong cùng một thời gian. Điều này khác hẳn RNN, vốn phải xử lý tuần tự từng bước.

Nhưng khi huấn luyện mô hình để dự đoán từ kế tiếp, sẽ nảy sinh một vấn đề.
Ví dụ câu: “My dog loves taking long walks.”

  • Nếu ta đưa vào “My dog loves taking long” và muốn dự đoán từ thứ 6 (walks), thì ổn.
  • Nhưng đồng thời, ta cũng muốn mô hình dự đoán từ thứ 5 (long), dự đoán từ thứ 4 (taking), … tất cả song song.
  • Vấn đề: khi dự đoán long, mô hình thấy luôn từ long trong input. Quá dễ, và không còn ý nghĩa học.

Cách giải: masking.
Trong self-attention của decoder, ta chèn một lớp mặt nạ, để tại mỗi bước dự đoán, mô hình chỉ được nhìn thấy các từ trước đó, không được nhìn “tương lai”.

  • Khi dự đoán từ thứ 1 → không thấy gì.
  • Khi dự đoán từ thứ 2 → chỉ thấy “My”.
  • Khi dự đoán từ thứ 3 → chỉ thấy “My dog”.
  • … và cứ thế.

Nhờ masking, mô hình vừa chạy song song và không bị “nhìn thấy” dữ liệu. Ta cố tình ẩn bớt dữ liệu đi để mô hình có thể học thêm.

Tham khảo:

  • Deep Learning (Andrew Glassner)

[SWE học A.I] Phần 8: Model Training & Evaluation

This entry is part 7 of 8 in the series SWE Học A.I

Huấn luyện (Train)

Khi huấn luyện một bộ phân loại bằng học có giám sát (supervised learning), mỗi mẫu dữ liệu đều có một nhãn (label) được gán thủ công, mô tả lớp mà mẫu đó thuộc về. Tập hợp tất cả các mẫu dữ liệu dùng để học, cùng với nhãn của chúng, được gọi là tập huấn luyện (training set).

Chúng ta sẽ lần lượt trình bày từng mẫu trong tập huấn luyện cho bộ phân loại. Với mỗi mẫu, hệ thống nhận các đặc trưng (features) của mẫu và dự đoán lớp của nó.

Nếu dự đoán đúng (tức là khớp với nhãn đã gán), chúng ta chuyển sang mẫu tiếp theo. Nếu dự đoán sai, chúng ta cung cấp đầu ra của bộ phân loại và nhãn đúng trở lại cho nó.

image 14 - quochung.cyou PTIT

Như ảnh trên, ta có thể thấy, trong quá trình huấn luyện, chúng ta sẽ cần điều chỉnh các tham số (parameter) của bộ phân loại để tăng khả năng dự đoán đúng nhãn. Điều này dẫn đến một bài toán tối ưu hóa (optimization problem), nơi mục tiêu là giảm thiểu sai số hoặc tối đa hóa xác suất xảy ra của dữ liệu. Một kỹ thuật phổ biến để giải bài toán này là phương pháp giảm gradient (gradient descent).

Gradient descent

Ta có một hàm số [latex]f[/latex] nhận đầu vào là một vector các số thực và trả về một số thực duy nhất. Một ví dụ đơn giản là hàm tính tổng bình phương các phần tử trong vector:

Python
from scratch.linear_algebra import Vector, dot

def sum_of_squares(v: Vector) -> float:
    """Tính tổng bình phương các phần tử trong vector v"""
    return dot(v, v)

Mục tiêu là tìm vector [latex]v[/latex] sao cho hàm [latex]f(v)[/latex] đạt giá trị lớn nhất (tối đa hóa) hoặc nhỏ nhất (tối thiểu hóa). Gradient (vector của các đạo hàm riêng [latex]\nabla f[/latex]) cho biết hướng làm hàm số tăng nhanh nhất. Ý tưởng của phương pháp giảm gradient là:

  1. Chọn một điểm xuất phát ngẫu nhiên.
  2. Tính gradient tại điểm đó.
  3. Di chuyển một bước nhỏ theo hướng gradient (để tối đa hóa) hoặc ngược hướng (để tối thiểu hóa).
  4. Lặp lại quá trình với điểm mới.
image 15 - quochung.cyou PTIT

Trong hình trên thể hiện một hàm hai biến [latex]f(x, y) = x^2 + y^2[/latex], có dạng hình paraboloid lồi hướng lên, với điểm thấp nhất nằm tại gốc tọa độ [latex](0, 0, 0)[/latex].

Các mũi tên tam giác đỏ thể hiện các bước di chuyển của thuật toán. Tại mỗi bước, gradient được tính và điểm hiện tại được cập nhật theo hướng:

[latex]v_{\text{new}} = v_{\text{old}} – \eta \nabla f(v_{\text{old}})[/latex]

trong đó [latex]\eta[/latex] là tốc độ học (learning rate).

Ước lượng gradient

Nếu hàm [latex]f[/latex] chỉ có một biến, đạo hàm tại điểm [latex]x[/latex] đo lường sự thay đổi của [latex]f(x)[/latex] khi [latex]x[/latex] thay đổi một lượng rất nhỏ.

[latex]
\frac{f(x + h) – f(x)}{h}
[/latex]

Đây gọi là thương số sai phân.

  • [latex]h[/latex]: là một bước nhỏ (ví dụ: 0.001).
  • [latex]f(x+h)[/latex]: là giá trị hàm khi đi thêm một chút từ [latex]x[/latex].
  • [latex]f(x+h) – f(x)[/latex]: là phần thay đổi.
  • Chia cho [latex]h[/latex] để biết “mỗi bước nhỏ thay đổi bao nhiêu” → chính là độ dốc (gradient)
image 16 - quochung.cyou PTIT

Đường cong màu xám là đồ thị của hàm [latex]f(x)[/latex].

Hai điểm được đánh dấu:

  • [latex](x, f(x))[/latex] – điểm gốc.
  • [latex](x + h, f(x + h))[/latex] – điểm gần đó.

Tam giác màu xanh dương thể hiện:

  • Đáy tam giác là [latex]h[/latex]
  • Chiều cao là [latex]f(x+h) – f(x)[/latex]
  • Độ dốc của đoạn thẳng là:

[latex]
\frac{f(x+h) – f(x)}{h}
[/latex]

→ chính là ước lượng đạo hàm tại [latex]x[/latex].

Khi hàm [latex]f[/latex] có nhiều biến, ta tính đạo hàm riêng (partial derivative) cho từng biến, giữ các biến khác cố định:

Python
def partial_difference_quotient(f: Callable[[Vector], float], v: Vector, i: int, h: float) -> float:
    """Tính thương số sai phân riêng thứ i của hàm f tại vector v"""
    w = [v_j + (h if j == i else 0) for j, v_j in enumerate(v)]
    return (f(w) - f(v)) / h

def estimate_gradient(f: Callable[[Vector], float], v: Vector, h: float = 0.0001):
    return [partial_difference_quotient(f, v, i, h) for i in range(len(v))]

Lưu ý: Việc ước lượng gradient bằng thương số sai phân tốn nhiều tài nguyên tính toán, đặc biệt với vector có kích thước lớn. Trong thực tế, người ta thường tính gradient trực tiếp bằng toán học để tối ưu hiệu suất.

Sử dụng Gradient để Tối Ưu Hóa Hàm Số

Rõ ràng rằng hàm tổng bình phương (sum of squares) đạt giá trị nhỏ nhất khi đầu vào là một vector toàn số không. Tuy nhiên, giả sử chúng ta chưa biết điều này, chúng ta có thể sử dụng gradient để tìm giá trị tối thiểu trong không gian các vector ba chiều. Bắt đầu từ một điểm ngẫu nhiên, ta thực hiện các bước nhỏ theo hướng ngược với gradient cho đến khi gradient đạt giá trị rất nhỏ.

Python
from scratch.linear_algebra import distance, add, scalar_multiply

def gradient_step(v: Vector, gradient: Vector, step_size: float) -> Vector:
    """Di chuyển một khoảng `step_size` theo hướng `gradient` từ điểm `v`"""
    assert len(v) == len(gradient)
    step = scalar_multiply(step_size, gradient)
    return add(v, step)

def sum_of_squares_gradient(v: Vector) -> Vector:
    """Tính gradient của hàm tổng bình phương"""
    return [2 * v_i for v_i in v]

# Chọn điểm bắt đầu ngẫu nhiên
v = [random.uniform(-10, 10) for i in range(3)]

for epoch in range(1000):
    grad = sum_of_squares_gradient(v)    # Tính gradient tại v
    v = gradient_step(v, grad, -0.01)    # Bước ngược hướng gradient
    print(epoch, v)

assert distance(v, [0, 0, 0]) < 0.001    # v gần với [0, 0, 0]

Nếu thực thi đoạn code trên, vector v sẽ tiến gần đến [0, 0, 0]. Số lượng epoch càng lớn, kết quả càng chính xác.

Kiểm Thử (Test)

Chúng ta bắt đầu với một hệ thống có các tham số được khởi tạo ngẫu nhiên. Sau đó, chúng ta huấn luyện nó bằng dữ liệu trong tập huấn luyện. Khi hệ thống được triển khai ra thế giới thực, nó sẽ đối mặt với dữ liệu thực tế (deployment data, release data, hoặc user data).

Chúng ta muốn biết hệ thống sẽ hoạt động tốt như thế nào trên dữ liệu thực tế trước khi triển khai. Không cần độ chính xác hoàn hảo, nhưng thường chúng ta mong hệ thống đạt hoặc vượt một ngưỡng chất lượng nhất định. Làm sao để ước lượng chất lượng dự đoán của hệ thống trước khi triển khai?

Hệ thống cần hoạt động tốt trên tập huấn luyện, nhưng nếu chỉ đánh giá độ chính xác dựa trên dữ liệu này, chúng ta thường bị đánh lừa.

Giả sử chúng ta dùng bộ phân loại có giám sát để xử lý ảnh chó. Với mỗi ảnh, hệ thống sẽ gán nhãn xác định giống chó. Mục tiêu là triển khai hệ thống trực tuyến để người dùng có thể kéo ảnh chó của họ vào trình duyệt và nhận về giống chó hoặc nhãn “giống hỗn hợp (mixed breed)”.

Để huấn luyện, chúng ta thu thập 1.000 ảnh chó thuần chủng, mỗi ảnh được chuyên gia gắn nhãn. Chúng ta cho hệ thống xem cả 1.000 ảnh, lặp đi lặp lại qua nhiều epoch (lần lặp), thường xáo trộn thứ tự ảnh mỗi lần lặp để tránh trình tự lặp lại. Nếu hệ thống được thiết kế tốt, nó sẽ dần đạt kết quả chính xác hơn, ví dụ đạt 99% trong việc xác định giống chó trên tập huấn luyện.

Tuy nhiên, điều này không có nghĩa hệ thống sẽ đạt 99% chính xác khi triển khai trực tuyến. Vấn đề là hệ thống có thể đã khai thác các mối quan hệ đặc biệt trong tập huấn luyện, nhưng không đúng với dữ liệu nói chung.

image 17 - quochung.cyou PTIT

Ví dụ, giả sử các ảnh chó Poodle trong tập huấn luyện đều có một cục bông ở đuôi, trong khi các giống khác thì không. Hệ thống nhận ra điều này và chỉ cần tìm cục bông để phân loại Poodle, thay vì xem xét các đặc trưng như kích thước chân, hình dạng mũi, v.v. Quy tắc này giúp phân loại đúng 100% ảnh Poodle trong tập huấn luyện, nhưng không phải cách chúng ta mong muốn. Hệ thống được cho là đã “học cách gian lận” (cheating)

image 18 - quochung.cyou PTIT

Một ví dụ khác: Giả sử tất cả ảnh chó Yorkshire Terrier (Yorkie) trong tập huấn luyện đều được chụp khi chó ngồi trên ghế sofa, và không ảnh nào của giống khác có sofa. Hệ thống có thể học rằng nếu có sofa trong ảnh, đó là Yorkie. Quy tắc này hoạt động hoàn hảo trên tập huấn luyện.

image 19 - quochung.cyou PTIT

Khi triển khai, nếu ai đó gửi ảnh một chú chó Great Dane đứng trước trang trí lễ hội với những quả bóng trắng hoặc một chú Husky nằm trên sofa, hệ thống có thể nhầm quả bóng trắng ở đuôi Great Dane là cục bông và gọi đó là Poodle, hoặc thấy sofa và gọi Husky là Yorkie.

Đây không chỉ là vấn đề lý thuyết. Một ví dụ nổi tiếng từ những năm 1960 (Muehlhauser 2011) kể về một hệ thống học máy nhận diện xe tăng trong ảnh cây cối. Hệ thống được cho là nhận diện xe tăng hoàn hảo, nhưng sau đó phát hiện rằng ảnh có xe tăng được chụp vào ngày nắng, còn ảnh không xe tăng chụp vào ngày âm u. Hệ thống chỉ phân biệt trời sáng và tối, không liên quan gì đến xe tăng.

Đây là lý do tại sao chỉ nhìn vào hiệu suất trên tập huấn luyện không đủ để dự đoán hiệu suất thực tế. Hệ thống có thể học các đặc điểm kỳ lạ (idiosyncrasies) trong tập huấn luyện và sử dụng chúng làm quy tắc, nhưng thất bại với dữ liệu mới không có những đặc điểm đó. Hiện tượng này được gọi là quá khớp (overfitting), hay thường gọi là “gian lận” (cheating)

Dữ liệu kiểm thử (Test Data)

Cách tốt nhất để xác định hiệu suất của hệ thống trên dữ liệu mới, chưa từng thấy là thử nghiệm nó trên dữ liệu kiểm thử (test data hoặc test set). Dữ liệu kiểm thử sẽ không được cho vào trong quá trình huấn luyện, vì vậy từ góc nhìn của hệ thống, chúng sẽ là các dữ liệu mới hoàn toàn.

Dữ liệu kiểm thử phải đại diện cho dữ liệu thực tế mà hệ thống sẽ gặp khi triển khai. Quy trình thông thường là huấn luyện hệ thống trên tập huấn luyện cho đến khi đạt hiệu suất tốt nhất có thể, sau đó đánh giá trên tập kiểm thử để dự đoán hiệu suất thực tế.

Nếu hiệu suất trên tập kiểm thử không đủ tốt, chúng ta cần cải thiện hệ thống, thường bằng cách thu thập thêm dữ liệu và huấn luyện lại. Thêm dữ liệu cũng giúp đa dạng hóa tập huấn luyện, ví dụ, tìm chó không phải Poodle có cục bông ở đuôi hoặc chó không phải Yorkie trên sofa, buộc hệ thống tìm cách phân loại khác để tránh quá khớp.

image 20 - quochung.cyou PTIT

Do đó, chúng ta tách dữ liệu kiểm thử khỏi tập huấn luyện ngay từ đầu và chỉ sử dụng nó một lần sau khi huấn luyện hoàn tất. Nếu hệ thống không đạt yêu cầu trên tập kiểm thử, chúng ta phải bắt đầu lại với hệ thống mới được khởi tạo ngẫu nhiên, huấn luyện với dữ liệu mới hoặc lâu hơn, rồi đánh giá lại trên tập kiểm thử.

Thông thường, chúng ta tạo tập kiểm thử bằng cách chia tập dữ liệu gốc thành hai phần: tập huấn luyện (khoảng 75%) và tập kiểm thử (25%). Việc chọn mẫu thường ngẫu nhiên, nhưng có thể dùng thuật toán phức tạp hơn để đảm bảo mỗi tập đại diện tốt cho dữ liệu gốc.

Dữ Liệu Xác Thực (Validation Data)

Trong quy trình trên, chúng ta huấn luyện hệ thống, sau đó dừng lại và đánh giá trên tập kiểm thử. Nếu hiệu suất không đủ, chúng ta bắt đầu lại. Cách này hiệu quả nhưng chậm.

Trong thực tế, chúng ta thường muốn ước lượng hiệu suất hệ thống trong quá trình huấn luyện để dừng lại khi đạt mục tiêu. Vì vậy, chúng ta chia dữ liệu gốc thành ba tập: tập huấn luyện (training set), tập xác thực (validation set), và tập kiểm thử (test set), thường theo tỷ lệ 60% – 20% – 20%

image 21 - quochung.cyou PTIT

Quy trình mới là: huấn luyện qua một epoch trên tập huấn luyện, sau đó đánh giá hiệu suất trên tập xác thực. Việc này được lặp lại sau mỗi epoch, gây rò rỉ dữ liệu, nhưng tập xác thực chỉ dùng để ước lượng không chính thức. Hiệu suất trên tập xác thực giúp chúng ta theo dõi quá trình học của hệ thống. Khi thấy hiệu suất đủ tốt, chúng ta dùng tập kiểm thử một lần để đánh giá chính xác.

Tập xác thực cũng hữu ích khi tìm kiếm siêu tham số (hyperparameters) – các biến được cài đặt trước để kiểm soát hoạt động của hệ thống, như mức độ cập nhật tham số sau lỗi hoặc độ phức tạp của bộ phân loại. Với mỗi bộ siêu tham số, chúng ta huấn luyện trên tập huấn luyện và đánh giá trên tập xác thực. Kết quả từ tập xác thực giúp quyết định khi nào dừng huấn luyện. Khi hiệu suất đạt yêu cầu, chúng ta dùng tập kiểm thử để đánh giá cuối cùng.

Quy trình này là một vòng lặp: chọn siêu tham số, huấn luyện, đánh giá trên tập xác thực, lặp lại với bộ siêu tham số mới, và cuối cùng chọn hệ thống tốt nhất để kiểm tra trên tập kiểm thử.

image 22 - quochung.cyou PTIT

Vì tập xác thực đã ảnh hưởng đến việc chọn siêu tham số. Dù bộ phân loại không học trực tiếp từ tập xác thực, dữ liệu này đã “rò rỉ” vào quá trình chọn bộ phân loại tốt nhất. Để đánh giá chính xác trên dữ liệu hoàn toàn mới, không có cách nào khác ngoài việc dùng tập kiểm thử vào cuối cùng.

Xác Thực Chéo (Cross-Validation)

Trong phần trước, chúng ta đã dành gần một nửa dữ liệu huấn luyện để làm tập xác thực và kiểm tra. Điều này không thành vấn đề khi chúng ta có lượng dữ liệu đủ lớn để chia. Nhưng nếu tập dữ liệu của chúng ta nhỏ và không thể thu thập thêm dữ liệu thì sao?

Nếu chúng ta chấp nhận một ước lượng về hiệu suất của hệ thống thay vì một phép đo đáng tin cậy, chúng ta không cần phải để dành một tập kiểm tra riêng. Thực tế, chúng ta có thể huấn luyện trên toàn bộ dữ liệu đầu vào và vẫn dự đoán được hiệu suất trên dữ liệu mới.

Kỹ thuật thực hiện công việc này được gọi là xác thực chéo (cross-validation) hoặc xác thực luân phiên (rotation validation).

Ý tưởng cốt lõi là chạy một vòng lặp lặp đi lặp lại việc huấn luyện hệ thống từ đầu và sau đó kiểm tra nó. Mỗi lần lặp, chúng ta chia toàn bộ dữ liệu đầu vào thành một tập huấn luyện tạm thời và một tập xác thực tạm thời. Điều quan trọng là các tập này được tạo khác nhau trong mỗi lần lặp. Điều này cho phép chúng ta sử dụng toàn bộ dữ liệu để huấn luyện (mặc dù không phải tất cả cùng một lúc, như sẽ thấy sau).

Chúng ta bắt đầu bằng cách xây dựng một bộ phân loại mới. Dữ liệu đầu vào được chia thành tập huấn luyện tạm thời và tập xác thực tạm thời. Chúng ta huấn luyện hệ thống trên tập huấn luyện tạm thời và đánh giá nó bằng tập kiểm tra tạm thời, từ đó thu được điểm số về hiệu suất của bộ phân loại. Sau đó, chúng ta lặp lại vòng lặp, nhưng lần này chia dữ liệu thành các tập huấn luyện và kiểm tra tạm thời khác. Khi đã hoàn thành tất cả các lần lặp, trung bình của các điểm số này là ước lượng hiệu suất tổng thể của bộ phân loại.

image 23 - quochung.cyou PTIT

Nhờ xác thực chéo, chúng ta có thể huấn luyện trên toàn bộ dữ liệu (mặc dù không phải tất cả trong mỗi lần lặp) và vẫn có được một phép đo khách quan về chất lượng hệ thống từ tập kiểm tra riêng. Thuật toán này không gặp vấn đề rò rỉ dữ liệu (data leakage) vì mỗi lần lặp, chúng ta tạo một bộ phân loại mới, và tập kiểm tra tạm thời cho bộ phân loại đó chứa dữ liệu hoàn toàn mới, chưa từng được sử dụng bởi bộ phân loại cụ thể đó, do đó việc sử dụng nó để đánh giá hiệu suất là công bằng. Tuy nhiên, nhược điểm của kỹ thuật này là ước lượng cuối cùng về độ chính xác của hệ thống không đáng tin cậy bằng khi sử dụng tập kiểm tra riêng.

Có nhiều thuật toán khác nhau để xây dựng các tập huấn luyện và xác thực tạm thời. Có thể điểm qua 1 số phương pháp phổ biến:

Xác Thực Chéo K-Fold

Phương pháp phổ biến nhất để xây dựng các tập dữ liệu tạm thời cho xác thực chéo được gọi là xác thực chéo k-fold. Ở đây, chữ “k” không phải là chữ cái đầu của một từ, mà đại diện cho một số nguyên (ví dụ, chúng ta có thể thực hiện “xác thực chéo 2-fold” hoặc “xác thực chéo 5-fold”). Thông thường, giá trị của k là số lần chúng ta muốn lặp lại vòng lặp.

Thuật toán bắt đầu trước khi vòng lặp xác thực chéo diễn ra. Chúng ta lấy dữ liệu huấn luyện và chia nó thành một loạt các nhóm có kích thước bằng nhau. Mỗi mẫu dữ liệu được đặt vào đúng một nhóm, và tất cả các nhóm có kích thước giống nhau (trừ một nhóm nhỏ hơn ở cuối nếu không thể chia đều dữ liệu).

Để hình dung, hãy tưởng tượng bạn viết tất cả các mẫu trong tập huấn luyện lên một tờ giấy dài, sau đó gấp tờ giấy đó thành một số phần bằng nhau. Mỗi lần gấp tạo ra một nếp, và phần vật liệu giữa các nếp được gọi là một fold.

image 24 - quochung.cyou PTIT

Hãy sử dụng năm fold này để xem vòng lặp diễn ra như thế nào. Lần đầu tiên qua vòng lặp, chúng ta coi các mẫu trong Fold 2 đến Fold 5 là tập huấn luyện tạm thời, và các mẫu trong Fold 1 là tập kiểm tra tạm thời. Nghĩa là, chúng ta huấn luyện bộ phân loại với các mẫu trong Fold 2 đến Fold 5, sau đó đánh giá nó với các mẫu trong Fold 1.

image 25 - quochung.cyou PTIT
image 26 - quochung.cyou PTIT

Lần tiếp theo qua vòng lặp, bắt đầu với một bộ phân loại mới được khởi tạo với các số ngẫu nhiên, chúng ta sử dụng các mẫu trong Fold 1, 3, 4, và 5 làm tập huấn luyện tạm thời, và các mẫu trong Fold 2 làm tập kiểm tra tạm thời. Chúng ta huấn luyện và kiểm tra như thường lệ với hai tập này, và tiếp tục với các fold còn lại.