Size, Stride, Alignment trong Swift

Trong Swift, mỗi kiểu dữ liệu sẽ có 3 thuộc tính: size, stride, alignment.

Size

Giả sử, chúng ta có 2 struct đơn giản sau:

struct Hooman {
    let age: Int
}

struct HoomanWithPuppy {
    let age: Int
    let puppies: Int
}

Nhìn qua chúng ta cũng có thể đoán được một instance của struct HoomanWithPuppy sẽ lớn hơn, chiếm nhiều chỗ hơn trong bộ nhớ hơn của struct Hooman. Nhưng chính xác là lớn hơn bao nhiêu byte?

Chúng ta có thể sử dụng MemoryLayout, một Generic Enumeration để xem một instance của một kiểu dữ liệu sẽ chiếm bao nhiêu byte bộ nhớ.

Để xem size của một kiểu dữ liệu, dùng property size và truyền vào parameter generic là kiểu dữ liệu cần xem. Ví dụ:

let size = MemoryLayout<Hooman>.size

Còn để xem size của một instance đã có, dùng static function size(ofValue:)

let aHooman = Hooman(age: 23)
let size = MemoryLayout.size(ofValue: aHooman)

Cả 2 cách trên đều cho kết quả size = 8 byte.

Và cũng không có gì đáng ngạc nhiên khi size của struct HoomanWithPuppy bằng 16 byte.

Size của một struct nhìn chung khá trực quan, có thể tính toán dựa trên size của các kiểu dữ liệu của mỗi property thành phần. Ví dụ với struct như này:

struct Puppy {
  let weight: Int
  let isTrained: Bool
}

Size của struct Puppy sẽ bằng size của các property:

MemoryLayout<Int>.size + MemoryLayout<Bool>.size
// returns 9, bằng 8 + 1

MemoryLayout<Puppy>.size
// returns 9

Stride

Giả sử, chúng ta có một mảng các chó con Puppy với mỗi instance có size 9 byte. Vậy trong bộ nhớ. mảng Puppy được lưu như thế nào? Có phải trông nó sẽ như này:

Nếu bạn nghĩ như vậy thì bạn đã nhầm. Vì stride là khoảng cách giữa 2 phần tử trong bộ nhớ, lớn hơn hoặc bằng size của nó.

MemoryLayout<Puppy>.size
// returns 9

MemoryLayout<Puppy>.stride
// returns 16

Thực tế thì layout của mảng Puppy sẽ trông như này:

Như vậy có nghĩa là nếu bạn có một con trỏ đang trỏ đến một phần tử và bạn muốn trỏ đến phần tử tiếp theo thì stride chính là số byte cần dịch con trỏ.

Vậy thì tại sao stride và size lại có thể khác nhau. Hãy chuyển sang phần tiếp theo: Alignment.

Alignment

Hãy tưởng tượng bạn có một chiếc máy tính đọc dữ liệu từ bộ nhớ theo từng 8 bit, hay 1 byte. Việc đọc byte số 1 và byte số 7 đều tốn một khoảng thời gian bằng nhau.

Sau đó bạn chuyển sang sử dụng máy tính lên hệ điều hành 16 bit. Lúc này mỗi lần truy xuất dữ liệu, máy tính sẽ đọc theo từng word 2 byte. Giả sử, bạn muốn đọc 2 byte cạnh nhau là byte 0 và byte 1, lúc này máy tính sẽ chỉ cần đọc 1 lần duy nhất word 0 và chia đôi word 16 bit này để lấy dữ liệu. Việc đọc bộ nhớ trong trường hợp này sẽ nhanh gấp 2 lần.

Tuy nhiên, trong trường hợp như này: bạn cần đọc một giá trị 2 byte bắt đầu từ byte thứ 3.

Trường hợp này, việc đọc bộ nhớ nảy sinh vấn đề misaligned. Để đọc được, máy tính sẽ phải đọc 2 lần. Lần 1 đọc word số 1, cắt đôi và lấy giá trị của phần bit sau. Lần 2 đọc word số 2, cắt đôi và lấy giá trị của phần bit trước. Như vậy, để đọc 1 giá trị mà cẫn những 2 lần đọc và thực hiện thêm các bước phức tạp nên sẽ chậm đi 2 lần.

Trong một số hệ thống, việc đọc các giá trị misaligned còn có thể tệ hơn cả việc chậm mà còn gây ra crash chương trình.

Các kiểu dữ liệu nguyên thủy

Trong Swift, các kiểu dữ liệu nguyên thủy như Int hoặc Double có giá trị alignment và size của chúng bằng nhau. Một số nguyên 32 bit (4 byte) sẽ có size bằng 4 byte và được căn chỉnh trong bộ nhớ theo 4 byte.

