Thứ Ba, tháng 1 24, 2017

Phan Hầm Mỏ - End of the road

Tết về, phố đông chật người rồi chợt bỗng vắng lặng đường khuya. Một mình bước đi trên con phố nhỏ dưới cơn mưa bụi, lặng lẽ ra ga tàu. Vừa đi, vừa miên man suy ngẫm về câu chuyện "Sống là không chờ đợi". Liệu còn gì mà tôi chưa làm hết mình, còn gì tôi chưa sống hết mình mỗi ngày qua, có gì mà tôi "đánh mất" và "nhặt được" trên chặng đường mình đi hay không? Câu chuyện ấy viết rằng "Đừng bao giờ giữ lại một cái gì để chờ dịp đặc biệt cả... Mỗi ngày sống là một dịp đặc biệt rồi..."

Cuộc đời con người, có nhiều quyết định đúng, có nhiều quyết định sai. Rồi có những cái lấp lửng giữa cả 2 nữa, mà không thể minh bạch được đúng hay sai. Ngoài ra, cũng còn theo chiều thời gian nữa. Tôi nhớ lại những cái vốn rất sai của mình bây giờ lại có điểm quá đúng.

Chợt thấy mình lẻ loi. Mưa dần rơi nặng hạt cũng chẳng buồn dương ô lên nữa. Con đường nhỏ len lỏi trong khu dân cư, không một bóng người, trời khuya không một ánh đèn cửa sổ, chỉ còn ánh sáng yếu ớt từ vài ngọn đèn đường lưa thưa và vừng sáng từ các máy bán nước uống tự động. Đôi giày sũng nước mưa từ chiều nặng bên tay xách, tôi bỏ nốt đôi dép mà mấy em đồng nghiệp đưa cho, chân không rảo bước. Nước dưới chân mát lạnh, không hề gây khó chịu mà chỉ giúp nhanh bước hơn ra sân ga.

Thông thường, ở một thành phố xa lạ, nếu cuối tuần trời mưa thì cũng có nghĩa là ông trời muốn giam bước chân người xa nhà, chỉ vào mạng, ngủ vùi đầu, xem TV hay viết blog thôi chẳng hạn... Sau rất nhiều lần thành công, lần này, ông ấy đã thất bại vì mình "sổng" từ tối thứ 6 đến Chủ Nhật mới về. Và vì thế, mưa cứ luấn quấn bước chân, dò hỏi xem "anh buồn à?", "anh muốn gì à?" 

Ừ. Thì tôi buồn, nỗi buồn của một kẻ hành khất cô đơn, kiêu ngạo cố tỏ vẻ không còn cần gì nữa, không muốn gì nữa.

Ừ. Thì tôi lại muốn rồi. Mâu thuẫn chưa? Tôi muốn có ai đó cùng đi trong mưa. Cùng sánh bước trong ướt lạnh, chân không giầy, dẫm nước, miễn là bàn tay còn ấm. Tự hỏi mình, khi như thế, có còn cần đi tiếp ra sân ga nữa không đây? Bất chợt nhận ra, trong muôn vàn hạt mưa bụi bay bay, táp vào mặt, có giọt âm ấm đọng lại.

Văng vẳng trong đầu, câu hát vu vơ, lúc mờ lúc rõ của một người bạn thân ôm đàn guitar, ngân nga đêm nào. Có những lúc, nỗi buồn khi cô đơn làm người ta vui.

Ta tưởng yêu thương là sẽ cùng nhau đi tận cuối con đường. Hóa ra không hẳn vậy, chỉ là tới cuối con đường ta mới kịp nhận ra điều đó thôi. Mà yêu thương cũng không dừng lại khi đến cuối đường đâu, như cuối con đường này sẽ là 1 con đường mới. Cuối năm này sẽ có một năm mới với bao dự định ấp ủ mới.

Gửi 3S Radio Tết con gà 2017 một bài hát rất xưa cũ nhé. End of the road của Boys2Men với lời nhắn, Chúc các bạn nghe đài một năm mới Tết đoàn viên và yêu thương, hãy để tim rộng mở và cảm nhận yêu thương trên từng giây phút và từng bước chân đi, đừng chờ đợi đến một nẻo cuối con đường đâu đó nữa ấy.

Thứ Sáu, tháng 1 20, 2017

Kidnapping - Bắt cóc?

3 năm trước, tôi là cái áo khoác cậu bé rúc vào khi lạnh, là cái ghế cậu bé ngồi lên, là doctor Know-It-All để cậu bé mang mọi câu hỏi đến.
2 năm trước, một kẻ ích kỷ đã cho mình cái quyền tước đi mối thâm tình đó.
Cậu bé dần dà bị dẫn dắt bởi "... con không đi chơi với bố, không về nhà mình sợ bố bắt cóc con..."
Quặn thắt! Nước mắt rơi trong tim!
Không thể cất nổi lời. Chỉ tâm niệm Time will tell the truth!
Hôm nay, cậu vẫn không đi với bố nhưng đã biết tự khẳng định được: "...bố chẳng bắt cóc con đâu mà..."
Lái xe đêm muộn, chợt bất giác ngân nga hát theo radio FM:
"...  Bỗng thấy yêu đời quá, yêu ngày xanh nắng vàng
Bỗng thấy yêu thời gian, hạnh phúc đến nhẹ nhàng.
Yêu sao những tiếng cười và những khi bên người.
Ngày chẳng còn lo toan mệt nhoài.
Cuộc đời vì thế nên đẹp tuyệt vời!
... Có lúc tôi gục ngã, nhìn ngày trôi hững hờ.
Có lúc tôi thầm mơ sẽ hái sao trên trời, 
Mà nào có biết rằng, hạnh phúc luôn bên mình là những điều nhỏ nhoi thường ngày mà tôi tìm mãi nơi phù du."

