11/08/2018, 21:06

IO: Buffer vs non-buffer technique

Introduction Buffering (buffered IO) là một trong những kỹ thuật kinh điển khi chúng ta cần đọc/ghi dữ liệu. Trong bài viết này mình sẽ đi sâu hơn một chút về kỹ thuật này, với mục đích giới thiệu nó tới các bạn chưa biết hoặc cấu hình chưa phù hợp. TL;DR Buffering: Lưu tạm thời data ...

Introduction

Buffering (buffered IO) là một trong những kỹ thuật kinh điển khi chúng ta cần đọc/ghi dữ liệu. Trong bài viết này mình sẽ đi sâu hơn một chút về kỹ thuật này, với mục đích giới thiệu nó tới các bạn chưa biết hoặc cấu hình chưa phù hợp.

TL;DR

Buffering:

  • Lưu tạm thời data nhận được từ một IO Operation trong user space trước khi gửi tới kernel space (trường hợp write) hoặc trước khi chuyển tới process của bạn ( trường hợp read)
  • Tối giản hóa số lần syscall, mình sẽ giải thích cụ thể sau.

Demonstration

Giả sử bạn cần đọc một file CSV chứa 5 triệu dòng raw data và cần xử lý từng dòng để lấy ra các thông tin cần thiết nào đó. Kích thước của một dòng không quá 1KB.

Với 5 triệu dòng, lựa chọn duy nhất của bạn là đọc từng block và xử lý online nếu không muốn đọc tất cả vào bộ nhớ và lấy đi vài GB RAM. Vài GB Ram là không đáng kể so với memory của máy chủ, nhưng nếu muốn đọc cùng lúc vài chục file như vậy thì chắc các bạn cũng tưởng tượng tình hình thê lương ra sao. Đối với các thiết bị embedded, chuyện lưu một file lớn như vậy vào RAM thậm chí là bất khả thi.

Block Processing

Để giải quyết bài toán trên, bạn cần đọc từng block data một cho tới khi EOF.

Ví dụ, đặt BlockSize = 1KB. Lần đầu bạn đọc 1KB, kiếm dấu xuống dòng trong dữ liệu bạn đọc được, phần byte thừa còn lại bạn cần ghép với block sau, và cứ như vậy cho tới khi đọc hết file.

Số lần syscall khoảng OP = FileSize / BlockSize. Trong trường hợp ví dụ chúng ta đang xét, OP vào khoảng 5 triệu.

Để tối giản hóa, ta có thể sử dụng kỹ thuật buffering:

  • Một lần đọc dữ liệu bạn đọc trước nhiều hơn 1KB, chẳng hạn 4KB.
  • Lấy ra 1KB để xử lý và lưu 3KB thừa vào RAM.
  • Lần đọc tiếp theo bạn chỉ cần đọc trực tiếp từ RAM để lấy ra block cần xử lý. Bớt đi 3 OP.
  • Tổng số OP còn khoảng hơn 1 triệu.

PS: nếu muốn bạn có thể processing ngay trên 4KB đọc được, lấy ra nhiều dòng dữ liệu cùng lúc. Tuy nhiên nếu thuật toán xử lý của bạn chỉ phù hợp với dữ liệu 1KB (xử lý trên các thiết bị embedded) thì không cần thiết phải làm mọi chuyện phức tạp lên.

Tại sao cần reduce chỉ số OP trên?

Có rất nhiều lý lẽ, dễ thấy nhất là với trường hợp file của chúng ta nằm trên ổ cứng HDD với tay quay và phiến đĩa.

Để access một offset trên phiến đĩa, HDD cần quay - quay - và point vào offset đó, đọc ra lượng byte nào đó tính từ offset.

Nhưng cái đầu đọc của HDD không giữ nguyên ở đó để bạn đọc tiếp, nó thường bị move around để đọc các file/offset khác do nhu cầu của các ứng dụng khác hoặc chính OS.

Dễ thấy tối thiểu được OP giúp giảm thiểu số lần HDD cần quay quay và point, tuning IO Performance đáng kể.

Benchmark
package main

import (
    "bufio"
    "fmt"
    "io"
    "os"
    "testing"
)

var bigFilePath = "/Volumes/MyData/Isos/highSierra.zip" // 5.2 GB
var readPortionSize = 1024 * 4                          // 4KB block

func openBigFile(b *testing.B) *os.File {
    file, err := os.Open(bigFilePath)
    if err != nil {
        fmt.Println(err)
        b.FailNow()
    }

    return file
}

func Benchmark_ReadNonBuffer(b *testing.B) {
    file := openBigFile(b)
    defer file.Close()

    buf := make([]byte, readPortionSize)
    for {
        if _, e := file.Read(buf); e != nil && e != io.EOF {
            fmt.Println(e)
            b.FailNow()
        } else if e == io.EOF {
            return
        }
    }
}

