.. raw:: html .. _sec_async: Tính toán Bất đồng bộ ===================== .. raw:: html Máy tính ngày nay là các hệ thống có tính song song cao, được cấu thành từ nhiều lõi CPU (mỗi lõi thường có nhiều luồng), nhiều phần tử xử lý trong mỗi GPU và thường có nhiều GPU trong mỗi máy. Nói ngắn gọn, ta có thể xử lý nhiều tác vụ cùng một lúc, thường là trên nhiều thiết bị khác nhau. Tiếc thay, Python không phải là một ngôn ngữ phù hợp để viết mã tính toán song song và bất đồng bộ, nhất là khi không có sự trợ giúp từ bên ngoài. Xét cho cùng, Python là ngôn ngữ đơn luồng, và có lẽ trong tương lai sẽ không có gì thay đổi. Các framework học sâu như MXNet và TensorFlow tận dụng mô hình lập trình bất đồng bộ để cải thiện hiệu năng (PyTorch sử dụng bộ định thời của chính Python nên có tiêu chí đánh đổi hiệu năng khác). Do đó, việc hiểu rõ cách lập trình bất đồng bộ giúp ta phát triển các chương trình hiệu quả hơn bằng cách chủ động giảm thiểu yêu cầu tính toán và các quan hệ phụ thuộc tương hỗ. Việc này cho phép ta giảm chi phí tính toán phụ trợ và tăng khả năng tận dụng vi xử lý. Ta bắt đầu bằng việc nhập các thư viện cần thiết. .. code:: python from d2l import mxnet as d2l import numpy, os, subprocess from mxnet import autograd, gluon, np, npx from mxnet.gluon import nn npx.set_np() .. raw:: html Bất đồng bộ qua Back-end ------------------------ .. raw:: html Để khởi động, hãy cùng xét một bài toán nhỏ - ta muốn sinh ra một ma trận ngẫu nhiên và nhân nó lên nhiều lần. Hãy thực hiện việc này bằng cả NumPy và NumPy của MXNet để xem xét sự khác nhau. .. code:: python with d2l.Benchmark('numpy'): for _ in range(10): a = numpy.random.normal(size=(1000, 1000)) b = numpy.dot(a, a) with d2l.Benchmark('mxnet.np'): for _ in range(10): a = np.random.normal(size=(1000, 1000)) b = np.dot(a, a) .. parsed-literal:: :class: output numpy: 0.3791 sec mxnet.np: 0.0076 sec .. raw:: html NumPy của MXNet nhanh hơn tới cả hàng trăm hàng ngàn lần. Ít nhất là có vẻ là như vậy. Do cả hai thư viện đều được thực hiện trên cùng một bộ xử lý, chắc hẳn phải có gì đó ảnh hướng đến kết quả. Nếu ta ép MXNet phải hoàn thành tất cả phép tính trước khi trả về kết quả, ta có thể thấy rõ điều gì đã xảy ra ở trên: phần tính toán được thực hiện bởi back-end trong khi front-end đã trả lại quyền điều khiển cho Python. .. code:: python with d2l.Benchmark(): for _ in range(10): a = np.random.normal(size=(1000, 1000)) b = np.dot(a, a) npx.waitall() .. parsed-literal:: :class: output Done: 0.2889 sec .. raw:: html Nhìn chung, MXNet có front-end cho phép tương tác trực tiếp với người dùng thông qua Python, cũng như back-end được sử dụng bởi hệ thống nhằm thực hiện nhiệm vụ tính toán. Như ở :numref:`fig_frontends`, người dùng có thể viết chương trình MXNet bằng nhiều ngôn ngữ front-end như Python, R, Scala và C++. Dù sử dụng ngôn ngữ front-end nào, chương trình MXNet chủ yếu thực thi trên back-end lập trình bằng C++. Các thao tác đưa ra bởi ngôn ngữ front-end được truyền vào back-end để thực thi. Back-end tự quản lý các luồng xử lý bằng việc liên tục tập hợp và thực thi các tác vụ trong hàng đợi. Chú ý rằng, back-end cần phải có khả năng theo dõi quan hệ phụ thuộc giữa các bước trong đồ thị tính toán để có thể hoạt động. Nghĩa là ta không thể song song hóa các thao tác phụ thuộc lẫn nhau. .. raw:: html .. _fig_frontends: .. figure:: ../img/frontends.png :width: 500px Lập trình Front-end. .. raw:: html Hãy xét một ví dụ đơn giản để có thể hiểu rõ hơn đồ thị quan hệ phụ thuộc (*dependency graph*). .. code:: python x = np.ones((1, 2)) y = np.ones((1, 2)) z = x * y + 2 z .. parsed-literal:: :class: output array([[3., 3.]]) .. raw:: html .. _fig_asyncgraph: .. figure:: ../img/asyncgraph.svg Quan hệ phụ thuộc. .. raw:: html Đoạn mã trên cũng được mô tả trong :numref:`fig_asyncgraph`. Mỗi khi luồng front-end của Python thực thi một trong ba câu lệnh đầu tiên, nó sẽ chỉ đưa tác vụ đó vào hàng chờ của back-end. Khi kết quả của câu lệnh cuối cùng cần được in ra, luồng front-end của Python sẽ chờ luồng xử lý back-end C++ tính toán xong kết quả của biến ``z``. Lợi ích của thiết kế này nằm ở việc luồng front-end Python không cần phải đích thân thực hiện việc tính toán. Do đó, hiệu năng tổng thể của chương trình cũng ít bị ảnh hưởng bởi hiệu năng của Python. :numref:`fig_threading` mô tả cách front-end và back-end tương tác với nhau. .. raw:: html .. _fig_threading: .. figure:: ../img/threading.svg Front-end và Back-end .. raw:: html Lớp cản và Bộ chặn ------------------ .. raw:: html Có khá nhiều thao tác buộc Python phải chờ cho đến khi nó hoàn thành: - Hiển nhiên nhất là lệnh ``npx.waitall()`` chờ đến khi toàn bộ phép toán đã hoàn thành, bất chấp thời điểm câu lệnh tính toán được đưa ra. Trong thực tế, trừ khi thực sự cần thiết, việc sử dụng thao tác này là một ý tưởng tồi do nó có thể làm giảm hiệu năng. - Nếu ta chỉ muốn chờ đến khi một biến cụ thể nào đó sẵn sàng, ta có thể gọi ``z.wait_to_read()``. Trong trường hợp này MXNet chặn việc trả luồng điều khiển về Python cho đến khi biến ``z`` đã được tính xong. Các thao tác khác sau đó mới có thể tiếp tục. .. raw:: html Hãy xem cách các lệnh chờ trên hoạt động trong thực tế: .. code:: python with d2l.Benchmark('waitall'): b = np.dot(a, a) npx.waitall() with d2l.Benchmark('wait_to_read'): b = np.dot(a, a) b.wait_to_read() .. parsed-literal:: :class: output waitall: 0.0023 sec wait_to_read: 0.0021 sec .. raw:: html Cả hai thao tác hoàn thành với thời gian xấp xỉ nhau. Ngoài các thao tác chặn (*blocking operation*) tường minh, bạn đọc cũng nên biết về việc chặn *ngầm*. Rõ ràng việc in một biến ra yêu cầu biến đó phải sẵn sàng và do đó nó là một bộ chặn. Cuối cùng, ép kiểu sang NumPy bằng ``z.asnumpy()`` và ép kiểu sang số vô hướng bằng ``z.item()`` cũng là bộ chặn, do trong NumPy không có khái niệm bất đồng bộ. Có thể thấy việc ép kiểu cũng cần truy cập giá trị, giống như hàm ``print``. Việc thường xuyên sao chép một lượng nhỏ dữ liệu từ phạm vi của MXNet sang NumPy và ngược lại có thể làm giảm đáng kể hiệu năng của một đoạn mã đáng lẽ sẽ có hiệu năng tốt, do mỗi thao tác như vậy buộc đồ thị tính toán phải tính toàn bộ các giá trị trung gian để suy ra các số hạng cần thiết *trước khi* thực hiện bất cứ thao tác nào khác. .. code:: python with d2l.Benchmark('numpy conversion'): b = np.dot(a, a) b.asnumpy() with d2l.Benchmark('scalar conversion'): b = np.dot(a, a) b.sum().item() .. parsed-literal:: :class: output numpy conversion: 0.0028 sec scalar conversion: 0.0122 sec .. raw:: html Cải thiện Năng lực Tính toán ---------------------------- .. raw:: html Trong một hệ thống đa luồng lớn (ngay cả laptop phổ thông cũng có 4 luồng hoặc hơn, và trên các máy trạm đa socket, số luồng có thể vượt quá 256), chi phí phụ trợ từ việc định thời các thao tác có thể trở nên khá lớn. Đó là lý do tại sao hai quá trình tính toán và định thời nên xảy ra song song và bất đồng bộ. Để minh hoạ cho lợi ích của việc này, hãy so sánh khi liên tục cộng 1 vào một biến theo cách đồng bộ và bất đồng bộ. Ta mô phỏng quá trình thực thi đồng bộ bằng cách chèn một lớp cản ``wait_to_read()`` giữa mỗi phép cộng. .. code:: python with d2l.Benchmark('synchronous'): for _ in range(1000): y = x + 1 y.wait_to_read() with d2l.Benchmark('asynchronous'): for _ in range(1000): y = x + 1 y.wait_to_read() .. parsed-literal:: :class: output synchronous: 0.0246 sec asynchronous: 0.0226 sec .. raw:: html Ta có thể tóm tắt đơn giản sự tương tác giữa luồng front-end Python và luồng back-end C++ như sau: .. raw:: html 1. Front-end ra lệnh cho back-end đưa tác vụ tính ``y = x + 1`` vào hàng đợi. 2. Back-end sau đó nhận các tác vụ tính toán từ hàng đợi và thực hiện các phép tính. 3. Back-end trả kết quả tính toán về cho front-end. .. raw:: html Giả sử thời gian thực hiện mỗi giai đoạn trên lần lượt là :math:`t_1, t_2` và :math:`t_3`. Nếu ta không áp dụng lập trình bất đồng bộ, tổng thời gian để thực hiện 1000 phép tính xấp xỉ bằng :math:`1000 (t_1+ t_2 + t_3)`. Còn nếu ta áp dụng lập trình bất đồng bộ, tổng thời gian để thực hiện 1000 phép tính có thể giảm xuống còn :math:`t_1 + 1000 t_2 + t_3` (giả sử :math:`1000 t_2 > 999t_1`), do front-end không cần phải chờ back-end trả về kết quả tính toán sau mỗi vòng lặp. .. raw:: html Cải thiện Mức chiếm dụng Bộ nhớ ------------------------------- .. raw:: html Cùng hình dung với trường hợp ta liên tục thêm các tính toán vào back-end bằng cách thực thi mã Python trên front-end. Ví dụ, trong một khoảng thời gian rất ngắn, front-end liên tục thêm vào một lượng lớn các tác vụ trên minibatch. Xét cho cùng, công việc trên có thể hoàn thành nhanh chóng nếu không có phép tính nào thật sự diễn ra trên Python. Nếu tất cả tác vụ trên cùng được khởi động một cách nhanh chóng thì có thể dẫn đến dung lượng bộ nhớ sử dụng tăng đột ngột. Do dung lượng bộ nhớ có sẵn trên GPU (và ngay cả CPU) là có hạn, điều này có thể gây ra sự tranh chấp tài nguyên hoặc thậm chí làm sập chương trình. Độc giả có lẽ đã nhận ra rằng ở các quy trình huấn luyện trước, ta áp dụng các thao tác đồng bộ như ``item`` hay ngay cả ``asnumpy``. .. raw:: html Chúng tôi khuyến nghị nên sử dụng các thao tác này một cách cẩn thận, ví dụ như với từng minibatch, ta cần đảm bảo sao cho hiệu năng tính toán và mức chiếm dụng bộ nhớ (*memory footprint*) được cân bằng. Để minh họa, hãy cùng lập trình một vòng lặp huấn luyện đơn giản, đo lượng bộ nhớ tiêu hao và thời gian thực thi, sử dụng hàm sinh dữ liệu và mạng học sâu dưới đây. .. code:: python def data_iter(): timer = d2l.Timer() num_batches, batch_size = 150, 1024 for i in range(num_batches): X = np.random.normal(size=(batch_size, 512)) y = np.ones((batch_size,)) yield X, y if (i + 1) % 50 == 0: print(f'batch {i + 1}, time {timer.stop():.4f} sec') net = nn.Sequential() net.add(nn.Dense(2048, activation='relu'), nn.Dense(512, activation='relu'), nn.Dense(1)) net.initialize() trainer = gluon.Trainer(net.collect_params(), 'sgd') loss = gluon.loss.L2Loss() .. raw:: html Tiếp theo, ta cần công cụ để đo lường mức chiếm dụng bộ nhớ của đoạn mã trên. Để có thể xây dựng công cụ này, ta sử dụng lệnh ``ps`` của hệ điều hành (chỉ hoạt động trên Linux và macOS). Để phân tích chi tiết hoạt động của đoạn mã trên, bạn có thể sử dụng `Nsight `__ của Nvidia hoặc `vTune `__ của Intel. .. code:: python def get_mem(): res = subprocess.check_output(['ps', 'u', '-p', str(os.getpid())]) return int(str(res).split()[15]) / 1e3 .. raw:: html Trước khi bắt đầu kiểm tra, ta cần khởi tạo các tham số của mạng và xử lý một batch. Nếu không, việc kiểm tra dung lượng bộ nhớ sử dụng thêm sẽ là khá rắc rối. Bạn đọc có thể tham khảo :numref:`sec_deferred_init` để hiểu rõ chi tiết việc khởi tạo. .. code:: python for X, y in data_iter(): break loss(y, net(X)).wait_to_read() .. raw:: html Để đảm bảo bộ đệm tác vụ tại back-end không bị tràn, ta chèn phương thức ``wait_to_read`` vào back-end cho hàm mất mát ở cuối mỗi vòng lặp. Điều này buộc mỗi lượt truyền xuôi phải hoàn thành trước khi lượt truyền xuôi tiếp theo được bắt đầu. Chú ý rằng có một phương án thay thế khác (có lẽ tinh tế hơn) là theo dõi lượng mất mát ở biến vô hướng và buộc đi qua một lớp chặn (*barrier*) qua việc gọi phương thức ``item``. .. code:: python mem = get_mem() with d2l.Benchmark('time per epoch'): for X, y in data_iter(): with autograd.record(): l = loss(y, net(X)) l.backward() trainer.step(X.shape[0]) l.wait_to_read() # Barrier before a new batch npx.waitall() print(f'increased memory: {get_mem() - mem:f} MB') .. parsed-literal:: :class: output batch 50, time 1.4251 sec batch 100, time 2.8567 sec batch 150, time 4.2812 sec time per epoch: 4.2966 sec increased memory: 21.844000 MB .. raw:: html Như ta có thể thấy, thời gian thực hiện từng minibatch khá khớp so với tổng thời gian chạy của đoạn mã tối ưu. Hơn nữa, lượng bộ nhớ sử dụng tăng không đáng kể. Giờ hãy cùng xem chuyện gì sẽ xảy ra nếu ta bỏ lớp chặn ở cuối mỗi minibatch. .. code:: python mem = get_mem() with d2l.Benchmark('time per epoch'): for X, y in data_iter(): with autograd.record(): l = loss(y, net(X)) l.backward() trainer.step(X.shape[0]) npx.waitall() print(f'increased memory: {get_mem() - mem:f} MB') .. parsed-literal:: :class: output batch 50, time 0.0534 sec batch 100, time 0.1061 sec batch 150, time 0.1598 sec time per epoch: 4.2995 sec increased memory: 2.484000 MB .. raw:: html Mặc dù thời gian để đưa ra chỉ dẫn cho back-end nhỏ hơn đến hàng chục lần, ta vẫn cần thực hiện các bước tính toán. Hậu quả là một lượng lớn các kết quả trung gian không được đưa ra sử dụng và có thể chất đống trong bộ nhớ. Dù rằng việc này không gây ra bất cứ vấn đề nào trong ví dụ nhỏ trên, nó có thể dẫn đến tình trạng cạn kiệt bộ nhớ nếu không được kiểm tra trong viễn cảnh thực tế. Tóm tắt ------- .. raw:: html - MXNet tách riêng khối front-end Python khỏi khối back-end thực thi. Điều này cho phép nhanh chóng chèn các câu lệnh một cách bất đồng bộ vào khối back-end và kết hợp tính toán song song. - Sự bất đồng bộ giúp front-end phản ứng nhanh hơn. Tuy nhiên, cần phải áp dụng cẩn thận để không làm tràn các tác vụ ở trạng thái đợi, gây chiếm dụng bộ nhớ. - Nên đồng bộ theo từng minibatch một để giữ cho front-end và back-end được đồng bộ tương đối. - Nên nhớ rằng việc chuyển quản lý bộ nhớ từ MXNet sang Python sẽ buộc back-end phải chờ cho đến khi biến đó sẵn sàng. ``print``, ``asnumpy`` và ``item`` đều gây ra hiệu ứng trên. Điều này có thể có ích đôi lúc, tuy nhiên lạm dụng chúng có thể làm sụt giảm hiệu năng. - Nhà sản xuất vi xử lý cung cấp các công cụ phân tích hiệu năng tinh vi, cho phép đánh giá hiệu năng của học sâu một cách chi tiết hơn rất nhiều. Bài tập ------- .. raw:: html 1. Như đã đề cập ở trên, sử dụng tính toán bất đồng bộ có thể giảm tổng thời gian cần thiết để thực hiện :math:`1000` phép tính xuống :math:`t_1 + 1000 t_2 + t_3`. Tại sao ở đó ta lại phải giả sử :math:`1000 t_2 > 999 t_1`? 2. Bạn có thể chỉnh sửa vòng lặp huấn luyện như thế nào nếu muốn xử lý 2 batch cùng lúc (đảm bảo batch :math:`b_t` hoàn thành trước khi batch :math:`b_{t+2}` bắt đầu)? 3. Chuyện gì sẽ xảy ra nếu thực thi mã nguồn đồng thời trên cả CPU và GPU? Liệu có nên tiếp tục đồng bộ sau khi xử lý mỗi minibatch? 4. So sánh sự khác nhau giữa ``waitall`` và ``wait_to_read``. Gợi ý: thực hiện một số lệnh và đồng bộ theo kết quả trung gian. Thảo luận --------- - `Tiếng Anh - MXNet `__ - `Tiếng Việt `__ Những người thực hiện --------------------- Bản dịch trong trang này được thực hiện bởi: - Đoàn Võ Duy Thanh - Đỗ Trường Giang - Nguyễn Văn Cường - Phạm Minh Đức - Nguyễn Lê Quang Nhật - Phạm Hồng Vinh - Lê Khắc Hồng Phúc