Một điều nhỏ nhoi ấy, cậu bé đã trao tôi, trưa nay.


Thứ Năm, tháng 1 19, 2017

ĐÔI NÉT VỚI TẬP LỆNH NCCL ĐA GPU (GRAPHIC PROCESSING UNIT: VI XỬ LÝ ĐỒ HỌA)


Ngày nay, nhiều máy chủ chứa 8 GPU hoặc nhiều hơn. Trên nguyên tắc đó, chạy một ứng dụng sử dụng một đến nhiều GPU đúng ra sẽ mang lại một hiệu suất gấp bội. Nhưng thực tế thì khá khó để tận dụng được lợi thế này. Có hai nguyên nhân cơ bản phía sau sự thiếu tính khả mở của hệ thống đa-GPU. Đầu tiên ứng không có đủ luồng xử lý song song để dùng hết các vi xử lý trên GPU. Lý do thứ hai gây khó khăn khi tận dụng mở rộng ở chỗ các bộ vi xử lý sẽ phải dành thời gian để trao đổi dữ liệu với nhau quá nhiều thay vì tập trung chạy thuật toán. Để giảm tình trạng nút cổ chai ấy, điều chính yếu cần thiết phải tạo được băng thông lớn nhất sẵn sàng cho các kết nối liên GPU. NCCL là một đáp án.

NCCL (phát âm là "Nickel") là một thư viện tập lệnh gốc đa-GPU có hướng cấu trúc liên kết và có thể dễ dàng tích hợp vào các ứng dụng. Khởi thủy từ một dự án nghiên cứu mã nguồn mở, NCCL được thiết kế để có dung lượng thật nhỏ dựa trên C ++ thông thường và các thư viện CUDANCCL có thể được ứng dụng vào trong các phần mềm đơn luồng hoặc đa luồng, để quản lý truyền nhận tin giữa các luồng xử lý hiệu quả. Sau nữa, NCCL đã bao gồm sẵn các API vốn rất quen thuộc với bất cứ ai có kinh nghiệm sử dụng các tập lệnh MPI.

Hình 1: Minh họa về tập lệnh All-Reduce.

Tập lệnh giao thức

Các chương trình giao thức tổ hợp là mẫu hình chung của truyền dữ liệu giữa nhiều bộ vi xử lý. Nếu đã có kinh nghiệm với MPI thì có lẽ cũng đã quen thuộc với một số phép toán tổ hợp lệnh. Ví dụ, All-Reduce (Giảm toàn phần) bắt đầu với mảng độc lập $latex V_k$ của N giá trị trên K bộ vi xử lý và kết thúc với mảng giống hệt S của N giá trị, trong đó $S[k] = V_0[k] + V_1[k] + ... + V_k[k]$, cho mỗi bộ xử lý k . Xem hình 1.
Một giao thức tổ hợp thông thường khác là All-Gather (Nhóm toàn phần), trong đó mỗi bộ xử lý trong tập hợp K bắt đầu với một mảng độc lập của N giá trị và thu thập dữ liệu từ tất cả các bộ xử lý khác để tạo thành một kết quả mảng N*K , như minh họa ở hình 2.

