id: version-3.1.0_Viet-graph title: Model original_id: graph

Forward và backward propagation trong mạng thần kinh nhân tạo (neural network) có thể sử dụng một tập hợp các hàm như convolution và pooling. Mỗi hàm nhận một vài input tensors và áp dụng một operator để tạo output tensors. Bằng việc thể hiện mỗi operator là một node và mỗi tensor là một edge, tất cả dạng hàm tạo thành một computational graph. Với computational graph, tối ưu hoá tốc độ và bộ nhớ có thể được tiến hành bởi việc đưa vào thực hiện việc phân bổ/giải phóng bộ nhớ và thao tác một cách hợp lý. Trong SINGA, người dùng chỉ cần xác định neural network model sử dụng API của hàm Model. Graph được xây dựng và tối ưu hoá ở C++ phía sau một cách tự động.

Theo đó, một mặt người dùng thực hiện network sử dụng API của hàm Model tuân theo phong cách lập trình bắt buộc như PyTorch. Có điều khác với PyTorch phải tái tạo lại các thao tác ở mỗi vòng lặp, SINGA buffer các thao tác để tạo computational graph một cách đầy đủ (khi tính năng này được kích hoạt) sau vòng lặp đầu tiên. Do đó, mặt khác, SINGA có computational graph giống như được tạo bởi các libraries sử dụng lập trình khai báo (declarative programming), như TensorFlow. Nên nó được tối ưu hoá qua graph.

Ví Dụ

Mã code sau mô phỏng việc sử dụng API của hàm Model.

  1. Áp dụng model mới như một tập con của Model class.
class CNN(model.Model):

    def __init__(self, num_classes=10, num_channels=1):
        super(CNN, self).__init__()
        self.conv1 = layer.Conv2d(num_channels, 20, 5, padding=0, activation="RELU")
        self.conv2 = layer.Conv2d(20, 50, 5, padding=0, activation="RELU")
        self.linear1 = layer.Linear(500)
        self.linear2 = layer.Linear(num_classes)
        self.pooling1 = layer.MaxPool2d(2, 2, padding=0)
        self.pooling2 = layer.MaxPool2d(2, 2, padding=0)
        self.relu = layer.ReLU()
        self.flatten = layer.Flatten()
        self.softmax_cross_entropy = layer.SoftMaxCrossEntropy()

    def forward(self, x):
        y = self.conv1(x)
        y = self.pooling1(y)
        y = self.conv2(y)
        y = self.pooling2(y)
        y = self.flatten(y)
        y = self.linear1(y)
        y = self.relu(y)
        y = self.linear2(y)
        return y

    def train_one_batch(self, x, y):
        out = self.forward(x)
        loss = self.softmax_cross_entropy(out, y)
        self.optimizer(loss)
        return out, loss
  1. Tạo một instance cho model, optimizer, device, v.v. Compile model đó
model = CNN()

# khởi tạo optimizer và đính nó vào model
sgd = opt.SGD(lr=0.005, momentum=0.9, weight_decay=1e-5)
model.set_optimizer(sgd)

# khởi tạo device
dev = device.create_cuda_gpu()

# input và target placeholders cho model
tx = tensor.Tensor((batch_size, 1, IMG_SIZE, IMG_SIZE), dev, tensor.float32)
ty = tensor.Tensor((batch_size, num_classes), dev, tensor.int32)

# compile model trước khi training
model.compile([tx], is_train=True, use_graph=True, sequential=False)
  1. Train model theo vòng lặp
for b in range(num_train_batch):
    # tạo mini-batch tiếp theo
    x, y = ...

    # Copy dữ liệu vào input tensors
    tx.copy_from_numpy(x)
    ty.copy_from_numpy(y)

    # Training với một batch
    out, loss = model(tx, ty)

Ví dụ này có trên Google Colab notebook tại đây.

Các ví dụ khác:

Thực Hiện

Xây Dựng Graph

SINGA tạo computational graph qua 3 bước:

  1. Buffer các thao tác
  2. Phân tích hoạt động các thư viện sử dụng trong dự án (dependencies)
  3. Tạo nodes và edges dựa trên dependencies

Sử dụng phép nhân ma trận từ dense layer của MLP model làm ví dụ. Quá trình này gọi là hàm forward function của class MLP

class MLP(model.Model):

    def __init__(self, data_size=10, perceptron_size=100, num_classes=10):
        super(MLP, self).__init__()
        self.linear1 = layer.Linear(perceptron_size)
        ...

    def forward(self, inputs):
        y = self.linear1(inputs)
        ...