func Benchmark_ReadWithBuffer_x2(b *testing.B) {
    file := openBigFile(b)
    defer file.Close()

    reader := bufio.NewReaderSize(file, readPortionSize<<1)

    buf := make([]byte, readPortionSize)
    for {
        if _, e := reader.Read(buf); e != nil && e != io.EOF {
            fmt.Println(e)
            b.FailNow()
        } else if e == io.EOF {
            return
        }
    }
}

func Benchmark_ReadWithBuffer_x4(b *testing.B) {
    file := openBigFile(b)
    defer file.Close()

    reader := bufio.NewReaderSize(file, readPortionSize<<2)

    buf := make([]byte, readPortionSize)
    for {
        if _, e := reader.Read(buf); e != nil && e != io.EOF {
            fmt.Println(e)
            b.FailNow()
        } else if e == io.EOF {
            return
        }
    }
}
Benchmark_ReadNonBuffer-8                      1        51536639906 ns/op
Benchmark_ReadWithBuffer_x2-8                  1        1073528550 ns/op
Benchmark_ReadWithBuffer_x4-8                  2         537319055 ns/op

(Lower ns/op is better)

File highSierra.zipcủa mình ở trên nằm trên ổ cứng HDD gắn ngoài. Cấu hình máy của mình là Macbook Pro 2017 15", full options trừ cái ổ SSD.

Configuration

Đặt buffer size bao nhiêu thì tùy thuộc vào cấu hình phần cứng của bạn. Không phải cứ buffer size lớn sẽ tạo ra hiệu quả. Vì vậy hãy luôn benchmark trực tiếp để có được buffer size phù hợp.

Conclusion

Kết quả benchmark nếu file trên nằm ở ổ SSD sẽ rất khác, không vênh nhau bao nhiêu. Tuy nhiên như bạn biết, phần lớn ổ cứng trên các máy chủ hiện nay là HDD do vấn đề về chi phí.

Vì vậy khi viết ứng dụng nặng về IO, bạn cần hết sức quan tâm tới cấu hình máy chủ, nhất là ở ổ cứng.

Cách tốt nhất là luôn enable buffering trong mỗi đoạn code của bạn. Đối với riêng bài toán xử lý CSV mà mình có đề cập ở trên, các bạn có thể sử dụng bufio.Scanner để đọc line by line. Scanner sẽ tự ghép các block of data để trả về đầy đủ line cho bạn.

Bonus for golang

Deep inside bufio.Reader

func (b *Reader) Read(p []byte) (n int, err error) {
    n = len(p)
    if n == 0 {
        return 0, b.readErr()
    }
    if b.r == b.w {
        if b.err != nil {
            return 0, b.readErr()
        }
        if len(p) >= len(b.buf) {
            // Large read, empty buffer.
            // Read directly into p to avoid copy.
            n, b.err = b.rd.Read(p)
            if n < 0 {
                panic(errNegativeRead)
            }
            if n > 0 {
                b.lastByte = int(p[n-1])
                b.lastRuneSize = -1
            }
            return n, b.readErr()
        }
        // One read.
        // Do not use b.fill, which will loop.
        b.r = 0
        b.w = 0
        n, b.err = b.rd.Read(b.buf)
        if n < 0 {
            panic(errNegativeRead)
        }
        if n == 0 {
            return 0, b.readErr()
        }
        b.w += n
    }

    // copy as much as we can
    n = copy(p, b.buf[b.r:b.w])
    b.r += n
    b.lastByte = int(b.buf[b.r-1])
    b.lastRuneSize = -1
    return n, nil
}

Reader sử dụng 2 index là .r và .w như bạn thấy để kiểm soát bạn đã đọc tới đâu và ghi tới đâu.

Khi b.r == b.w nghĩa là bạn đã đọc full từ buffer và cần đọc thêm block data từ file. Nếu portion bạn cần đọc lớn hơn cả độ lớn của buffer thì sẽ đọc thẳng dữ liệu từ file và lấy ra lượng data <= portion size của bạn. Nếu không sẽ đọc vào buffer và reset lại giá trị của .r và .w.

Khi b.r != b.w nghĩa là tồn tại một lượng data bạn chưa đọc, [b.r : b.w]. Lượng data này sẽ được đẩy vào process của bạn, nhiều nhất có thể MAX(b.w - b.r, len(p)) trong đó p chính là nơi chứa data để process của bạn xử lý. Trường hợp tốt nhất thì sau thao tác này, b.r sẽ bằng b.w và quay về trường hợp trên.

Cứ như vậy cho tới khi bạn đọc hết dữ liệu. Có một điểm lưu ý ở đây là với cách thức đọc dữ liệu như trên, tồn tại những thời điểm mà process của bạn nhận được lượng dữ liệu nhỏ hơn len(p). Do đó:

  • Trong code benchmark có đoạn: n, e := reader.Read(buf). Vì vậy điều kiện n < len(buf) không phải là điều kiện đủ để check EOF.
  • Luôn check EOF và các lỗi phát sinh e != nil && e != io.EOF

Have fun!

0