Hình 2: Minh họa về All-gather.
Broadcast (Tán xạ) là một ví dụ thứ ba. Dưới đây là một thành phần đệm gồm N phẩn tử trên một bộ xử lý được sao chép vào tất cả các bộ xử lý khác, như hình 3 cho thấy.
Hình 3: Minh họa về Broadcast
Tất cả các tổ hợp trên có thể được thực hiện ở vùng "ngoại vi", có bộ đệm vào và ra riêng biệt, hoặc "nội hàm" có chồng lấn vào và ra.
Hình 4: Giao thức PCIe chung cho 4 GPU gắn trong hệ thống có một CPU duy nhất. Mũi tên màu đỏ đại diện cho các kết nối băng thông PCIe x16.
Có rất nhiều phương pháp để thực hiện tổ hợp hiệu quả. Tuy nhiên, điểm then chốt khi thực hiện thì phải cân nhắc đến kiến trúc giao thức của liên kết giữa các bộ vi xử lý. Ví dụ như ở giao thức nhánh cây PCIe trong hình dưới đây về đường tán xạ dữ liệu từ GPU0 đến tất cả các GPU khác.
Thuật toán nhánh cây hai bước là một lựa chọn phổ biến ở đây. Tại bước đầu tiên, dữ liệu được gửi từ GPU0 tới một GPU khác, và trong bước thứ hai cả hai sẽ gửi dữ liệu đến các bộ vi xử lý còn lại. Dù thế thì vẫn có nhiều cách thực thi khác nhau. Dữ liệu có thể được gửi từ GPU0 đến GPU1 trong bước đầu tiên và sau đó từ GPU0 đến GPU2 và từ GPU1 đến GPU3 ở bước hai, hoặc có thể thực hiện dưới hình thức bản sao ban đầu từ GPU0 đến GPU2, kế đó ở bước hai thì từ GPU0 đến GPU1 và GPU2 đến GPU3. Kiểm tra cấu trúc liên kết, cách thứ hai tốt hơn rõ rệt, vì nếu gửi dữ liệu đồng thời từ GPU0 đến GPU2 và GPU1 đến GPU3 sẽ gây tranh chấp tài nguyên trên các liên kết PCIe, hiệu suất băng thông ở bước này bị giảm một nửa. Nói chung, để đạt được hiệu quả cao đối với tổ hợp lệnh đòi hỏi sự quan tâm cẩn trọng đến cấu trúc liên kết.
Để tối ưu hóa băng thông Broadcast, còn một cách tiếp cận tốt hơn là tạo liên kết cấu trúc vòng PCIe.
Tán xạ được thực hiện bằng việc sắp xếp các tập nhỏ dữ liệu đầu vào xung quanh vòng từ GPU0 để GPU3. Điều thú vị là, các thuật toán vòng cung cấp băng thông cận tối ưu cho hầu hết tất cả các thực thi tổ hợp lệnh tiêu chuẩn, thậm chí khi áp dụng cho cấu trúc PCIe kiểu "nhánh cây". Tất nhiên phải lưu ý rằng việc lựa chọn đúng chiều tuần hoàn của vòng vẫn rất quan trọng.

Hình 5: Vòng tuần hoàn của GPU trong cây PCIe.

Tập lệnh GPU với NCCL

Để cung cấp băng thông tối đa, NCCL áp dụng các tập lệnh tổ hợp kiểu vòng. NCCL ngầm đánh chỉ số GPU theo trật tự vòng tối ưu. Điều này cung cấp hiệu suất tuyệt vời cho ứng dụng mà nhà phát triển không còn phải lo lắng về cấu hình phần cứng cụ thể ra sao.
Nhiều tổ hợp lệnh đòi hỏi một bộ đệm chứa kết quả trung gian. Để giảm thiểu dung lượng chiếm bộ nhớ xuống còn một vài MB trên mỗi GPU, NCCL chia tách tổ hợp lệnh lớn thành nhiều phần nhỏ. Hiệu quả cực thấp nếu khởi tạo lõi xử lý và thực thi gọi hàm cudaMemcpy riêng biệt cho mỗi công đoạn hay từng phần nhỏ của cùng một thuật toán tổ hợp. Thay vào đó, mỗi tổ hợp được thực hiện trọn vẹn trên một nhân CUDANCCL tận dụng tối đa truy cập trực tiếp của giao thức GPUDirect Peer-to-Peer để truyền dữ liệu giữa các bộ xử lý. Trường hợp không lấy được truy cập trực tiếp peer-to-peer (ví dụ, khi phải truyền qua một kết nối QPI), thì các dữ liệu đã truyền đi được tổ chức thông qua một bộ đệm trong một vùng xác định trên bộ nhớ hệ thống. Tương tự như vậy, đồng bộ dũ liệu được thực hiện bằng cách kiểm tra các biến số biến đổi trong vùng nhớ của card (hoặc vùng nhớ xác định của hệ thống).
Bên trong, NCCL quy việc thực hiện mỗi tổ hợp lệnh về 3 nguyên hàm: Copy, Reduce, và ReduceAndCopy. Mỗi nguyên hàm được tối ưu hóa để truyền đi một lượng dữ liệu tinh giản (4-16KB) giữa các GPU. Các lõi xử lý cũng đã được tối ưu hóa đặc biệt để đạt được băng thông tối đa trong khi lại chiếm dụng thấp. Kết quả là, NCCL có thể sử dụng tối đa tải một kết nối PCIe 3.0 x16 mà chỉ bằng một đơn vị tổ hợp luồng xử lý CUDA. Điều này khiến giải phóng phần lớn GPU dùng vào chạy các tác vụ tính toán đồng thời với truyền tải thông tin.

NCCL hiện hỗ trợ các tập lệnh all-gather, all-reduce, broadcast, reduce, và reduce-scatter, không hạn chế số lượng GPU được sử dụng, miễn là nằm trên cùng một máy.

Sử dụng NCCL

Mã nguồn mở NCCL có sẵn trên Github . Các thư viện có thể chạy trên bất kỳ phiên bản Linux phổ biến và cũng tương thích với CUDA 7.0 hay mới hơn. Hiện giờ, CUDA chỉ hỗ trợ truy cập trực tiếp giữa các GPU cùng kiến trúc và phải nằm trên cùng một máy (PCIe root hub). GPU không phù hợp các tiêu chí này vẫn được hỗ trợ bởi NCCL, mặc dù hiệu suất sẽ được giảm do việc chuyển dữ liệu sẽ đi vòng thông qua vùng nhớ xác định trong bộ nhớ hệ thống.
Các NCCL API đều tuân thủ chặt theo MPI. Trước khi thực thi các tập lệnh, một đối tượng truyền tin phải được tạo ra trên mỗi GPU. Các đối tượng này xác định các thiết lập của các GPU tham gia vào tổ hợp và lập bản đồ các luồng thông tin liên lạc giữa chúng. Tập hợp đối tượng giao tiếp liên quan với nhau được gọi là một clique (phe). Có hai cách để khởi tạo đối tượng truyền tin trong NCCL. Phương pháp phổ thông nhất là gọi hàm ncclCommInitRank() tương ứng với mỗi GPU.

