Vì sao dọn rác xong mà RAM vẫn chẳng xuống?
🌱 mầmgieo chăm #bun#bai-hoc#hieu-nang
Viết cho Ai chạy máy chủ Bun hay JavaScript giữ nhiều dữ liệu trong RAM, đang đo bộ nhớ và thấy con số chẳng chịu xuống.
Mình từng nghĩ muốn bớt RAM thì tìm xem cái gì đang giữ RAM. Tuần này mình mới thấy cái ngốn nhiều nhất có thể chẳng giữ gì hết.
Mấy con số đo lại mình đo chiều 3/11 trên Bun 1.4.4, máy Mac, dùng đúng 277.138 dòng dữ liệu thật. Số trên máy chủ thật lấy từ nhật ký sửa lỗi, mình chưa đo lại được. Phần sửa và phần đo lại đều có AI làm cùng.
English: Why doesn't RAM go down after I collect the garbage?.
Máy chủ ngồi không mà vẫn giữ 2 GB
Có một trang tra cứu văn bản luật mình đang làm, máy chủ của nó khởi động xong là giữ khoảng 2 GB, lúc đông lên tới 2,9 GB. Mục tiêu đặt ra là dưới 1 GB.
Cách đầu tiên ai cũng nghĩ tới là kể tên những món đang giữ bộ nhớ. Tụi mình nạp từng món trong một tiến trình sạch rồi đọc số. Danh sách từ khoá tìm kiếm 637 MB, danh sách văn bản 242 MB, đồ thị liên kết khoảng 250 MB. Mấy món này đều cắt được, và đã cắt: 637 xuống còn 175.
Nhưng cộng hết lại vẫn chưa ra con số thật. Hụt mất cả mảng lớn, chẳng thuộc về món nào.
Thủ phạm lớn nhất chẳng giữ gì
Mảng hụt đó nằm ở một chỉ mục tiêu đề: 277 nghìn dòng tiêu đề điều luật, cắt thành từng cặp từ, ghi lại cặp nào nằm ở dòng nào. Bản thân chỉ mục nhỏ xíu, nhưng lần dựng nó làm tiến trình phình thêm 880 tới 1.188 MB, và phần phình đó ở lại luôn.
Phản xạ của mình là dọn sau khi dựng xong: gọi Bun.gc(true) rồi Bun.shrink(). Nhật ký ghi kết quả là bớt 7 MB. Bộ dọn rác đã dọn xong rồi, dữ liệu sống chỉ còn chừng đó, mà hệ điều hành vẫn thấy tiến trình to nguyên.
Cách sửa ngược lại với phản xạ: đừng cố lấy lại sau khi lên đỉnh, hãy đừng để nó lên đỉnh. Cứ 20 nghìn dòng thì dọn rác một lần, ngay giữa vòng lặp. Đổi lại, lần dựng từ 1,5 giây thành khoảng 2,3 giây, chạy nền, và giữa các mẻ còn nhường chỗ cho request đang chờ.
Mình đo lại thì hụt mất cái đỉnh
Commit message là lời khai, nên mình tính chạy lại cho chắc. Lần đầu mình viết một vòng lặp tạo 1 GB rác rồi đọc số. Kết quả: 22 MB, chẳng thấy gì hết. Cách dọn nào cũng vậy.
Lần hai mình dùng đúng dữ liệu thật và đúng hàm cắt từ, vẫn trong tiến trình sạch. Khác biệt chỉ còn chừng 20 MB, xa lắm mới tới 880.
Lúc đó mình suýt viết rằng nhật ký sai. Rồi mình để ý thứ khác đi: máy chủ thật dựng chỉ mục khi đã giữ cả đống dữ liệu sống, còn tiến trình của mình thì trống. Vậy là mình nạp trước 3,28 triệu object sống cho giống máy chủ, rồi dựng lại. Mỗi dòng dưới đây mình chạy ít nhất hai lần:
| dữ liệu sống có sẵn | cách dọn | RAM sau cùng | đỉnh | thời gian dựng |
|---|---|---|---|---|
| chẳng có | chẳng dọn | ~155 MB | ~205 MB | 1,2 giây |
| chẳng có | dọn trong lúc dựng | ~135 MB | ~180 MB | 1,3 giây |
| ~660 MB | chẳng dọn | ~1.140 MB | ~1.980 MB | 1,2 giây |
| ~660 MB | dọn sau, gc + shrink | ~1.110 MB | ~1.985 MB | 1,2 giây |
| ~660 MB | dọn trong lúc dựng | 699 MB | 786 MB | 2,1 giây |
| ~660 MB | chỉ cắt, chẳng giữ chỉ mục | 666 MB | ~1.970 MB | 1 giây |
Cỡ của hiệu ứng chẳng bằng số trên máy chủ thật, vì 3,28 triệu object của mình chỉ là đồ giả. Nhưng hình dạng thì khớp: nhà càng đầy, rác càng chất cao rồi mới dọn. Đo trong tiến trình sạch sẽ chẳng bao giờ thấy, mà đo trong tiến trình sạch lại là cách tụi mình tìm thủ phạm.
Dòng cuối bảng sửa một câu trong nhật ký
Nhật ký sửa lỗi ghi "bộ nhớ mà allocator đã nới ra thì chẳng trả lại". Dòng cuối bảng cho thấy câu đó chưa đủ. Cùng đỉnh gần 2 GB, nếu chỉ cắt mà chẳng giữ lại gì thì RAM rơi về 666 MB, gần như lúc đầu.
Vậy rác thuần thì trả lại ngon lành, cái kẹt là rác nằm xen với thứ sống sót. Chỉ mục giữ những mảng cứ dài ra từng chút, rải đều giữa đống rác. Mình đoán mỗi mảnh sống ghim luôn cả trang bộ nhớ chung quanh, nên hệ điều hành chẳng lấy lại nổi. Đó mới là đoán, mình chưa soi vô allocator để chứng minh.
Mấy cái bẫy khác trên đường đo
Ước lượng trước khi cắt lệch tới 15 lần. Hai chỗ cắt tiếp theo, tụi mình ước chừng 100 MB và 50 MB. Đo thật chỉ bớt 50 MB và 3 MB. Một phần vì mảng số đặt ngoài heap: heap xuống mà RAM thì lên lại 16 MB.
Đọc số sau một phút là tự dối mình. Máy chủ thật giữ 750 tới 780 MB trong mấy phút đầu, rồi tự nhảy lên 1,28 GB dù chẳng có request nào. Bộ đếm của JavaScript chẳng nhúc nhích qua cú nhảy đó. Từ đó tụi mình chỉ đọc sau ít nhất năm phút.
Bớt RAM mà câu trả lời đổi thì chẳng đáng. Có một bộ 62 câu hỏi cố định, so từng kết quả trước và sau. Không câu nào đổi, tổng thời gian còn giảm từ 2.082 xuống 1.313 ms. Máy chủ thật từ 2.249 MB xuống 1.013 MB, chỉ là hai lần đọc này cách lần khởi động khác nhau (3,7 tiếng so với 5 phút), nên mình chưa dám coi là chung cuộc.
Bạn tự thử nha
Dán đoạn này vô một file, chạy bun file.ts none rồi bun file.ts during:
const during = process.argv[2] === "during";
const mb = () => Math.round(process.memoryUsage().rss / 1048576);
// what a server already holds
const live = Array.from({ length: 3_000_000 }, (_, i) => ({ id: i, name: `row ${i}` }));
const words = "trách nhiệm của cơ quan quản lý nhà nước về đất đai và nhà ở thời hạn bảo hiểm hợp đồng lao động".split(" ");
const index = new Map<string, number[]>();
Bun.gc(true);
const before = mb();
for (let h = 0; h < 300_000; h++) {
if (during && h % 20_000 === 0) Bun.gc(true);
const text = Array.from({ length: 12 }, (_, i) => words[(h * (i + 7)) % words.length]).join(" ");
const cut = text.normalize("NFD").replace(/\p{M}/gu, "").split(" ");
for (const pair of new Set(cut.slice(1).map((w, i) => `${cut[i]} ${w}`))) {
const list = index.get(pair);
if (list) list.push(h);
else index.set(pair, [h]);
}
}
Bun.gc(true);
Bun.shrink();
await Bun.sleep(1000);
console.log(during ? "during" : "after", { before, after: mb(), live: live.length, pairs: index.size });
Trên máy mình, cách dọn sau cùng làm RAM lên thêm khoảng 40 MB, cách dọn trong lúc dựng chỉ thêm 3 tới 7 MB. Đoạn này nhỏ nên chênh lệch cũng nhỏ. Hễ dữ liệu sống càng nhiều, dòng càng dài, thì khoảng cách càng rộng.
Giá phải trả cũng tăng theo: mỗi lần Bun.gc(true) phải đi hết đống dữ liệu sống. Với 1,7 GB heap, lần dựng ở bản dữ liệu giả của mình từ 0,7 giây thành 3,7 giây. Nên chỉ làm vậy cho việc chạy nền, đừng làm trong lúc đang trả lời request.
Chưa biết gì
- Mình chưa chứng minh cái đoán về trang bộ nhớ bị ghim. Mình chỉ có dòng cuối bảng làm chứng gián tiếp.
- Cú nhảy lúc máy ngồi không vẫn chưa ai giải thích. Mấy tiến trình chạy cùng lúc nhảy cùng lúc, nên có thể do áp lực bộ nhớ của macOS, nhưng đó cũng mới là đoán.
- Mình đo trên Mac. Trên máy chủ Linux mới có con số trước và sau, chưa có bảng đầy đủ như trên.
- Con số sau 12 tiếng thì chưa đọc. Có thể bộ nhớ lại nhích lên từ từ, và mọi thứ ở đây chỉ mới là câu chuyện của năm phút đầu.
Bạn từng gặp con số RAM nào chẳng chịu xuống, rồi hoá ra lý do chẳng nằm ở chỗ mình tìm chưa? Viết cho mình một lá thư bên dưới nha.
Gửi Ba rồng một lá thư
Đọc xong muốn nói gì thì viết cho mình nha. Thư chỉ tới tay mình, chẳng tự hiện lên trang. Lá nào hay mà bạn cho phép, mình chép lại dưới bài, không kèm tên bạn.
Từ terminal cũng gửi được: curl -d 'msg=…' olragon.com/garden/high-water-vi/letter