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.

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

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

../_images/computegraph.svg

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 ef 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 ef 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)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:

  1. Khai báo các thao tác sẽ được thực thi.
  2. Biên dịch các thao tác thành chương trình có thể chạy được.
  3. 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 HybridSequentialHybridConcurrent. Mặc định, chúng được thực thi giống hệt như cách lớp Block hoặc SequentialConcurrent đượ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 SequentialBlock (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.

net.hybridize()
net(x)
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.

#@save
class Benchmark:
    def __init__(self, description='Done'):
        self.description = description

    def __enter__(self):
        self.timer = d2l.Timer()
        return self

    def __exit__(self, *args):
        print(f'{self.description}: {self.timer.stop():.4f} sec')

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.

net = get_net()
with Benchmark('Without hybridization'):
    for i in range(1000): net(x)
    npx.waitall()

net.hybridize()
with Benchmark('With hybridization'):
    for i in range(1000): net(x)
    npx.waitall()
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.

net.export('my_mlp')
!ls -lh my_mlp*
-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

!head my_mlp-symbol.json
{
  "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.

net = HybridNet()
net.initialize()
x = np.random.normal(size=(1, 3))
net(x)
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.

net.hybridize()
net(x)
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:

net(x)
array([[0.00097611, 0.00019453]])
Điều này khá khác biệt so vớinhững gì ta đã thấy trước đó. Tất cả các lệnh in được định nghĩa trong 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++.
Cũng nên lưu ý rằng một số hàm không được hỗ trợ trong mô-đun symbol (như asnumpy) và các toán tử thực thi tại chỗ (in-place) như a += ba[:] = 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 HybridSequentialHybridBlock 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ức hybridize.

12.1.5. Bài tập

  1. 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.
  2. Hãy thêm x.asnumpy() vào dòng đầu tiên của hàm hybrid_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?
  3. Đ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 iffor trong hàm hybrid_forward?
  4. 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.

12.1.6. Thảo luận

12.1.6.1. 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
  • Nguyễn Văn Tâm
  • Phạm Hồng Vinh
  • Lê Khắc Hồng Phúc
  • Nguyễn Văn Quang
  • Nguyễn Mai Hoàng Long
  • Phạm Minh Đức
  • Nguyễn Văn Cường