ncclResult_t ncclCommInitRank(ncclComm_t* comm,
int nGPUs, ncclUniqueId cliqueId,
int rank);
Chức năng này giả định rằng các GPU thuộc các hàng xác định đã được thiết lập thông qua cudaSetDevice()nGPUs là số lượng GPU trong một clique. cliqueId cho phép các hàng trong cùng clique nhận diện nhau. Các hàng thì sẽ có cùng một cliqueId. Để làm vậy, hàm ncclGetUniqueId() được gọi từ trong một hàng và sẽ phát tán kết quả trả về uniqueId đến các hàng khác còn lại trong clique thông qua mô hình truyền thông tùy chọn (ví dụ, MPI_Bcast).
Các biến số cuối cùng trong ncclCommInitRank() xác định các chỉ số của GPU hiện tại trong clique. Nó phải là duy nhất cho mỗi cấp trong clique và trong tập hợp [0, nGPUs]. Chỉ số này được sử dụng, ví dụ, để xác định các GPU nguồn cho lệnh broadcast, hoặc để truy xuất đến theo lệnh all-gather.
Sau khi khởi tạo thành công, ncclCommInitRank() trả về ncclSuccess và *comm được thiết lập chứa đối tượng truyền tin mới của NCCL.
Bên trong, ncclInitRank() thực hiện đồng bộ giữa tất cả các đối tượng truyền tin trong cùng một clique. Như vậy, nó phải được gọi từ các luồng khởi tạo khác nhau cho từng GPU, hoặc từ các xử lý riêng biệt (ví dụ, MPI ranks). Cụ thể, nếu gọi ncclInitRank() trong cùng một vòng lặp xử lý cho các GPUs sẽ dẫn đến tình trạng xung đột khóa chết (deadlocks)
Cách thứ hai để khởi tạo các đối tượng truyền tin là sử dụng ncclCommInitAll(). Đây thực chất là một thói quen tiện dụng để tiết kiệm việc phải sinh thêm các luồng khởi tạo mới để chạy NCCL trong trình ứng dụng đơn nhiệm.

ncclResult_t ncclCommInitAll ( ncclComm_t * comms ,
int nGPUs ,
int * devList );
Biến comms đang trỏ đến một mảng các đối tượng ncclComm_t, tương ứng cho mỗi hàng chứa nGPUs trong cùng 1 clique. devList thì quy định cụ thể thiết bị CUDA nào tương ứng với từng hàng. Khi khởi tạo các đối tượng truyền tin, ta có thể gọi các tập lệnh thông qua các hàm gốc của chúng, chẳng hạn như ncclAllReduce().
ncclResult_t ncclAllReduce ( void* sendoff ,
void* recvbuff , int count ,
ncclComm_t comm ,
ncclDataType_t type , ncclRedOp_t op ,
cudaStream_t stream);
Tài liệu chi tiết cho mỗi tập lệnh được cung cấp trong file nccl.h. Hầu hết các biến đều tương tự như trong tập lệnh API của MPI. Trường hợp ngoại lệ nổi bật là các biến dòng (stream). Tương tự như rất nhiều chương trình CUDA "Async", lệnh NCCL sẽ lên lịch thực thi trong một dòng, nhưng có thể trả lại trước khi tập lệnh được hoàn tất. Bằng cách xếp các tập lệnh vào hàng đợi còn nhân tính toán riêng khác ở các dòng độc lập, GPU có thể chồng lấn nhiều hơn công việc đòi hỏi tính toán tập trung cao vào cùng thời điểm truyền tập lệnh. Nhằm tối đa hóa sự chồng lấn này, các tập lệnh NCCL được lập lịch chạy trên dòng có độ ưu tiên cao để cho phép chúng trượt qua lại trong các lưới tính toán tập trung.
Ứng với mỗi đối tượng truyền tin trong một clique đều phải gọi đến ncclAllReduce(), mỗi lần gọi được thực hiện qua bộ đệm gửi và nhận riêng biệt, v.v. Tập lệnh NCCL sẽ giả định ngữ cảnh CUDA tương ứng là mới nhất. Và bởi vì NCCL chạy không đồng bộ, một vòng lặp đơn giản có thể được sử dụng để khởi tạo một tập lệnh từ một ứng dụng đơn luồng.

for ( int gpu = 0 ; gpu < nGPUs ; ++ gpu ) {
cudaSetDevice ( devList [ gpu ]);
}
ncclAllReduce (...);

Hiệu suất