Layer Linear tạo thành từ phép tính mutmul. autograd áp dụng phép matmul bằng cách gọi hàm Mult được lấy từ CPP qua SWIG.

# áp dụng matmul()
singa.Mult(inputs, w)

Từ phía sau, hàm Mult function được áp dụng bằng cách gọi GEMV, là một hàm CBLAS. thay vì gọi hàm GEMV trực tiếp, Mult gửi đi GEMV và đối số (argument) tới thiết bị (device) như sau,

// Áp dụng Mult()
C->device()->Exec(
    [a, A, b, B, CRef](Context *ctx) mutable {
        GEMV<DType, Lang>(a, A, B, b, &CRef, ctx);
    },
    read_blocks, {C->block()});

Hàm Exec function của Device buffer hàm này và các đối số của nó. Thêm vào đó, nó cũng có thông tin về các block (một block là một đoạn bộ nhớ cho một tensor) để đọc và viết bởi hàm này.

Sau khi Model.forward() được thực hiện xong một lần, tất cả quá trình được buffer bởi Device. Tiếp theo, thông tin đọc/viết của tất cả quá trình sẽ được phân tích để tạo computational graph. Ví dụ, nếu một block b được viết bởi quá trình 01 và sau đó được đọc bởi quá trình 02 khác, chúng ta sẽ biết 02 là dựa vào 01 và có edge trực tiếp từ A sang B, thể hiện qua block b (hoặc tensor của nó). Sau đó một graph không tuần hoàn sẽ được tạo ra như dưới đây. Graph chỉ được tạo ra một lần.

Computational graph của MLP


Sơ đồ 1 - Ví dụ Computational graph của MLP.

Tối Ưu Hoá

Hiện nay, các tối ưu hoá sau được thực hiện dựa trên computational graph.

Phân bổ thụ động (Lazy allocation) Khi tensor/blocks được tạo ra, các thiết bị (devices) không phân bổ bộ nhớ cho chúng ngay lập tức. Thay vào đó, khi block được tiếp cận lần đầu tiên, bộ nhớ sẽ được phân bổ.

Tự động tái sử dụng (Automatic recycling) Đếm số của mỗi tensor/block được tính dựa trên graph. Trước khi thực hiện quá trình nào, đếm số là số lượng hàm đọc block này. Trong quá trình thực hiện, khi một hàm nào được tiến hành, đếm số của mỗi block đầu vào bị trừ đi 1. Nếu đếm số của một block bằng 0, thì block này sẽ không được đọc lại nữa trong toàn bộ quá trình còn lại. Bởi vậy, bộ nhớ của nó được giải phóng một cách an toàn. Thêm vào đó, SINGA theo dõi việc sử dụng block bên ngoài graph. Nếu block được sử dụng bởi mã code Python (không phải các hàm autograd), nó sẽ không được tái sử dụng.

Chia sẻ bộ nhớ SINGA sử dụng memory pool, như là CnMem để quản lý bộ nhớ CUDA. Với Automatic recycling và memory pool, SINGA có thể chia sẻ bộ nhớ giữa các tensor. Xem xét hai hàm c = a + bd=2xc. Trước khi thực hiện hàm thứ hai, theo như Lazy allocation thì bộ nhớ của d nên được sử dụng. Cũng như a không được sử dụng ở toàn bộ quá trình còn lại. Theo Tự động sử dụng (Automatic recycling), block của a sẽ được giải phóng sau hàm đầu tiên. Vì thế, SINGA sẽ đề xuất bốn hàm tới CUDA stream: addition, free a, malloc b, và multiplication. Memory pool sau đó có thể chia sẻ bộ nhớ được a với b giải phóng thay vì yêu cầu GPU thực hiện real malloc cho b.

Các kĩ thuật tối ưu hoá khác, ví dụ từ compliers, như common sub-expression elimination và parallelizing operations trên CUDA streams khác nhau cũng có thể được áp dụng.

Toán Tử (Operator) mới

Mỗi toán tử được định nghĩa trong autograd module áp dụng hai hàm: forward và backward, được thực hiện bằng cách gọi toán tử (operator) từ backend. Để thêm một toán tử mới vào hàm autograd, bạn cần thêm nhiều toán tử ở backend.

Lấy toán tử Conv2d làm ví dụ, từ phía Python, hàm forward và backward được thực hiện bằng cách gọi các toán tử từ backend dựa trên loại device.

