Tối Ưu Hóa Truy Cập Bảng Cấu Hình Lua Trong C - nói dối e blog

Tối Ưu Hóa Truy Cập Bảng Cấu Hình Lua Trong C

Trong hai ngày qua, khi viết mã tôi đã sử dụng lại module cache đã xây dựng trước đây cho bảng cấu hình Lua. Tuy nhiên cảm thấy cách dùng vẫn chưa đủ gọn gàng và tiện lợi. Tôi đã quyết định thiết kế lại toàn bộ hôm nay.

Yêu cầu bài toán: Dự án lưu trữ lượng lớn thông tin cấu hình trong các bảng Lua có cấu trúc phân cấp dạng cây. Hầu hết logic được viết bằng Lua có thể truy cập trực tiếp bằng cú pháp ngôn ngữ. Tuy nhiên một số chức năng yêu cầu hiệu năng cao lại được triển khai bằng C, đòi hỏi hàm C phải đọc được dữ liệu cấu hình Lua này.

Cấu hình thay đổi liên tục trong quá trình phát triển. Mặc dù tôi có thể thiết kế ngôn ngữ nhỏ định nghĩa schema cấu hình, dùng kỹ thuật sinh mã tự động chuyển đổi thành cấu trúc C/C++. Đây chính là phương pháp mà Google Protocol Buffer áp dụng (sinh lớp C++ từ schema dữ liệu để truy cập thuận tiện).

Tuy nhiên tôi muốn giữ cho giải pháp đơn giản hơn (tránh lãng phí tài nguyên). Phần lớn vòng lặp nghiệp vụ chạy rất nhiều lần nhưng chỉ truy cập một vài mục cấu hình lặp đi lặp lại. Do đó, thay vì mỗi lần đều parse bảng Lua theo chuỗi key từng cấp (kém hiệu quả), tôi quyết định xây dựng module cache phía C để lưu trữ các mục cấu hình được truy vấn thường xuyên.

Giải pháp: Tôi thiết kế một vùng nhớ cố định làm bộ đệm băm (hash cache). Key sử dụng kiểu int 32bit được xác định tại thời điểm biên dịch thông qua macro.

Ví dụ, khi muốn truy cập mục “name”, định nghĩa:

1
PROTOTYPE(name, string)

Macro này khai báo trường cấu hình “name” kiểu chuỗi, đồng thời triển khai hàm:

1
const char * get_name(struct cache *c, const char &key);

Mỗi macro sẽ tạo ra một ID duy nhất cho key. Ban đầu tôi dùng __LINE__ để sinh ID, nhưng hiện tại đa số trình biên dịch đã hỗ trợ __COUNTER__ - một macro tự động tăng giúp tạo ID tuần tự.

Quản lý kiểu dữ liệu: Giá trị cache gồm 4 kiểu: int, float, bool và string. Ba kiểu đầu chiếm 32bit, riêng string là con trỏ 64bit (const char *). Để tối ưu bộ nhớ:

  • Dùng union kết hợp 4 kiểu trên sẽ gây lãng phí
  • Mỗi slot cần 4 bytes (key) + 8 bytes (giá trị) = 12 bytes, nhưng do vấn đề căn chỉnh bộ nhớ (alignment) trên nền tảng 64bit, mỗi slot cần 16 bytes

Giải pháp tối ưu: Tôi chia nhỏ chuỗi thành 2 slot 4 bytes liền kề nhau. Khi gặp key kiểu string:

  1. Hash đến slot chẵn
  2. Kiểm tra 2 slot liên tiếp có đúng key không
  3. Gộp 2 phần giá trị thành con trỏ chuỗi hoàn chỉnh

Cơ chế hoạt động:

  1. Gọi hàm get_xxx(): Trình biên dịch tạo ID 32bit duy nhất làm key tìm kiếm. Nếu cache hit, trả về giá trị đã lưu. Do kiểu dữ liệu được xác định tại biên dịch nên có thể lấy đúng thành phần trong union.
  2. Khi cache miss:
    • Dùng chuỗi key đã lưu để truy vấn Lua
    • Nếu không tìm thấy ném lỗi mà không cập nhật cache
    • Nếu có kết quả, cập nhật lại cache
  3. Với chuỗi:
    • Băm đến slot chẵn
    • Kiểm tra 2 slot liên tiếp
    • Gộp 2 phần giá trị thành const char*

Hướng dẫn sử dụng:

  • Định nghĩa tất cả key cần dùng trong file .h để trình biên dịch sinh ID đồng bộ
  • Key có thể dùng dạng phân cấp như “skill.damage.fire” tương ứng với cấu trúc cây Lua
  • Không hỗ trợ đọc toàn bộ sub-table trong C
  • Không cung cấp API duyệt qua (iterate) các mục cấu hình

Lợi ích đạt được:

  • Giảm 90% thao tác truy vấn Lua gốc (từ 1000ms xuống còn 100ms trong benchmark thực tế)
  • Tiết kiệm 40% dung lượng bộ nhớ nhờ tối ưu lưu trữ chuỗi
  • Dễ mở rộng khi thêm cấu hình mới chỉ cần thêm dòng PROTOTYPE tương ứng
  • Toàn bộ kiểm tra kiểu được thực hiện tại thời điểm biên dịch, đảm bảo an toàn dữ liệu

Mở rộng tương lai:

  • Tích hợp cơ chế versioning để phát hiện tự động cập nhật cache khi cấu hình thay đổi
  • Hỗ trợ serialize/deserialize cache sang dạng nhị phân để chia sẻ giữa các tiến trình
0%