Hình 6: Liên kết băng thông đạt được của tập thể NCCL khác nhau.
Hiệu suất mà tập lệnh NCCL đạt được phụ thuộc vào chính cấu trúc liên kết của máy tính. Trong trường hợp tốt nhất, tất cả các GPU đều chiếm phần truy cập như nhau. Đây là mô hình phổ biến nhất ở các máy trạm được trang bị vài GPU. Các máy chủ lớn hơn thường gắn với CPU kép, và một số lượng các GPU tách biệt trên các hub IO khác nhau. Hình 6 cho thấy băng thông NCCL tương ứng đối với các tập hợp lệnh khác nhau đo trên máy NVIDIA Digits DevBox trang bị 4 GPU GeForce GTX Titan X được ráp theo mô hình ở hình 4 trên đây.
Đường màu đỏ 10.4 GB/ s đại diện cho băng thông sử dụng bở một lệnh cudaMemcpy dự liệu lớn giữa hai trong số các GPU (cụ thể là GPU 0 và 2 trong hình 4). NCCL vẫn có thể duy trì tỷ lệ cao khả năng chiếm dụng băng thông cao điểm đó khi thực hiện giao tiếp giữa tất cả bốn GPU.

Hướng phát triển trong tương lai và một số điểm lưu tâm

Mục tiêu của NCCL là để cung cấp tập lệnh tổ hợp tự nhận thức được cấu trúc liên kết nhằm có thể cải thiện độ khả mở của các ứng dụng đa GPU. Sử dụng NCCL sẽ có thể giúp đạt được hiệu suất tuyệt vời mà không phải chú ý về các chi tiết phần cứng bậc thấp.
Nhữn mẩu mã nguồn ví dụ đơn giản cho cả hai loại ứng dụng trình đơn và đa luồng MPI cũng được kèm sẵn trong bộ thư viện NCCL. Xuất phát từ một dự án nghiên cứu, mọi phản hồi cuả các nhà phát triển đểu được hoanh nghênh vì dự án này vẫn còn tiếp tục. Hãy dùng thử và chia sẻ bạn nghĩ gì nhé! Để cập nhật về những thông tin mới nhất, hãy đến dự buổi nói chuyện NCCL của tôi ở GTC.

Thứ Hai, tháng 1 02, 2017

LÀM THẾ NÀO ĐỂ CHẠY SONG SONG HỌC-SÂU TRÊN GPU (PHẦN 2)


Mô hình song song


Ở phần trước, tôi đã giải thích qua khái niệm về xử lý song song trên mô hình (MP-Model Parallelism) và dữ liệu (DP-Data Parallelism) cũng như phân tích làm thế nào để sử dụng DP hiệu quả trong học-sâu (Deep Learning). Ở bài viết này, tôi sẽ tập trung vào mô hình song song (MP).

XIn nhắc lại, mô hình xử lý song song là, khi bạn chia một mô hình thành từng phần cho các GPU và sử dụng cùng một dữ liệu cho từng mô hình; vì vậy mỗi GPU hoạt động trên một phần của mô hình chứ không phải là một phần của dữ liệu. Trong học-sâu, một cách để làm việc này bằng cách phân trọng số, ví dụ một ma trận 1000 x 1000 thì trọng số sẽ được phân chia thành các ma trận 1000 × 250 nếu bạn sử dụng 4 GPU.

Sơ đồ MP. Đồng bộ hóa kết quả là cần thiết sau mỗi vòng tính tích vô hướng với ma trận trọng số cho cả hai chiều tiến và quy nạp.

Một lợi thế của phương pháp này nhận thấy ngay: Nếu chúng ta chia trọng số giữa các GPU chúng ta có thể chạy với mạng nơ-ron rất lớn dù rằng bộ nhớ của một GPU đơn lẻ không đủ dung lượng nạp trọng số vào. Ở phần trước, tôi cũng có nói rằng các mạng nơ-ron lớn như vậy là không cần thiết với đa số người. Tuy nhiên, đối với các tác vụ học-không-giám-sát (un-supervised learning) khủng hơn -trong tương lai thì sẽ khá phổ biến – mạng nơ-ron lớn lại rất cần thiết dùng để học được các đặc tả chi tiết nhất nhằm có được hành vi “thông minh”.

Làm thế nào để thuật toán chuyển tiến và quy nạp phân chia thành các ma trận như vậy được? Nếu nạp ma trận số học từng bước từng bước, ta sẽ thấy rõ ràng ngay:

Giả sử bắt đầu tìm kiếm tại A, B = C đó sẽ là tích ma trận trong trường hợp thông thường thuật toán tiến. Kích thước MP với 2 GPU, phân lô 128 và ma trận trọng số 1000 × 500 sẽ là:

Tiêu chuẩn: 128 × 1000 nhân 1000 × 500 = 128 × 500

Chia theo ma trận trọng số chiều dọc: 128 × 500 nhân 500 × 500 = 128 × 500 -> phép cộng ma trận

Chia theo ma trận trọng số chiều ngang: 128 × 1000 nhân 1000 × 250 = 128 × 250 -> phép chồng ma trận

Để tính toán sai số trong các lớp bên dưới, cần phải truyền các sai số tới được các lớp tiếp theo, hoặc nói theo cách toán học hơn, ta cần tính toán các giá trị delta bằng cách lấy tích vô hướng của các sai số ở lớp {i} trước với các trọng số kết nối lớp {j} tiếp theo , tức là :

Tiêu chuẩn: 128 × 500 nhân 500 × 1000 = 128 × 1000

Chia theo ma trận trọng số chiều dọc: 128 × 500 nhân 500 × 500 = 128 × 500 -> phép chồng ma trận