class _Conv2d(Operation):

    def forward(self, x, W, b=None):
        ......
        if training:
            if self.handle.bias_term:
                self.inputs = (x, W, b) # ghi chép x, W, b
            else:
                self.inputs = (x, W)

        if (type(self.handle) != singa.ConvHandle):
            return singa.GpuConvForward(x, W, b, self.handle)
        else:
            return singa.CpuConvForward(x, W, b, self.handle)

    def backward(self, dy):
        if (type(self.handle) != singa.ConvHandle):
            dx = singa.GpuConvBackwardx(dy, self.inputs[1], self.inputs[0],
                                        self.handle)
            dW = singa.GpuConvBackwardW(dy, self.inputs[0], self.inputs[1],
                                        self.handle)
            db = singa.GpuConvBackwardb(
                dy, self.inputs[2],
                self.handle) if self.handle.bias_term else None
        else:
            dx = singa.CpuConvBackwardx(dy, self.inputs[1], self.inputs[0],
                                        self.handle)
            dW = singa.CpuConvBackwardW(dy, self.inputs[0], self.inputs[1],
                                        self.handle)
            db = singa.CpuConvBackwardb(
                dy, self.inputs[2],
                self.handle) if self.handle.bias_term else None
        if db:
            return dx, dW, db
        else:
            return dx, dW

Mỗi toán tử ở backend nên được thực hiện theo cách sau:

  • Giả dụ toán từ là foo(); khi được thực hiện nên được gói vào trong một hàm khác, như _foo(). foo() chuyển _foo cùng với các đối số như một hàm lambda tới hàm Device's Exec để buffer. Block để đọc và viết cũng được chuyển cho Exec.

  • Tất cả đối số được sử dụng trong hàm lambda expression cần phải được thu thập dựa trên các nguyên tắc sau.

    • thu thập bằng giá trị: Nếu biến đối số (argument variable) là biến local hoặc sẽ được giải phóng ngay (như intermediate tensors). Hoặc, những biến số này sẽ bị loại bỏ khi foo() tồn tại.

    • thu thập theo tham khảo:Nếu biến số được ghi lại từ phía python hoặc một biến bất biến (như tham số W và ConvHand trong Conv2d class).

    • mutable: Biểu thức lambda expression nên có biến thẻ (mutable tag) nếu một biến được thu thập theo giá trị bị thay đổi trong hàm _foo()

Đây là một ví dụ về operator được áp dụng ở backend.

Tensor GpuConvBackwardx(const Tensor &dy, const Tensor &W, const Tensor &x,
                        const CudnnConvHandle &cch) {
  CHECK_EQ(dy.device()->lang(), kCuda);

  Tensor dx;
  dx.ResetLike(x);

  dy.device()->Exec(
      /*
       * dx is a local variable so it's captured by value
       * dy is an intermediate tensor and isn't recorded on the python side
       * W is an intermediate tensor but it's recorded on the python side
       * chh is a variable and it's recorded on the python side
       */
      [dx, dy, &W, &cch](Context *ctx) mutable {
        Block *wblock = W.block(), *dyblock = dy.block(), *dxblock = dx.block();
        float alpha = 1.f, beta = 0.f;
        cudnnConvolutionBackwardData(
            ctx->cudnn_handle, &alpha, cch.filter_desc, wblock->data(),
            cch.y_desc, dyblock->data(), cch.conv_desc, cch.bp_data_alg,
            cch.workspace.block()->mutable_data(),
            cch.workspace_count * sizeof(float), &beta, cch.x_desc,
            dxblock->mutable_data());
      },
      {dy.block(), W.block()}, {dx.block(), cch.workspace.block()});
      /* the lambda expression reads the blocks of tensor dy and w
       * and writes the blocks of tensor dx and chh.workspace
       */

  return dx;
}

Điểm Chuẩn (Benchmark)

Trên một node

  • Thiết lập thí nghiệm
    • Model
    • GPU: NVIDIA RTX 2080Ti
  • Kí hiệu
    • s :giây (second)
    • it : vòng lặp (iteration)
    • Mem:sử dụng bộ nhớ tối đa trong một GPU
    • Throughout:số lượng hình ảnh được xử lý mỗi giây
    • Time:tổng thời gian
    • Speed:vòng lặp mỗi giây
    • Reduction:tốc độ giảm bộ nhớ sử dụng so với sử dụng layer
    • Speedup: tốc độ tăng tốc so với dev branch
  • Kết quả

Đa quá trình (Multi processes)

  • Thiết lập thí nghiệm
    • API
    • GPU: NVIDIA RTX 2080Ti * 2
    • MPI: hai quá trình MPI trên một node
  • Kí hiệu: như trên
  • kết quả

Kết Luận

  • Training với computational graph giúp giảm đáng kể khối bộ nhớ.
  • Hiện tại, tốc độ có cải thiện một chút. Nhiều tối ưu hoá có thể được thực hiện giúp tăng hiệu quả.