.. 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