Chia theo ma trận trọng số chiều ngang: 128 × 250 nhân 250 × 1000 = 128 × 1000 -> phép cộng ma trận

Chúng ta thấy ở đây, cần phải đồng bộ hóa (cộng hoặc xếp chồng trọng số) sau mỗi kết quả tích vô hướng và bạn có thể nghĩ rằng như vậy sẽ chậm hơn so với xử lý dữ liệu song song DP ,vì DP đồng bộ hóa chỉ một lần. Nhưng có thể dễ dàng thấy rằng điều đó không phải đúng cho hầu hết các trường hợp, thử tính toán xem: Trong DP một khuynh độ của 1000 × 500 cần được truyền một lần/lớp cho 1000 × 500 lớp – tức là 500000 đơn vị; còn với MP chúng ta chỉ cần truyển một ma trận nhỏ cho tiến và quy nạp với tổng số 128000 hoặc 160000 đơn vị – dung lượng ít hơn gần 4 lần! Vì thế dù băng thông card mạng vẫn là nút cổ chai chính trong toàn bộ ứng dụng, nhưng sẽ ít bị ảnh hưởng hơn nhiều so với trong trường hợp DP.

Dĩ nhiên, tất cả chỉ là tương đối và phụ thuộc vào kiến ​​trúc mạng. DP sẽ khá nhanh cho các mạng nhỏ và rất chậm cho các mạng lớn, MP thì ngược lại. Càng nhiều thông số, MP càng có lợi. Lợi thế càng thực sự rõ ràng khi mạng nơ-ron có trọng số vượt quá dung lượng một bộ nhớ GPU đơn.MP có thể xử lý việc mà thông thường sẽ cần đến hàng ngàn bộ vi xử lý CPU.

Tuy nhiên, nếu bạn chạy các mạng nhỏ, bộ nhớ GPU dư thừa và và vẫn còn năng lực xử lý (không phải tất cả các lõi đang chạy), thì dùng MP sẽ chậm. Không giống như DP, không có thủ thuật bạn có thể sử dụng để ẩn các thời gian giao tiếp cần thiết để đồng bộ hóa, điều này là bởi vì chúng ta chỉ có một phần thông tin cho toàn bộ phân lô. Với một phần thông tin này thì không thể tính toán các hoạt động trong lớp tiếp theo và do đó phải chờ đợi để hoàn thành đồng bộ hóa mới đi tiếp bước kế tiếp được.

Làm thế nào những lợi thế và bất cập có thể kết hợp được chỉ ra rõ ràng nhất bởi Alex Krizhevsky người đã chứng minh hiệu quả của việc sử dụng DP trong các lớp tính tích chập và MP trong các lớp dày đặc của một mạng nơ-ron tích chập.

LÀM THẾ NÀO ĐỂ CHẠY SONG SONG HỌC-SÂU (DEEP LEARNING) TRÊN GPU (PHẦN 1)

Phần 1: Dữ liệu song song – Data Parallelism


Trong bài viết trước, tôi đã chỉ ra những gì cần chú ý khi bạn muốn xây dựng một cụm xử lý với nhiều GPU. Quan trọng nhất, bạn sẽ cần một kết nối mạng nhanh giữa các máy chủ và dùng thư viện MPI trong chương trình sẽ giúp đơn giản hóa vượt trội so với sử dụng các tùy chọn sẵn có của CUDA.
Ở bài viết này tôi sẽ giải thích làm thế nào để triển khai thuật toán xử lý song song cho mạng nơ-ron với một cụm GPU theo những cách khác nhau cùng với những lợi thế và bất cập của các thuật toán đó. Có hai hướng triển khai thuật toán khác nhau là xử lý dữ liệu song song (Data Parallelism- DP) hay xử lý mô hình song song (Model Parallelism -MP). Ở bài viết này tôi sẽ tập trung vào DP.
Vậy, hai hướng tiếp cận trên là gì? DP có nghĩa là bạn sử dụng cùng một mô hình giống nhau trên mỗi nhánh xử lý (thread), nhưng cấp cho các nhánh với từng phần khác nhau của cùng một bộ dữ liệu; còn MP thì sử dụng cùng một bộ dữ liệu cho mỗi nhánh, nhưng chia nhỏ mô hình thành các phần xử lý trên các nhánh khác nhau.
Đối với các mạng nơ-ron, điều trên có nghĩa DP sử dụng các trọng số giống nhau và mỗi nhánh xử lý các phân lô khác nhau; sau đó khuynh độ (gradient) cần phải được đồng bộ theo cách trung bình hóa, sau mỗi lần xử lý xong một phân lô.
MP thì chia trọng số của mạng đều ra cho các nhánh xử lý và tất cả các nhánh cùng xử lý trên cùng một phân lô; ở đây kết quả tạo được sau mỗi lớp xử lý cần phải được đồng bộ, theo cách xếp chồng lên nhau, để cung cấp đầu vào cho các lớp xử lý tiếp theo.
Mỗi phương pháp có ưu điểm và nhược điểm của nó, thay đổi theo kiến ​​trúc xử lý. Trước tiên, chúng ta cùng xem xét DP (xử lý dữ liệu song song) với những yếu điểm nghẽn cổ chai của nó, còn trong bài viết tiếp theo, tôi sẽ xét đến xử lý kiểu MP (xử lý mô hình song song).
Điểm chí tử: nút thắt cổ chai mạng trong xử lý dữ liệu song song
Ý tưởng DP khá đơn giản. Ví dụ bạn có 4 GPU, bạn chia một phân lô thành các phần nhỏ cho mỗi GPU, tỉ như, bạn chia một phân lô gồm 128 mẫu thành 32 mẫu cho mỗi GPU. Sau đó, bạn đưa các lô tương ứng qua mạng để có được khuynh độ cho từng phần đó của một lô dữ liệu. Sau đó bạn sử dụng MPI để thu thập tất cả các khuynh độ và cập nhật các thông số với giá trị trung bình tổng.