MemoryLayout<Int32>.size
// returns 4
MemoryLayout<Int32>.alignment
// returns 4
MemoryLayout<Int32>.stride
// returns 4

Giá trị stride bằng 4 nên các bộ đệm liên tiếp sẽ không cần phần bộ nhớ padding.

Các kiểu dữ liệu phức tạp

Giờ chúng ta quay lại xét struct Puppy ở trên. Hay xem xét trường hợp các instance của Puppy được xếp sát liền nhau trong bộ nhớ:

Các giá trị Bool có alignment = 1 nên không gặp vấn đề gì. Nhưng số nguyên thứ 2 của instance Puppy thứ 2 sẽ bị misaligned. Vì nó là một giá trị 64 bit (8 byte) với alignment = 8 nhưng vị trí byte đầu tiên của nó lại không chia hết cho 8.

Ở trên chúng ta biết được rằng stride của struct Puppy bằng 16, nghĩa là layout trong bộ nhớ thật sự sẽ được bố trí như này:

Ở đây, phần bộ nhớ của instance thứ nhất sẽ được thêm 7 byte trống để đảm bảo rằng địa chỉ ô nhớ đầu tiên của instance tiếp theo chia hết cho 8, không bị misaligned.

Đó cũng là lí do tại sao stride của struct lại có thể bằng hoặc lớn hơn size của nó. Là để thêm đủ số byte padding lấp đầy giá trị alignment.

Cách tính alignment và stride

Alignment của một kiểu struct là alignment lớn nhất trong các kiểu dữ liệu thành phần tạo nên nó.

MemoryLayout<Puppy>.alignment
// returns 8

Trở lại với struct Puppy, property isTrained kiểu Bool có alignment = 1, property weight kiểu Int có alignment = 8 => Alignment của struct Puppy = 8.

Và stride sẽ bằng size làm tròn lên sao cho chia hết cho alignment. Ví dụ trong trường hợp này:

  • Size bằng 9.
  • 9 không chia hết cho alignment = 8.
  • Số chia hết cho 8 lớn hơn 9 bằng 16.
  • Vậy giá trị stride = 16.

Tổng kết

  • Size là số lượng byte mà con trỏ cần để đọc được hết toàn bộ dữ liệu của kiểu dữ liệu đó.
  • Stride là số lượng byte mà con trỏ cần dịch chuyển để đọc được giá trị của phần tử tiếp theo trong vùng bộ nhớ liên tiếp (ví dụ mảng).
  • Alignment của một struct là alignment lớn nhất trong các kiểu dữ liệu thành phần của nó để đảm bảo khi hệ điều hành đọc dữ liệu theo các word 16 bit, 32 bit, 64 bit sao cho các thành phần đều được aligned, tránh phải đọc nhiều lần và các thao tác xoay bit, dịch bit phức tạp.

Tiếp tục giả sử có 2 struct sau:

struct Puppy {
  let age: Int
  let isTrained: Bool
} // Int, Bool

struct Kitty { 
  let isTrained: Bool
  let age: Int
} // Bool, Int

Bạn thử đoán xem 2 struct này có size, stride và alignment bằng bao nhiêu?

Nếu câu trả lời của bạn là struct Kitty có size, stride, alignment giống hệt struct Puppy thì bạn đã nhầm. Vì size của Kitty sẽ bằng 16, stride = 16 và alignment vẫn bằng 8.

MemoryLayout<Kitty>.size
// returns 16

Tại sao vậy? Vẫn là struct với 1 giá trị Bool và 1 giá trị Int thôi mà? Tuy nhiên struct Kitty bao gồm 1 Bool đứng trước 1 Int, bạn đã nghĩ layout trong bộ nhớ của nó như này:

Tuy nhiên, như trên thì giá trị nguyên 8 byte sẽ không được aligned. Thực tế thì struct Kitty sẽ trông như này:

Bản thân mỗi struct và các property thành phần của nó phải được aligned. Giá trị Bool đầu tiên thì ok, đã aligned, nhưng sang giá trị Int thứ 2 nếu xếp sát vào Int thì sẽ bị misaligned nên hệ điều hành đã tự động thêm 7 byte làm padding, để đảm bảo giá trị Int cũng được aligned.

Thử đoán các giá trị size, stride, alignment với 2 struct này:

struct Student {
  let age: Int
  let isTrained: Bool
  let isGraduated: Bool
} // Int, Bool, Bool

struct Worker {
  let isTrained: Bool
  let age: Int
  let isGraduated: Bool
} // Bool, Int, Bool

Kết quả đúng:

MemoryLayout<Student>.size // 10
MemoryLayout<Student>.stride // 16
MemoryLayout<Student>.alignment // 8

MemoryLayout<Worker>.size // 17
MemoryLayout<Worker>.stride // 24
MemoryLayout<Worker>.alignment // 8