12.1. Trình biên dịch và Trình thông dịch¶
Cho đến nay, ta mới chỉ tập trung vào lập trình mệnh lệnh, kiểu lập
trình sử dụng các câu lệnh như print
, +
hay if
để thay đổi
trạng thái của chương trình. Hãy cùng xét ví dụ đơn giản sau về lập
trình mệnh lệnh.
10
Python là một ngôn ngữ thông dịch. Khi thực hiện hàm fancy_func
, nó
thực thi các lệnh trong thân hàm một cách tuần tự. Như vậy, nó sẽ chạy
lệnh e = add(a, b)
rồi sau đó lưu kết quả vào biến e
, làm cho
trạng thái chương trình thay đổi. Hai câu lệnh tiếp theo
f = add(c, d)
và g = add(e, f)
sẽ được thực thi tương tự, thực
hiện phép cộng và lưu kết quả vào các biến.
Fig. 12.1.1 sẽ minh họa luồng dữ liệu.
Fig. 12.1.1 Luồng dữ liệu trong lập trình mệnh lệnh.¶
Mặc dù lập trình mệnh lệnh rất thuận tiện, nhưng nó lại không quá hiệu
quả. Ở đây nếu hàm add
được gọi nhiều lần trong fancy_func
,
Python cũng sẽ thực thi ba lần gọi hàm độc lập. Nếu điều này xảy ra, giả
sử trên một GPU (hay thậm chí nhiều GPU), chi phí phát sinh từ trình
thông dịch Python có thể sẽ rất lớn. Hơn nữa, nó sẽ cần phải lưu giá trị
các biến e
và f
cho tới khi tất cả các lệnh trong fancy_func
thực thi xong. Điều này là do ta không biết liệu biến e
và f
có
được sử dụng bởi các phần chương trình khác sau hai lệnh
e = add(a, b)
và f = add(c, d)
nữa hay không.
12.1.1. Lập trình Ký hiệu¶
Lập trình ký hiệu là kiểu lập trình mà ở đó các tính toán thường chỉ được thực hiện một khi chương trình đã được định nghĩa đầy đủ. Cơ chế này được sử dụng trong nhiều framework, bao gồm: Theano, Keras và TensorFlow (hai framework sau đã hỗ trợ lập trình mệnh lệnh). Lập trình ký hiệu thường gồm những bước sau:
- Khai báo các thao tác sẽ được thực thi.
- Biên dịch các thao tác thành chương trình có thể chạy được.
- Thực thi bằng cách cung cấp đầu vào và gọi chương trình đã được biên dịch.
Quy trình trên cho phép chúng ta tối ưu hóa chương trình một cách đáng
kể. Đầu tiên, ta có thể bỏ qua trình thông dịch Python trong nhiều
trường hợp, từ đó loại bỏ được vấn đề nghẽn cổ chai có thể ảnh hưởng
nghiêm trọng tới tốc độ tính toán khi sử dụng nhiều GPU tốc độ cao với
một luồng Python duy nhất trên CPU. Thứ hai, trình biên dịch có thể tối
ưu và viết lại mã nguồn thành print((1 + 2) + (3 + 4))
hoặc thậm chí
print(10)
. Điều này hoàn toàn khả thi bởi trình biên dịch có thể
thấy toàn bộ mã nguồn rồi mới dịch sang mã máy. Ví dụ, nó có thể giải
phóng bộ nhớ (hoặc không cấp phát) bất cứ khi nào một biến không còn
được dùng đến. Hoặc nó có thể chuyển toàn bộ mã nguồn thành một đoạn
tương đương. Để hiểu rõ hơn vấn đề, dưới đây ta sẽ thử mô phỏng quá
trình lập trình mệnh lệnh (dựa trên Python).
def add_():
return '''
def add(a, b):
return a + b
'''
def fancy_func_():
return '''
def fancy_func(a, b, c, d):
e = add(a, b)
f = add(c, d)
g = add(e, f)
return g
'''
def evoke_():
return add_() + fancy_func_() + 'print(fancy_func(1, 2, 3, 4))'
prog = evoke_()
print(prog)
y = compile(prog, '', 'exec')
exec(y)
def add(a, b):
return a + b
def fancy_func(a, b, c, d):
e = add(a, b)
f = add(c, d)
g = add(e, f)
return g
print(fancy_func(1, 2, 3, 4))
10
Sự khác biệt giữa lập trình mệnh lệnh (thông dịch) và lập trình ký hiệu như sau:
- Lập trình mệnh lệnh dễ hơn. Khi lập trình mệnh lệnh được sử dụng trong Python, mã nguồn trông rất trực quan và dễ viết. Mã nguồn của lập trình mệnh lệnh cũng dễ gỡ lỗi hơn. Điều này là do ta có thể dễ dàng lấy và in ra giá trị của các biến trung gian liên quan, hoặc sử dụng công cụ gỡ lỗi có sẵn của Python.
- Lập trình ký hiệu lại hiệu quả hơn và dễ sử dụng trên nền tảng khác. Nó giúp việc tối ưu mã nguồn trong quá trình biên dịch trở nên dễ dàng hơn, đồng thời cho phép ta chuyển đổi chương trình sang một định dạng khác không phụ thuộc vào Python. Do đó chương trình có thể chạy trong các môi trường khác ngoài Python, từ đó tránh được mọi vấn đề tiềm ẩn về hiệu năng liên quan tới trình thông dịch Python.
12.1.2. Lập trình Hybrid¶
Trong quá khứ, hầu hết các framework đều chọn một trong hai phương án tiếp cận: lập trình mệnh lệnh hoặc lập trình ký hiệu. Ví dụ như Theano, TensorFlow, Keras và CNTK đều xây dựng mô hình dạng ký hiệu. Ngược lại, Chainer và PyTorch tiếp cận theo hướng lập trình mệnh lệnh. Mô hình kiểu mệnh lệnh đã được bổ sung vào TensorFlow 2.0 (thông qua chế độ Eager) và Keras trong những bản cập nhật sau này. Khi thiết kế Gluon, các nhà phát triển đã cân nhắc liệu rằng có thể kết hợp ưu điểm của cả hai mô hình lập trình lại với nhau hay không. Điều này đã dẫn đến mô hình hybrid, giúp người dùng phát triển và gỡ lỗi bằng lập trình mệnh lệnh thuần, đồng thời mang lại khả năng chuyển đổi hầu như toàn bộ chương trình sang dạng ký hiệu khi cần triển khai thành sản phẩm với hiệu năng tính toán cao.
Trong thực tiễn, ta sẽ xây dựng mô hình bằng lớp HybridBlock
hoặc
HybridSequential
và HybridConcurrent
. Mặc định, chúng được thực
thi giống hệt như cách lớp Block
hoặc Sequential
và
Concurrent
được thực thi trong kiểu lập trình mệnh lệnh.
HybridSequential
là một lớp con của HybridBlock
(cũng như
Sequential
là lớp con của Block
). Khi hàm hybridize
được
gọi, Gluon biên dịch mô hình thành định dạng được dùng trong lập trình
ký hiệu. Điều này cho phép ta tối ưu các thành phần nặng về mặt tính
toán mà không cần có nhiều thay đổi trong cách lập trình mô hình. Chúng
tôi sẽ minh hoạ lợi ích của việc này ở ví dụ bên dưới, tập trung vào các
mô hình Sequential
và Block
(mô hình Concurrent
cũng sẽ hoạt
động tương tự).
12.1.3. HybridSequential¶
Cách đơn giản nhất để hiểu cách hoạt động của phép hybrid hóa là xem xét
các mạng sâu đa tầng. Thông thường, trình thông dịch Python sẽ thực thi
mã nguồn cho tất cả các tầng để sinh một lệnh mà sau đó có thể được
truyền tới CPU hoặc GPU. Đối với thiết bị tính toán đơn (và nhanh), quá
trình trên không gây ra vấn đề lớn nào cả. Mặt khác, nếu ta sử dụng một
máy chủ tiên tiến có 8 GPU, ví dụ như P3dn.24xlarge trên AWS, Python sẽ
gặp khó khăn trong việc tận dụng tất cả các GPU cùng lúc. Lúc này trình
thông dịch Python đơn luồng sẽ trở thành nút nghẽn cổ chai. Hãy xem làm
thế nào để giải quyết vấn đề trên cho phần lớn đoạn mã nguồn bằng cách
thay Sequential
bằng HybridSequential
. Chúng ta bắt đầu với việc
định nghĩa một mạng MLP đơn giản.
from d2l import mxnet as d2l
from mxnet import np, npx
from mxnet.gluon import nn
npx.set_np()
# factory for networks
def get_net():
net = nn.HybridSequential()
net.add(nn.Dense(256, activation='relu'),
nn.Dense(128, activation='relu'),
nn.Dense(2))
net.initialize()
return net
x = np.random.normal(size=(1, 512))
net = get_net()
net(x)
array([[ 0.16526176, -0.14005631]])
Bằng cách gọi hàm hybridize
, ta có thể biên dịch và tối ưu hóa các
tính toán trong MLP. Kết quả tính toán của mô hình vẫn không thay đổi.
array([[ 0.16526176, -0.14005631]])
Điều này có vẻ tốt đến mức khó tin: chỉ cần ta chỉ định một khối trở
thành HybridSequential
, sử dụng đoạn mã y hệt như trước và gọi hàm
hybridize
. Một khi thực hiện xong những việc trên, mạng sẽ được tối
ưu hóa (chúng ta sẽ đánh giá hiệu năng ở phía dưới). Tiếc là cách này
không hoạt động với mọi tầng. Nhưng các khối được cung cấp sẵn bởi Gluon
mặc định được kế thừa từ lớp HybridBlock
và do đó có thể hybrid hóa
được. Tầng kế thừa từ lớp Block
sẽ không thể tối ưu hóa được.
12.1.3.1. Tăng tốc bằng Hybrid hóa¶
Để minh hoạ những cải thiện đạt được từ quá trình biên dịch, ta hãy so
sánh thời gian cần thiết để đánh giá net(x)
trước và sau phép hybrid
hóa. Đầu tiên hãy định nghĩa một hàm để đo thời gian trên. Hàm này sẽ
hữu ích trong suốt chương này khi chúng ta đo (và cải thiện) hiệu năng.
Bây giờ ta có thể gọi mạng hai lần với có hybrid hóa và không hybrid hóa.
Without hybridization: 0.6715 sec
With hybridization: 0.2874 sec
Như quan sát được trong các kết quả trên, sau khi thực thể
HybridSequential gọi hàm hybridize
, hiệu năng tính toán được cải
thiện thông qua việc sử dụng lập trình ký hiệu.
12.1.3.2. Chuỗi hóa¶
Một trong những lợi ích của việc biên dịch các mô hình là ta có thể
chuỗi hóa (serialize) mô hình và các tham số mô hình để lưu trữ. Điều
này cho phép ta lưu trữ mô hình mà không phụ thuộc vào ngôn ngữ
front-end. Điều này cũng cho phép ta sử dụng các mô hình đã huấn luyện
trên các thiết bị khác và dễ dàng sử dụng các ngôn ngữ lập trình
front-end khác. Đồng thời, mã nguồn này thường thực thi nhanh hơn so với
khi lập trình mệnh lệnh. Hãy xem xét phương thức export
sau.
-rw-r--r-- 1 hnguyent Imod 643K Aug 13 01:47 my_mlp-0000.params
-rw-r--r-- 1 hnguyent Imod 3.0K Aug 13 01:47 my_mlp-symbol.json
Mô hình này được chia ra thành một tập tin (nhị phân) lớn chứa tham số và tập tin JSON mô tả cấu trúc mô hình. Các tập tin có thể được đọc bởi các ngôn ngữ front-end khác được hỗ trợ bởi Python hoặc MXNet, ví dụ như C++, R, Scala, và Perl. Tập tin JSON có dạng như sau
{
"nodes": [
{
"op": "null",
"name": "data",
"inputs": []
},
{
"op": "null",
"name": "dense3_weight",
Mọi thứ trở nên phức tạp hơn một chút khi làm việc với các mô hình gần với mã nguồn. Về cơ bản, việc hybrid hóa cần giải quyết trực tiếp luồng điều khiển và các chi phí tính toán của Python.
Hơn nữa, trong khi thực thể của lớp Block cần sử dụng hàm forward
,
thực thể của lớp HybridBlock lại sử dụng hàm hybrid_forward
.
Trên đây chúng ta thấy rằng phương thức hybridize
có thể giúp mô
hình đạt được hiệu năng tính toán và tính cơ động vượt trội hơn. Dù vậy,
sự hybrid hóa có thể ảnh hưởng tới tính linh hoạt của mô hình, đặc biệt
là trong điều khiển luồng. Ta sẽ minh họa cách thiết kế các mô hình tổng
quát hơn cũng như cách trình biên dịch loại bỏ các thành phần thừa trong
Python.
class HybridNet(nn.HybridBlock):
def __init__(self, **kwargs):
super(HybridNet, self).__init__(**kwargs)
self.hidden = nn.Dense(4)
self.output = nn.Dense(2)
def hybrid_forward(self, F, x):
print('module F: ', F)
print('value x: ', x)
x = F.npx.relu(self.hidden(x))
print('result : ', x)
return self.output(x)
Đoạn mã trên biểu diễn một mạng đơn giản với 4 nút ẩn và 2 đầu ra.
Phương thức hybrid_foward
lấy thêm một đối số - mô-đun F
. Đối số
này là cần thiết để chọn thư viện xử lý phù hợp (ndarray
hoặc
symbol
) tùy vào việc chương trình có được hybrid hóa hay không. Cả
hai lớp này thực hiện các chức năng rất giống nhau và MXNet sẽ tự động
xác định đối số đầu vào. Để hiểu chuyện gì đang diễn ra chúng ta sẽ in
các đối số đầu vào khi gọi hàm.
module F: <module 'mxnet.ndarray' from '/master/home/hnguyent/miniconda3/envs/d2lenv/lib/python3.7/site-packages/mxnet/ndarray/__init__.py'>
value x: [[-0.6338663 0.40156594 0.46456942]]
result : [[0.01641375 0. 0. 0. ]]
array([[0.00097611, 0.00019453]])
Lặp lại nhiều lần việc tính lượt truyền xuôi sẽ cho ra cùng kết quả (ta
bỏ qua chi tiết). Bây giờ hãy xem chuyện gì xảy ra nếu ta kích hoạt
phương thức hybridize
.
module F: <module 'mxnet.symbol' from '/master/home/hnguyent/miniconda3/envs/d2lenv/lib/python3.7/site-packages/mxnet/symbol/__init__.py'>
value x: <_Symbol data>
result : <_Symbol hybridnet0_relu0>
array([[0.00097611, 0.00019453]])
Thay vì ndarray
, lúc này ta sử dụng mô-đun symbol
cho F
.
Thêm vào đó, mặc dù đầu vào thuộc kiểu ndarray
, dữ liệu truyền qua
mạng bây giờ được chuyển thành kiểu symbol
như một phần của quá
trình biên dịch. Việc gọi lại hàm net
dẫn tới một kết quả đáng kinh
ngạc:
array([[0.00097611, 0.00019453]])
hybrid_forward
đều bị bỏ qua. Thật
vậy, sau khi hybrid hóa, việc thực thi lệnh net(x)
không còn liên
quan gì tới trình thông dịch của Python nữa. Nghĩa là bất cứ đoạn mã
Python nào không cần thiết cho tính toán sẽ bị bỏ qua (chẳng hạn như
các lệnh in) để việc thực thi trôi chảy hơn và hiệu năng tốt hơn. Và
thay vì gọi Python, MXNet gọi trực tiếp back-end C++.symbol
(như asnumpy
) và các toán tử thực thi tại chỗ
(in-place) như a += b
và a[:] = a + b
phải được viết lại là
a = a + b
. Tuy nhiên, việc biên dịch mô hình vẫn đáng để thực hiện
bất cứ khi nào ta quan tâm đến tốc độ. Lợi ích về tốc độ này có thể
tăng từ vài phần trăm tới hơn hai lần, tùy thuộc vào sự phức tạp của
mô hình, tốc độ của CPU, tốc độ và số lượng GPU.12.1.4. Tóm tắt¶
- Lập trình mệnh lệnh khiến việc thiết kế mô hình mới dễ dàng hơn vì ta có thể viết mã với luồng điều khiển và được sử dụng hệ sinh thái phần mềm của Python.
- Lập trình ký hiệu đòi hỏi chúng ta định nghĩa và biên dịch chương trình trước khi thực thi nó. Lợi ích là hiệu năng được cải thiện.
- MXNet có thể kết hợp những ưu điểm của cả hai phương pháp khi cần thiết.
- Mô hình được xây dựng bởi các lớp
HybridSequential
vàHybridBlock
có thể chuyển đổi các chương trình mệnh lệnh thành các chương trình ký hiệu bằng cách gọi phương thứchybridize
.
12.1.5. Bài tập¶
- Hãy thiết kế một mạng bằng cách sử dụng lớp
HybridConcurrent
, có thể thử với GoogleNet trong :ref:sec_googlenet
. - Hãy thêm
x.asnumpy()
vào dòng đầu tiên của hàmhybrid_forward
trong lớp HybridNet, rồi thực thi mã nguồn và quan sát các lỗi bạn gặp phải. Tại sao các lỗi này xảy ra? - Điều gì sẽ xảy ra nếu ta thêm luồng điều khiển, cụ thể là các lệnh
Python
if
vàfor
trong hàmhybrid_forward
? - Hãy lập trình các mô hình bạn thích trong các chương trước bằng cách sử dụng lớp HybridBlock hoặc HybridSequential.