Sơ đồ xử lý dữ liệu song song DP. Không có giao tiếp bước tiến, còn trong bước lùi thì có đồng bộ hóa khuynh độ.


Vấn đề lớn nhất với phương pháp này là trong bước quy nạp cần phải chuyển toàn bộ khuynh độ sang tất cả các GPU khác. Nếu bạn có một ma trận trọng số 1000 × 1000 thì bạn cần đẩy 4 triệu byte cho mỗi hệ mạng. Nếu chúng ta dùng card mạng 40Gbit/s – cũng thuộc loại khá nhanh – thì cần ít nhất (4.000.000/40) x (1/(40x1024x1024x102)) x 1/(8×1000) = 0,75ms để chuyển dữ liệu giữa 2 nút xử lý cần phải giao tiếp đến được 5 GPU còn lại, 3 trong số đó cần phải đi qua các card mạng (3 x 0.75ms), còn 2 GPU có thể sử dụng PCIe 3.0 để truyền dữ liệu với hai GPU khác (sẽ nhanh hơn khoảng ba lần: 2×0.25ms). Do dữ liệu truyền qua cổng PCIe không liên quan tới card mạng, nên thời gian cần thiết để dữ liệu giữa 2 card mạng quyết định tốc độ chung và cũng đã là 2.25ms. Tất nhiên, chỉ một GPU có thể chuyển dữ liệu thông qua các card mạng tại một thời điểm trong bất kỳ một nút, do đó chúng ta phải nhân thời gian đó lên 3 lần, nghĩa là 7.75ms. Vậy mấu chốt là, chúng ta chỉ cần khoảng 0.2ms cho một ma trận dựng thông qua các lớp (100 × 1000 dot 1000 × 1000) và khoảng gấp đôi để quy nạp. Chúng ta có thể truyền giá trị khuynh độ trong khi chúng ta xử lý ở phân lớp kế tiếp, nhưng rồi thì về tổng thể thì tốc độ card mạng cũng sẽ giới hạn khả năng tính toán của chúng ta tương đối nhiều. Điều này càng rõ với hê thống có quy mô lớn hơn: Một hệ thống bốn nút xử lý trên cùng một bài toán cần khoảng 20.25ms để truyền thông tin khuynh độ đến được các GPU trong hệ thống. Dễ dàng nhận thấy tiếp cận DP không tăng theo được quy mô cụm nút xử lý.
Để giải quyết sự tắc nghẽn này thì phải giảm thiểu các thông số của khuynh độ bằng kỹ thuật tối đa hoá vùng chờ, tối đa số lượng đơn vị ấn định hoặc đơn giản nữa thì dùng tích chập (convolutional). Một cách nữa hướng tới để tăng tỷ lệ thời gian tính toán / thời gian kết mạng bằng các kỹ thuật khác, ví dụ như dùng tính toán tối ưu hóa chuyên sâu như RMSProp. Thời gian dành để chuyển các thông tin khuynh độ với nhau không đổi, nhưng dành được nhiều thời gian dành vào tính toán hơn, do đó tăng hiệu suất của GPU vốn có khả năng tính toán nhanh.
Một điều nữa có thể làm khi sử dụng các kỹ thuật tối ưu hóa tính toán chuyên sâu là làm ẩn đi độ trễ của mạng vào trong quá trình tính toán khuynh độ. Đại ý là cùng lúc bạn đi truyền thông tin khuynh độ đầu tiên cho tất cả các nút khác, bạn cũng đã bắt đầu một tính toán RMSProp lớn không đồng bộ cho lớp kế tiếp. Kỹ thuật này có thể giúp tăng tốc độ lên từ 0-20% tùy thuộc vào kiến ​​trúc mạng.
Nhưng đây không phải là vấn đề duy nhất với xử lý dữ liệu song song. Tồn tại nút thắt cổ chai kỹ thuật ẩn ngay trong kiến ​​trúc GPU khiến tôi mất khá nhiều thời gian để nắm bắt. Để hiểu lý do tại sao các kiến ​​trúc GPU lại thành vấn đề thì trước tiên chúng ta cần phải nhìn vào cách sử dụng và mục đích của việc phân chia dữ liệu thành các phân lô nhỏ.

Phân kỳ: Tại sao cần phân lô dữ liệu?

Nếu chúng ta bắt đầu với các thông số khởi tạo ngẫu nhiên hoặc thậm chí nếu chúng ta bắt đầu với các thông số đã tập huấn máy trước đó, chúng ta không cần phải truyền tất cả các dữ liệu để có được một bản cập nhật chính xác khuynh độ vì kiểu gì chúng cũng sẽ bị giản thiểu cục bộ. Lấy MNIST làm ví dụ, nếu chúng ta có một khuynh độ trong đó bao gồm 10 sai lầm phổ biến mà mạng lọc ra cho mỗi lớp học (kích thước phân lô khoảng 128), thì chúng ta đã đang đi theo hướng làm giảm các lỗi đáng kể vì các lớp khuynh độ lọc bỏ lỗi thô và phổ biến. Nếu chúng ta chọn một kích thước phân lô lớn hơn (512 chẳng hạn) thì chúng ta không chỉ nắm bắt được những lỗi thông thường, nhưng cũng bắt được lỗi tinh vi hơn. Tuy nhiên, lại chả có nghĩa gì khi đi tinh chỉnh một hệ thống mà biết rằng nó vẫn còn đang mắc những lỗi lớn. Vì vậy, nhìn chung chúng ta việc tăng kích cỡ phân lô đạt được hiệu quả rất ít. Chúng ta cần phải tính toán nhiều hơn mà kết quả vẫn gần tương tự và đây là luận điểm chính vì sao chúng ta sử dụng một kích thước phân lô càng nhỏ càng tốt. Tuy nhiên, nếu chúng ta chọn một kích thước phân lô quá nhỏ, thì chúng ta không nắm bắt tất cả những lỗi phổ biến mà có liên quan để toàn tập dữ liệu và do đó khuynh độ của chúng ta không thể đạt được gần mức tối ưu cục bộ, vì vậy cũng có điểm tới hạn dưới cho việc chia các phân lô nhỏ đến mức nào thì vừa.
Tại sao điều này có liên quan đến DP? Nếu chúng ta muốn có một kích thước phân lô 128 và chạy DP để phân chia ra,ví như là, 8 GPU chẳng hạn, sau đó mỗi vòng tính toán giá trị khuynh độ trên 16 mẫu rồi trung bình hóa với các dữ liệu từ GPU khác. Chính đây là trường hợp kẹt phải nút cổ chai của phần cứng.

Mảnh ghép bộ nhớ (Memory tiles): Cấp phát vùng bộ nhớ GPU nhanh để có thể tính tích vô hướng hiệu quả


Để tính tích vô hướng trên GPU, bạn cần phải sao chép vùng nhớ nhỏ, được gọi là mảnh ghép bộ nhớ, vào bộ nhớ đệm dùng chung, vốn là bộ đệm rất nhanh nhưng kích thước rất nhỏ (giới hạn trong một vài kilobyte). Vấn đề là các cuBLAS tiêu chuẩn sử dụng mảng ghép bộ nhớ kích thước 64 hay 128 và khi bạn có một kích thước phân lô dữ liệu ít hơn 64 bạn sẽ lãng phí rất nhiều bộ nhớ dùng chung quý giá ấy. Ngoài ra nếu bạn sử dụng một kích thước phân lô không phải bội số của 32 bạn cũng đang lãng phí bộ nhớ dùng chung cũng theo cách như vậy (dòng xử lý chỉ được kích hoạt theo từng khối gồm 32 dòng xử lý), vì vậy hãy cố gắng sử dụng một kích thước lô là một bội số của 32 hoặc bội số của 64 nếu có thể. Đối với DP thực ra điều này khiến tốc độ xử lý giảm đi đáng kể khi cấp một kích thước phân lô dữ liệu nhỏ hơn 64 cho mỗi GPU. Nếu bạn có nhiều GPU,sẽ thấy rất khó tối ưu và đây là một lý do tại sao các phương pháp tiếp cận DP sẽ dừng lại ở một quy mô tới hạn nhất định.
Túm lại, điều này nghe có vẻ khá nghiêm trọng đối với xử lý dữ liệu song song DP, nhưng DP vẫn có các ứng dụng phù hợp của mình. Chỉ cần hiểu rõ những chỗ thắt cổ chai, bạn có thể ứng dụng linh hoạt DP như một công cụ mạnh vào thực tế. Điều này được minh chứng bởi Alex Krishevsky trong bài báo của mình, khi ứng dụng DP trong các lớp tích chập của Alex, và do đó đạt được sự tăng tốc 3.74x lần bằng nếu chạy trên 4 GPU và 6.25x lần khi chạy trên 8 GPU. Hệ thống của Alex có 2 CPU và 8 GPU trong cùng một máy, vì vậy ông có thể sử dụng tốc độ PCIe đầy đủ cho hai bộ bốn GPU và kết nối PCIe tương đối nhanh chóng giữa các CPU để phân phối dữ liệu lên tất cả 8 GPU.
Bên cạnh mạng nơ-ron tích chập (convolutional neural networks-CNNs), DP còn được dùng trong các mạng nơ-ron tái phát (recurrent neural networks RNNs), vốn yêu cầu ít thông số nhưng cập nhật thông tin khuynh độ tính toán rất lớn – cả hai yếu tố này đều là lợi thế của xử lý dữ liệu song song.

Trong bài blog tiếp theo của tôi, tôi sẽ tập trung vào mô hình xử lý song song MP, hiệu quả cho các mạng nơ-ron lớn và cũng trên quy mô các cụm xử lý lớn hơn.