Crash Bandicoot và cách Naughty Dog biến đĩa CD thành bộ nhớ ảo trên PlayStation 1


Không thể nhét một khu rừng vào chiếc túi 2MB

Có một sự thật khá buồn cười: phần cứng không biết mơ.

Nó không biết trên màn hình đang có một khu rừng nhiệt đới, một thác nước, vài con rùa, hàng trăm chiếc thùng gỗ và một con cáo màu cam chạy như thể phía sau đang có người đòi nợ.

Phần cứng chỉ biết byte.

Cái cây là byte. Con rùa là byte. Tiếng thùng gỗ vỡ cũng là byte. Nụ cười ngơ ngác của Crash, xét đến tận cùng, vẫn chỉ là một đống số đang chen chúc trong bộ nhớ.

Và chiếc PlayStation đầu tiên chỉ có 2MB Main RAM.

Không phải 2GB.

Không phải 200MB.

Mà là đúng 2.097.152 byte, nhỏ hơn một tấm ảnh chụp bằng điện thoại hiện nay.

Trong 2MB ấy, trò chơi phải chứa mã chương trình, trạng thái nhân vật, dữ liệu va chạm, mô hình 3D, danh sách polygon, camera, animation và hàng loạt cấu trúc tạm thời phục vụ việc dựng hình.

Trong khi đó, theo Dave Baggett, nhiều màn chơi của Crash Bandicoot có hơn 10MB dữ liệu. Tài liệu kỹ thuật của Naughty Dog cũng mô tả hơn 20 màn chơi đang được phát triển khi ấy cần từ 2–5MB mỗi màn, nhưng vùng hoạt động dành cho chúng chỉ khoảng 1,2MB.

Bài toán nghe giống như yêu cầu nhét một căn nhà vào cốp xe máy.

Naughty Dog không thể làm cho RAM lớn hơn. Họ cũng không thể yêu cầu người chơi mua thêm thanh RAM rồi cắm vào PlayStation như máy tính.

Vì vậy, họ làm một chuyện thú vị hơn:

Không đưa toàn bộ thế giới vào RAM. Chỉ đưa vào RAM phần thế giới mà Crash sắp chạm tới.

Đó là lúc chiếc ổ CD-ROM bắt đầu đóng vai một tầng bộ nhớ khổng lồ, chậm chạp nhưng được điều khiển cực kỳ chính xác.

PlayStation 1 thực sự có bao nhiêu bộ nhớ?

Bộ nhớ của PlayStation 1 không phải một hồ nước lớn mà CPU, GPU và bộ xử lý âm thanh cùng nhau tự do múc dùng.

Nó được chia thành nhiều khu vực có nhiệm vụ khác nhau.

Thành phần Dung lượng Nhiệm vụ chính
Main RAM 2MB Mã chương trình, dữ liệu gameplay, geometry và dữ liệu đang xử lý
VRAM 1MB Framebuffer, texture và bảng màu
Sound RAM 512KB Dữ liệu âm thanh ADPCM, buffer và reverb
Scratchpad 1KB Vùng nhớ rất nhanh nằm gần CPU
CD-ROM Khoảng 300KB/s Nơi lưu trữ toàn bộ dữ liệu của trò chơi

Theo tài liệu kiến trúc PSX-SPX, VRAM và Sound RAM là những vùng nhớ riêng, không nằm trong không gian Main RAM thông thường của CPU.

Điều này tạo ra một dây chuyền khá rõ ràng:

  • Geometry và dữ liệu gameplay được đưa vào Main RAM.
  • CPU sử dụng GTE (Geometry Transformation Engine) để biến đổi tọa độ, chiếu mô hình 3D và thực hiện các phép tính hình học.
  • Texture và framebuffer được quản lý trong VRAM.
  • Dữ liệu âm thanh được chuyển vào Sound RAM để SPU xử lý.
  • Các kênh DMA vận chuyển dữ liệu giữa CD-ROM, Main RAM, GPU và SPU.

GTE là coprocessor hình học nằm trong kiến trúc CPU. CPU đưa dữ liệu vào các thanh ghi của GTE, gọi các instruction chuyên dụng rồi nhận lại kết quả. Nó giúp tăng tốc các phép biến đổi ma trận, ánh sáng và phép chiếu 3D mà không buộc CPU chính phải tự làm toàn bộ phép nhân số học.

Thành tựu của Naughty Dog nằm ở việc điều phối tất cả các vùng nhớ này như một hệ thống thống nhất. Công cụ build quyết định tài nguyên nào phải nằm trên đĩa, tài nguyên nào cần xuất hiện trong Main RAM, texture nào phải có mặt trong VRAM và âm thanh nào cần được giữ trong Sound RAM tại từng khu vực của màn chơi.

Mỗi vùng nhớ là một căn phòng riêng.

Naughty Dog không phá tường để biến chúng thành một căn phòng lớn. Họ xây một đội vận chuyển biết chính xác món đồ nào phải đi vào căn phòng nào và phải đến đó vào lúc nào.

Khi đĩa CD trở thành bộ nhớ ảo

PlayStation 1 không có cơ chế bộ nhớ ảo phần cứng giống máy tính hiện đại. Nó không có hệ điều hành tự động đẩy những page ít dùng xuống ổ đĩa rồi nạp chúng trở lại khi xảy ra page fault.

Naughty Dog xây dựng cơ chế ấy ngay bên trong trò chơi.

Trong tài liệu kỹ thuật của Crash Bandicoot, Andy Gavin gọi nó là một virtual memory scheme: hệ thống bộ nhớ ảo có thể hoán đổi động nhiều loại tài nguyên, bao gồm:

  • Geometry.
  • Animation.
  • Texture.
  • Mã điều khiển.
  • Âm thanh.
  • Dữ liệu va chạm.
  • Camera.
  • Dữ liệu của các vật thể trong màn chơi.

Khác biệt nằm ở chỗ toàn bộ quá trình do trò chơi tự quản lý.

Không có hệ điều hành đứng giữa.

Không có một người quản gia vô hình đến dọn RAM khi bộ nhớ sắp đầy.

Game engine phải tự biết Crash đang ở đâu, camera sắp nhìn thấy gì, tài nguyên nào phải được nạp, dữ liệu nào có thể giải phóng và ổ CD cần bắt đầu đọc từ vị trí nào.

Về bản chất, Naughty Dog đã đổi một tài nguyên đang thiếu là RAM để sử dụng hai tài nguyên còn dư hơn:

  • Dung lượng của đĩa CD.
  • Băng thông đọc đĩa trong thời gian trò chơi đang chạy.

Đây cũng là tư tưởng nền tảng của asset streaming trong game hiện đại.

Một thế giới có thể lớn hơn rất nhiều so với RAM, miễn là hệ thống luôn đưa đúng phần của thế giới vào bộ nhớ trước khi người chơi nhìn thấy nó.

Chia thế giới thành những viên gạch 64KB

Giả sử chúng ta chuyển nhà bằng một chiếc xe máy.

Trong nhà có giường, tủ lạnh, bàn, ghế, quần áo và 40 thùng sách. Không thể chở tất cả trong một chuyến. Nhưng tối nay chỉ cần ngủ, ta có thể chở nệm, gối và quạt trước. Sáng mai cần nấu ăn thì chở thêm bếp và nồi.

Những món đồ chưa cần dùng vẫn nằm trong nhà kho.

Crash Bandicoot hoạt động gần giống như vậy.

Theo bài viết của Dave Baggett trên Game Developer, dữ liệu được đóng gói thành các page có kích thước 64KB.

Mỗi page có thể chứa nhiều loại tài nguyên:

  • Một đoạn geometry của màn chơi.
  • Một số texture.
  • Animation của nhân vật.
  • Âm thanh.
  • Dữ liệu va chạm.
  • Mã GOOL điều khiển kẻ địch.
  • Thông tin camera.
  • Các vật thể như rùa, cua, thùng gỗ hoặc trái Wumpa.

Giả sử một màn chơi có 10MB dữ liệu:

$$ 10 \times 1024 / 64 = 160\text{ page} $$

Trong khi đó, vùng hoạt động khoảng 1,2MB chỉ tương đương gần 19 page 64KB nếu tính bằng một phép chia đơn giản:

$$ 1.2 \times 1024 / 64 \approx 19 $$

Con số thực tế còn phụ thuộc vào mã chương trình và các vùng dữ liệu khác, nhưng phép tính này giúp ta hình dung được bản chất của vấn đề: trên đĩa có thể tồn tại hàng trăm page, còn bộ nhớ chỉ giữ được một nhóm rất nhỏ tại mỗi thời điểm.

Khi Crash chạy đến một khu vực, game kiểm tra danh sách tài nguyên cần thiết.

1Khu vực sắp tới cần: A, B, C, D, E
2RAM hiện có:         A, B, C, X, Y
3
4Cần nạp:             D, E
5Có thể giải phóng:   X, Y

Trong lúc người chơi đang đập thùng ở khu vực hiện tại, hệ thống bắt đầu đọc DE từ đĩa CD.

Khi camera rời khỏi khu vực cũ, XY được giải phóng để nhường chỗ cho những page tiếp theo.

Thế giới phía sau Crash dần biến mất khỏi RAM.

Thế giới phía trước dần được sinh ra.

Người chơi nhìn thấy một khu rừng liên tục. Chiếc máy chỉ nhìn thấy những viên gạch 64KB liên tục đổi chỗ.

Một page 64KB chậm hơn sáu khung hình

Ổ CD-ROM 2x của PlayStation 1 có tốc độ đọc tuần tự khoảng 300KB mỗi giây.

Trong điều kiện lý tưởng, thời gian để đọc một page 64KB là:

$$ T = \frac{64}{300} \approx 0,213\text{ giây} $$

Crash Bandicoot chạy ở 30 khung hình mỗi giây. Mỗi khung hình kéo dài:

$$ \frac{1}{30} \approx 0,033\text{ giây} $$

Như vậy, chỉ riêng việc đọc một page đã cần thời gian tương đương hơn sáu khung hình:

$$ \frac{0,213}{0,033} \approx 6,4\text{ frame} $$

Đó mới chỉ là thời gian đọc tuần tự. Ổ CD còn có một mắt đọc cơ học phải di chuyển đến đúng vị trí trên bề mặt đĩa. Nếu dữ liệu nằm quá xa nhau, thời gian seek sẽ tiếp tục kéo dài.

Vì vậy, trò chơi không thể chờ đến khi Crash bước vào khu vực mới rồi mới bắt đầu nạp dữ liệu.

Nó phải đọc trước.

Khi Crash vẫn còn đứng ở đầu con đường, game đã biết cuối con đường có một cây cầu, ba con rùa, bảy chiếc thùng và một đoạn nhạc mới. Các page tương ứng được yêu cầu từ trước, truyền vào Main RAM bằng DMA và chuẩn bị cho thời điểm camera nhìn thấy chúng.

Đây là một bài toán deadline.

  • Nạp quá muộn, khung hình bị khựng.
  • Nạp quá sớm, page chiếm chỗ của dữ liệu đang cần.
  • Nạp đúng lúc, người chơi không hề biết có một mắt laser đang chạy dưới chân mình.

Dave Baggett mô tả hệ thống này là một công trình “full stack” theo nghĩa rất vật lý: từ bộ quản lý tài nguyên cấp cao xuống tận mã DMA ở cấp opcode.

Ở đây không có một dòng code thần kỳ.

Có hàng nghìn quyết định nhỏ diễn ra đúng giờ.

Dữ liệu phải được đặt đúng vị trí trên đĩa

Một ổ SSD hiện đại có thể truy cập những vùng dữ liệu nằm cách xa nhau khá nhanh.

Ổ CD-ROM thì khác. Nó là một thiết bị cơ học. Muốn đọc dữ liệu ở vị trí mới, mắt laser phải di chuyển trên bề mặt đĩa.

Nếu texture của con rùa nằm ở đầu đĩa, animation nằm ở giữa đĩa và âm thanh nằm gần cuối đĩa, hệ thống sẽ mất phần lớn thời gian cho việc chạy qua chạy lại.

Vì vậy, Andy Gavin kiểm soát cả vị trí vật lý của từng nhóm dữ liệu trên CD-ROM.

Những tài nguyên thường được dùng cùng nhau phải nằm gần nhau. Dữ liệu của khu vực tiếp theo phải được bố trí theo đúng thứ tự mà Crash có khả năng đi qua. Mắt đọc chỉ cần trượt dần trên đĩa, thay vì liên tục nhảy từ đầu này sang đầu kia.

Nói cách khác, trước khi đĩa game được đưa vào sản xuất, máy tính đã phải tính trước hành trình tương lai của mắt laser.

Người chơi chỉ nhấn nút tiến lên.

Nhưng để cú nhấn ấy không làm trò chơi dừng lại, vị trí của hàng triệu byte đã được sắp xếp từ trước.

NPT: chương trình tính trước tương lai của Crash

Naughty Dog phát triển một công cụ chạy trên workstation có tên NPT.

Theo tài liệu kỹ thuật của hãng, mỗi màn chơi có khoảng 500–1.000 tài nguyên. Nhiệm vụ của NPT là tìm cách bố trí các tài nguyên ấy sao cho lượng dữ liệu cần hiện diện tại bất kỳ thời điểm nào không vượt quá vùng nhớ hoạt động khoảng 1,2MB.

NPT phân tích:

  • Crash có thể đứng ở đâu.
  • Camera có thể nhìn thấy khu vực nào.
  • Mỗi khu vực cần những texture nào.
  • Vật thể nào có thể xuất hiện cùng lúc.
  • Animation nào phải có mặt.
  • Âm thanh nào sắp được phát.
  • Page nào đang được nhiều đối tượng cùng sử dụng.
  • Khi nào một page có thể rời khỏi bộ nhớ.

Sau đó, công cụ tạo ra một kế hoạch bố trí dữ liệu trên đĩa. Hệ thống virtual memory đa luồng trong game sẽ làm theo kế hoạch ấy khi chạy trên PlayStation.

Có thể hình dung một phiên bản đơn giản như sau:

 1while game_is_running:
 2    current_zone = get_current_camera_zone()
 3
 4    required_pages = page_table[current_zone]
 5    missing_pages = required_pages - resident_pages
 6
 7    prefetch(missing_pages)
 8    evict_pages_not_needed_soon()
 9
10    update_game()
11    render_frame()

Đoạn mã nhìn khá hiền.

Nhưng mỗi dòng là một vực thẳm.

page_table phải được tính đúng từ trước. prefetch() phải tôn trọng tốc độ đọc của CD. Không được giải phóng một page khi animation vẫn đang sử dụng nó. Texture phải đến VRAM trước khi GPU dựng polygon. Âm thanh phải có mặt trong Sound RAM trước khi SPU phát ra loa.

Và toàn bộ hệ thống phải tiếp tục chạy ở 30 FPS.

Xong thuật toán, quá dễ.

Chỉ còn việc dành vài năm viết engine, compiler, công cụ build, bộ nén và mã assembly để biến mấy dòng trên thành hiện thực.

Đóng gói tài nguyên là một bài toán cực khó

Dữ liệu không tự nhiên có kích thước vừa đẹp 64KB.

Giả sử ta có các tài nguyên:

1Con rùa:          7KB
2Animation Crash: 18KB
3Texture cây:     22KB
4Âm thanh thùng:   5KB
5Dữ liệu camera:   9KB
6Mã điều khiển:    6KB

Nhiệm vụ là xếp chúng vào các page 64KB sao cho:

  • Không page nào vượt quá 64KB.
  • Tổng số page được sử dụng càng ít càng tốt.
  • Các tài nguyên cần cùng lúc có thể được nạp hiệu quả.
  • Khoảng trống bị lãng phí càng nhỏ càng tốt.

Đây là một biến thể của bài toán bin packing.

Trong lý thuyết độ phức tạp, phiên bản quyết định của bài toán thuộc nhóm NP-complete, còn bài toán tìm phương án tối ưu thuộc nhóm NP-hard. Với số lượng tài nguyên lớn, thử mọi cách sắp xếp là điều không thể thực hiện trong thời gian hợp lý.

Dave Baggett đã xây dựng một packer sử dụng nhiều chiến lược:

  • First-fit.
  • Best-fit.
  • Nhiều thứ tự sắp xếp tài nguyên khác nhau.
  • Tìm kiếm ngẫu nhiên có định hướng.
  • Cơ chế tìm kiếm được ông so sánh với gradient descent và simulated annealing.

Công cụ chạy nhiều phương án rồi giữ lại kết quả tốt nhất.

Ví dụ, cách đóng gói thông thường có thể tạo ra kết quả:

1Page 1: 40KB + 18KB = 58KB, còn 6KB
2Page 2: 33KB + 20KB = 53KB, còn 11KB
3Page 3: 29KB + 28KB = 57KB, còn 7KB

Tổng cộng có 24KB bị bỏ trống.

Trên một máy có hàng gigabyte RAM, 24KB gần như không đáng kể. Trên PlayStation 1, đó có thể là một đoạn animation, một vật thể mới hoặc vài lớp texture.

Thậm chí một thay đổi rất nhỏ cũng có thể làm cách đóng gói thay đổi hoàn toàn. Con rùa béo thêm vài byte, page cũ không còn đủ chỗ, một tài nguyên khác phải chuyển sang page mới, rồi cả cấu trúc bộ nhớ của màn chơi bị xê dịch theo.

Đây là lúc sinh học gặp toán học.

Con rùa chỉ lớn thêm một chút.

Nhưng cả khu rừng phải chuyển nhà.

Camera là một phần của hệ thống bộ nhớ

Camera của Crash Bandicoot thường di chuyển theo những đường ray được thiết kế trước.

Người chơi có thể tiến, lùi và đi ngang ở một số đoạn, nhưng camera không hoàn toàn tự do bay khắp thế giới. Lựa chọn này vừa tạo cảm giác giống game platform 2D, vừa giúp Naughty Dog biết trước những gì có thể xuất hiện trên màn hình.

Trên các workstation SGI, nhóm phát triển tính trước:

  • Những polygon nào có thể nhìn thấy tại mỗi vị trí.
  • Polygon nào bị cây, vách đá hoặc công trình che khuất.
  • Thứ tự dựng polygon.
  • Tài nguyên nào cần thiết tại từng đoạn camera.
  • Khu vực nào phải được nạp tiếp theo.

Theo Andy Gavin, Crash Bandicoot đầu tiên giới hạn cảnh nhìn thấy tại một vị trí vào khoảng 800 polygon. Naughty Dog sử dụng cây cối, vách đá, tường và các khúc cua để che những phần còn lại của thế giới.

Một cái cây trong Crash vì vậy không chỉ là một cái cây.

Nó còn là một tấm rèm.

Phía sau tấm rèm ấy, hàng nghìn polygon có thể tạm thời không được dựng. Nhiều page có thể chưa cần nạp. GPU được giảm bớt công việc, còn hệ thống streaming có thêm thời gian chuẩn bị dữ liệu.

Camera, thiết kế màn chơi, hệ thống occlusion và virtual memory không phải bốn kỹ thuật đứng riêng biệt.

Chúng là bốn bánh răng của cùng một cỗ máy.

Một hệ thống giỏi không chỉ tối ưu thuật toán. Nó còn thiết kế cả thế giới xung quanh để thuật toán luôn được làm việc trong điều kiện thuận lợi.

Animation của Crash: đổi dung lượng lấy cảm xúc

Crash Bandicoot có khuôn mặt và cơ thể mềm mại hơn nhiều nhân vật 3D cùng thời.

Lý do nằm ở cách Naughty Dog xây dựng animation.

Các game 3D thời đó thường sử dụng skeleton đơn giản: mô hình được gắn vào một số xương, sau đó các xương xoay để điều khiển tay, chân và cơ thể. Trên mô hình ít polygon, cách này dễ làm nhân vật chuyển động cứng như một chuỗi xúc xích nối lại với nhau.

Naughty Dog sử dụng vertex animation.

Họ lưu vị trí các đỉnh của mô hình qua từng frame ở tốc độ 30 FPS. Họa sĩ có thể kéo mặt Crash dài ra, ép cơ thể dẹt xuống, làm mắt lồi lên hoặc biến cả người thành một quả bóng.

Theo Andy Gavin, hệ thống này cần những bộ nén vertex được viết bằng assembly. Mỗi thế hệ compressor tiếp tục ép dữ liệu nhỏ hơn để hàng nghìn frame animation có thể tồn tại trong ngân sách bộ nhớ rất hạn chế.

Ở Crash 2, lượng animation tăng khoảng tám lần. Naughty Dog tiếp tục viết lại bộ nén và lưu animation dưới dạng một bitstream đặc biệt được nén trong cả bốn chiều.

Nhờ đó, Crash không chỉ là một mô hình 3D biết chạy.

Nó biết hoảng hốt, ngơ ngác, đau đớn, cười ngớ ngẩn và bị ép thành một miếng hoạt hình.

Cảm xúc của nhân vật không xuất hiện miễn phí. Mỗi cái nhíu mày đều tiêu tốn byte. Mỗi cú há miệng đều phải được nén, đóng gói, đặt lên đĩa và đưa vào RAM đúng lúc.

Trong máy tính, ngay cả sự ngốc nghếch cũng cần một kiến trúc dữ liệu.

Tự viết engine sát với phần cứng

Crash Bandicoot sử dụng rất ít thư viện tiêu chuẩn của Sony. Các lập trình viên viết nhiều thành phần riêng và điều khiển phần cứng ở tầng thấp.

Họ xây dựng:

  • Trình quản lý bộ nhớ.
  • Paging system.
  • Công cụ đóng gói page.
  • Pipeline xử lý level trên SGI.
  • Bộ tính visibility và polygon sorting.
  • Compressor cho animation.
  • Mã DMA cấp thấp.
  • Ngôn ngữ GOOL để điều khiển vật thể.
  • Compiler riêng cho GOOL.
  • Các phần quan trọng của engine bằng assembly.

GOOL, viết tắt của Game Oriented Object LISP, được thiết kế để lập trình hành vi cho hàng trăm vật thể trong game. Nó hỗ trợ state machine, macro, dynamic loading và biên dịch mã điều khiển phù hợp với kiến trúc của engine.

Điều đáng chú ý là toàn bộ chuỗi công cụ cùng hiểu một cấu trúc dữ liệu chung.

Người làm level không chỉ tạo hình ảnh.

Công cụ build không chỉ gom file.

Compiler không chỉ dịch code.

Mỗi thành phần đều tham gia vào việc quyết định tài nguyên nào sẽ tồn tại trong bộ nhớ ở từng khoảnh khắc.

Theo Jason Rubin, bí mật của phần hình ảnh trong Crash nằm ở chính hệ thống lập trình phía dưới. Một thế giới hoạt hình đẹp không chỉ được vẽ ra. Nó được nâng đỡ bởi compressor, compiler, DMA, page table và hàng triệu phép tính đã hoàn thành từ trước.

Chỉ còn bốn byte

Đến giai đoạn cuối, Naughty Dog phải tiếp tục thu nhỏ phần mã C và assembly để trò chơi vừa hoàn toàn trong 2MB RAM.

Dave Baggett kể rằng nhóm đã thử thay đổi những đoạn code tương đương về mặt logic để compiler sinh ra mã máy ngắn hơn:

  • Đổi vòng lặp for thành while.
  • Tái sử dụng biến.
  • Sắp xếp lại biểu thức.
  • Tận dụng các bit thấp của con trỏ do địa chỉ được căn chỉnh theo 4 byte.
  • Viết lại những đoạn mã quan trọng bằng assembly.

Họ lần lượt tiết kiệm được 200 byte, 125 byte, 50 byte rồi 8 byte.

Cuối cùng, Crash Bandicoot vừa vào bộ nhớ với đúng 4 byte còn trống.

Bốn byte.

Vừa đủ chứa một số nguyên 32-bit.

Một trò chơi có khu rừng, thác nước, lâu đài, hàng trăm animation và cả một con cáo màu cam đầy cảm xúc đã đứng vừa trong chiếc máy với khoảng trống nhỏ hơn một câu chào.

Đó là tối ưu hóa ở trạng thái gần như vật chất.

Không còn những khái niệm mơ hồ như “hơi tốn RAM”.

Mỗi byte đều có tên.

Mỗi byte đều có công việc.

Và bốn byte cuối cùng đứng đó, giống bốn chiếc ghế trống còn sót lại sau khi cả thế giới đã chen vào căn phòng.

Bài học cho hệ thống hiện đại: từ chiếc đĩa CD đến mô hình 2,8 nghìn tỷ tham số

Ba mươi năm sau Crash Bandicoot, con người lại gặp đúng bài toán cũ.

Chỉ khác lần này, thứ không thể nhét vào bộ nhớ không còn là một khu rừng, vài con rùa và hàng trăm chiếc thùng gỗ.

Nó là một bộ não nhân tạo có hàng nghìn tỷ tham số.

Theo tài liệu chính thức của Moonshot AI, Kimi K3 có:

  • 2,8 nghìn tỷ tham số tổng.
  • 104 tỷ tham số được kích hoạt cho mỗi token.
  • 93 layer.
  • 896 expert, trong đó mỗi token chọn 16 expert.
  • Trọng số MXFP4 và activation MXFP8.

Nếu lưu toàn bộ 2,8 nghìn tỷ tham số bằng BF16, chỉ riêng model weights đã cần xấp xỉ:

$$ 2{,}8 \times 10^{12} \times 2 = 5{,}6 \text{ TB} $$

Nếu dùng 8-bit, con số giảm xuống khoảng 2,8TB.

Ngay cả với 4-bit, trong điều kiện lý tưởng nhất, model vẫn cần:

$$ 2{,}8 \times 10^{12} \times 0{,}5 = 1{,}4 \text{ TB} $$

Dung lượng thực tế còn có thể lớn hơn vì model phải lưu scale lượng tử hóa, metadata, embedding, buffer và các cấu trúc phục vụ quá trình tính toán.

Trong khi đó, một máy tính cá nhân khá mạnh có thể chỉ có 16GB VRAM.

Một workstation nhiều RAM hơn có thể có 128GB RAM nhưng không có GPU.

Cả hai đều nhỏ bé trước một model nặng hơn một terabyte.

Nhìn qua, bài toán có vẻ kết thúc.

Không đủ bộ nhớ thì không chạy được.

Nhưng đó cũng là điều người ta từng nghĩ khi Naughty Dog muốn đưa một màn chơi hơn 10MB vào vùng nhớ hoạt động khoảng 1,2MB.

Con người không giải quyết vấn đề bằng cách bắt toàn bộ thế giới phải nhỏ lại.

Con người chia thế giới thành từng phần, giữ phần đang cần trong vùng nhớ nhanh và đẩy phần chưa cần xuống một nơi chậm hơn.

LLM không sống trong một loại bộ nhớ

Một máy chạy LLM hiện đại có thể có nhiều tầng bộ nhớ:

Tầng Đặc điểm Dữ liệu thường được giữ
GPU VRAM hoặc HBM Rất nhanh, rất đắt, dung lượng nhỏ Layer đang tính toán, activation, KV cache
CPU RAM Chậm hơn GPU nhưng lớn hơn Model weights được offload, cache trung gian
NVMe SSD Lớn, rẻ hơn, chậm hơn RAM Model shards, weights chưa cần dùng
Storage từ xa Rất lớn nhưng độ trễ cao Checkpoint gốc, bản sao model

Đây chính là một hệ thống phân cấp bộ nhớ.

GPU giống căn bếp.

RAM giống kho hàng phía sau.

NVMe giống nhà kho ở cuối đường.

Đầu bếp không thể đem toàn bộ nguyên liệu của cả tháng chất lên bàn. Ông chỉ đưa lên những thứ cần cho món đang nấu. Trong lúc chảo thứ nhất đang nóng, người khác đã chạy xuống kho lấy nguyên liệu cho món thứ hai.

Xong món nào, dọn nguyên liệu món ấy.

Nghe quen quen.

Crash cũng từng làm như vậy với những page 64KB.

Không tải cả model, chỉ tải layer sắp được tính toán

Một Transformer không sử dụng đồng thời toàn bộ các layer.

Dữ liệu đi lần lượt qua từng layer:

 1Input
 2 3Layer 1
 4 5Layer 2
 6 7Layer 3
 8 9...
1011Layer 93
1213Output

Kết quả của layer trước trở thành đầu vào của layer sau.

Từ đặc điểm này, các hệ thống như DeepSpeed ZeRO-Inference có thể giữ model weights trong CPU RAM hoặc NVMe rồi đưa từng layer vào GPU khi cần.

Một phiên bản cực kỳ đơn giản có thể được hình dung như sau:

 1hidden_state = input_tensor
 2
 3for layer_id in range(number_of_layers):
 4    current_weights = load_layer_to_fast_memory(layer_id)
 5
 6    prefetch_layer(layer_id + 1)
 7
 8    hidden_state = forward(
 9        hidden_state,
10        current_weights,
11    )
12
13    release(current_weights)

Khi GPU đang tính layer 20, hệ thống có thể âm thầm nạp layer 21.

Khi layer 20 hoàn thành, weights của nó được giải phóng. Layer 21 bước vào VRAM. Layer 22 bắt đầu được đọc trước.

Trong một khoảnh khắc, GPU chỉ cần giữ một hoặc một vài layer thay vì cả model.

DeepSpeed gọi kỹ thuật này là layer-by-layer streaming. Hệ thống còn sử dụng prefetching để chồng thời gian truyền dữ liệu lên thời gian tính toán: GPU đang nhân ma trận của layer hiện tại thì DMA đã kéo layer tiếp theo qua PCIe.

Ổ CD-ROM của PlayStation năm 1996 đã làm một công việc có tinh thần tương tự.

Chỉ khác lần này, thứ đang chạy qua đường truyền không còn là texture của một con rùa.

Nó là hàng tỷ con số tạo nên một mảnh nhỏ của bộ não nhân tạo.

“Batch” ở đây không phải một mẩu của model

Có một điểm dễ nhầm.

Khi nói “load từng batch vào memory”, ta có thể đang nói về hai thứ khác nhau:

  • Chia model weights thành từng shard, layer hoặc block rồi nạp lần lượt.
  • Gom nhiều input thành một batch để cùng đi qua layer đang nằm trong GPU.

Hai việc này thường được kết hợp với nhau.

Giả sử phải tốn một giây để chuyển weights của một layer từ RAM vào GPU.

Nếu chỉ xử lý một câu hỏi rồi bỏ layer ấy đi, toàn bộ một giây truyền dữ liệu chỉ phục vụ một request.

Nếu gom 32 request thành một batch, layer ấy được nạp một lần nhưng phục vụ 32 request cùng lúc.

1Nạp Layer 20 vào GPU
23Xử lý Request 1
4Xử lý Request 2
5Xử lý Request 3
6...
7Xử lý Request 32
89Giải phóng Layer 20

Đây là lý do ZeRO-Inference hướng đến bài toán throughput và các batch tương đối lớn.

Batch không làm weights nhẹ hơn.

Nó làm cho mỗi lần vận chuyển weights trở nên đáng tiền hơn.

Giống như đã thuê một chiếc xe tải chở hàng từ kho về, ta cố nhét đủ hàng cho 32 đơn thay vì bắt chiếc xe chạy 32 chuyến, vì một lý do nào đó mà phòng vận hành chưa kịp nghĩ ra.

MoE: xây cả thành phố nhưng mỗi người chỉ đi qua vài căn nhà

Kimi K3 còn sử dụng kiến trúc Mixture-of-Experts (MoE).

Model có tổng cộng 896 expert, nhưng mỗi token chỉ được định tuyến qua 16 expert. Vì vậy, dù Kimi K3 có tổng cộng 2,8 nghìn tỷ tham số, số tham số được kích hoạt cho mỗi token vào khoảng 104 tỷ.

Nếu biểu diễn lý tưởng bằng 4-bit, riêng 104 tỷ tham số được kích hoạt tương đương khoảng:

$$ 104 \times 10^9 \times 0{,}5 = 52 \text{ GB} $$

Con số này vẫn lớn hơn nhiều so với 16GB VRAM, nhưng đã nhỏ hơn rất xa 1,4TB của toàn bộ model.

MoE giống một thành phố có 896 chuyên gia.

Khi người dùng hỏi cách viết chương trình, model có thể gọi một nhóm expert phù hợp. Khi người dùng gửi hình ảnh, một nhóm khác được sử dụng. Không phải cả 896 expert cùng chen vào một căn phòng rồi đồng thanh trả lời.

Tuy nhiên, “chỉ kích hoạt 104 tỷ tham số” không có nghĩa ta chỉ cần lưu 104 tỷ tham số.

Toàn bộ 2,8 nghìn tỷ tham số vẫn phải tồn tại ở đâu đó, bởi hệ thống chưa biết token tiếp theo sẽ gọi expert nào. Chúng có thể nằm trên nhiều GPU, trong CPU RAM hoặc trên storage. Router của từng layer chỉ quyết định phần nào sẽ thực sự tham gia tính toán.

Đây là lúc những hệ thống như KTransformers đưa ra cách bố trí thông minh hơn:

  • Attention, shared expert và các thành phần dùng thường xuyên nằm trên GPU.
  • Những expert “nóng”, thường được chọn, nằm trong VRAM.
  • Những expert “lạnh”, ít được gọi, nằm trong CPU RAM.
  • CPU và GPU cùng tham gia quá trình inference.
  • Trọng số có thể được lượng tử hóa để giảm dung lượng và băng thông truyền.

Nó rất giống cách giữ nhân vật Crash, camera và các tài nguyên dùng thường xuyên trong bộ nhớ, còn cây cối, kẻ địch và texture của khu vực xa hơn tiếp tục nằm trong kho.

Nếu máy chỉ có 16GB VRAM thì sao?

Với 16GB VRAM, ta không thể đặt Kimi K3 hoàn chỉnh vào GPU.

Ngay cả phần tham số được kích hoạt cho một token, xét theo phép tính 4-bit lý tưởng, cũng lớn hơn VRAM.

Một hệ thống offloading về mặt nguyên lý có thể làm như sau:

 1NVMe
 2 3CPU RAM
 4 516GB VRAM
 6 7GPU tính một layer hoặc một nhóm expert
 8 9Giải phóng VRAM
1011Nạp nhóm tiếp theo

Các project mã nguồn mở đã triển khai nhiều phần của tư tưởng này:

Project Cơ chế chính
DeepSpeed ZeRO-Inference Giữ weights trong CPU hoặc NVMe, stream từng layer vào GPU
Hugging Face Accelerate Chia layer giữa GPU, CPU và disk bằng device_map; nạp weights khi forward
FlexGen Offload weights và KV cache giữa GPU, CPU và SSD
llama.cpp Quantization, memory mapping và CPU–GPU hybrid inference
vLLM CPU offloading, continuous batching và quản lý KV cache theo page
KTransformers CPU–GPU hybrid inference và bố trí expert nóng/lạnh cho MoE

Nhưng “có thể về mặt kiến trúc” rất khác với “chạy tốt trong thực tế”.

Muốn chạy Kimi K3, phần mềm còn phải hiểu đúng Kimi Delta Attention, Attention Residuals, Stable LatentMoE, MXFP4, cách định tuyến 896 expert và các kernel tương ứng. Không thể lấy một framework có offloading, trỏ vào checkpoint rồi hy vọng 2,8 nghìn tỷ tham số tự ngoan ngoãn xếp hàng đi qua GPU.

Xong ý tưởng, quá dễ.

Chỉ còn việc viết kernel, scheduler, model loader, expert router và trình quản lý vài terabyte dữ liệu.

Nếu có 128GB RAM nhưng không có GPU thì sao?

128GB RAM lớn hơn 16GB VRAM tám lần.

Nhưng 128GB vẫn không đủ chứa model MXFP4 có kích thước lý tưởng khoảng 1,4TB.

Nếu có thêm NVMe đủ lớn, hệ điều hành có thể dùng memory mapping để ánh xạ file model vào không gian địa chỉ của tiến trình. Chương trình nhìn thấy một vùng dữ liệu khổng lồ, nhưng các page vật lý chỉ được kéo từ SSD vào RAM khi thực sự được truy cập.

Đây là cơ chế được Hugging Face Accelerate sử dụng trong chế độ disk offload và cũng là một trong những nền tảng giúp llama.cpp chạy model lớn hơn vùng RAM hoặc VRAM nhanh hiện có.

Dòng dữ liệu lúc này trở thành:

1Model file trên NVMe
23Page cache trong RAM
45CPU cache
67Vector instruction
89Kết quả của layer

Về mặt kỹ thuật, máy có thể không cần đặt toàn bộ model trong RAM cùng lúc.

Nhưng CPU phải thực hiện một lượng phép nhân ma trận khổng lồ. SSD cũng phải liên tục chuyển weights vào RAM. Nếu dữ liệu cần dùng không còn trong page cache, hệ điều hành lại phải xuống ổ đĩa lấy nó lên.

Một chiếc NVMe đọc tuần tự khoảng 7GB mỗi giây sẽ cần tối thiểu:

$$ \frac{1.400}{7} = 200 \text{ giây} $$

chỉ để đọc một lượt qua 1,4TB dữ liệu trong điều kiện lý tưởng.

Trong thực tế, MoE không nhất thiết đọc toàn bộ weights cho mỗi token vì chỉ một phần expert được kích hoạt. Nhưng việc truy cập expert có thể phân tán, băng thông không phải lúc nào cũng đạt mức lý tưởng, dữ liệu còn phải được giải lượng tử hóa và CPU vẫn phải thực hiện phép tính.

Vì vậy, một model “chạy được” bằng RAM và NVMe chưa chắc đã “dùng được”.

Nếu mỗi token cần vài chục giây hoặc vài phút, ta đã chiến thắng bài toán dung lượng nhưng thua trận đánh với thời gian.

Chiếc máy không còn báo hết bộ nhớ.

Nó chỉ ngồi suy nghĩ lâu đến mức người dùng bắt đầu suy nghĩ về ý nghĩa của cuộc đời.

Lượng tử hóa: bộ nén animation của thời đại LLM

Naughty Dog từng viết các compressor bằng assembly để ép hàng nghìn frame vertex animation vào bộ nhớ PlayStation.

Thời đại LLM làm điều tương tự với model weights.

Một tham số không nhất thiết phải được lưu bằng số thực 32-bit.

Nó có thể được biểu diễn bằng:

  • FP16 hoặc BF16.
  • FP8.
  • INT8.
  • INT4.
  • Các định dạng 3-bit, 2-bit hoặc hỗn hợp.
  • Những định dạng chuyên biệt như MXFP4.

Nếu một model được giảm từ 16-bit xuống 4-bit, dung lượng lý tưởng giảm khoảng bốn lần:

$$ \frac{16}{4} = 4 $$

Nhưng lượng tử hóa không chỉ tiết kiệm chỗ.

Nó còn làm giảm số byte phải chuyển từ SSD sang RAM, từ RAM sang GPU và từ HBM vào các compute unit.

Trong nhiều hệ thống LLM, nút thắt không nằm ở khả năng nhân số của GPU.

Nút thắt nằm ở việc đưa đủ dữ liệu đến nơi thực hiện phép nhân.

Con chip có thể rất khỏe.

Nhưng nếu mỗi giây nó phải đứng chờ một đoàn xe tải chở weights từ đầu bên kia PCIe, sức mạnh ấy cũng chỉ dùng để ngồi đợi.

PagedAttention: những page 64KB quay trở lại

LLM còn sinh ra một loại dữ liệu khác gọi là KV cache.

Khi model đọc một đoạn hội thoại, nó giữ lại các key và value đã tính cho những token trước để không phải tính lại từ đầu mỗi khi sinh token mới.

Context càng dài, KV cache càng lớn.

Nếu mỗi request được cấp trước một vùng nhớ liên tục theo context tối đa, phần lớn VRAM có thể bị bỏ trống. Một người hỏi ba dòng vẫn được đặt sẵn một căn phòng đủ chứa cả cuốn tiểu thuyết.

vLLM giải quyết vấn đề này bằng PagedAttention, một kỹ thuật được lấy cảm hứng trực tiếp từ virtual memory của hệ điều hành.

KV cache được chia thành những block nhỏ.

Các block logic liên tục của một cuộc hội thoại không cần nằm liên tục trong VRAM. Một block có thể nằm ở đầu bộ nhớ, block tiếp theo nằm ở cuối bộ nhớ, còn block thứ ba vừa được cấp phát từ khoảng trống của một request đã kết thúc.

Block table sẽ ghi lại vị trí của chúng.

1Cuộc hội thoại A:
2
3Block logic 0 → Block vật lý 7
4Block logic 1 → Block vật lý 2
5Block logic 2 → Block vật lý 19

Người dùng nhìn thấy một cuộc trò chuyện liên tục.

GPU nhìn thấy nhiều mảnh bộ nhớ nằm rải rác.

Đây gần như là sự trở lại nguyên vẹn của tư tưởng từng giúp Crash Bandicoot sống trong 2MB RAM:

  • Chia dữ liệu thành page.
  • Chỉ cấp phát khi cần.
  • Dùng bảng ánh xạ giữa dữ liệu logic và bộ nhớ vật lý.
  • Chia sẻ những block giống nhau.
  • Thu hồi block khi request kết thúc.
  • Đưa block sang tầng bộ nhớ chậm hơn khi vùng nhanh bị thiếu.

Ba mươi năm trôi qua.

Con rùa đã biến thành token.

Page dữ liệu màn chơi đã biến thành KV block.

Ổ CD-ROM đã biến thành NVMe.

Main RAM 2MB đã biến thành HBM hàng chục gigabyte.

Nhưng câu hỏi của kỹ sư vẫn vậy:

Trong khoảnh khắc này, phần nhỏ nào của bộ não nhân tạo bắt buộc phải có mặt trong vùng nhớ nhanh?

Điều con người thực sự cải tiến khi LLM xuất hiện

Sự phát triển của LLM không chỉ tạo ra những model lớn hơn.

Nó buộc con người phải học cách điều khiển dữ liệu tinh vi hơn:

  • Quantization làm mỗi tham số nhẹ hơn.
  • Sharded checkpoint chia model thành nhiều file có thể nạp lần lượt.
  • Memory mapping để hệ điều hành chỉ đọc những page thực sự cần.
  • CPU và NVMe offloading biến RAM và SSD thành phần mở rộng của VRAM.
  • Layer streaming đưa từng layer qua GPU.
  • Prefetching nạp layer tiếp theo trong khi layer hiện tại đang tính.
  • Batching cho nhiều request dùng chung một lần nạp weights.
  • Mixture-of-Experts chỉ kích hoạt một phần model cho mỗi token.
  • Expert placement giữ expert nóng trên GPU và expert lạnh trong RAM.
  • PagedAttention chia KV cache thành những block nhỏ có thể cấp phát động.
  • Continuous batching đưa request mới vào ngay khi request cũ rời khỏi hệ thống.
  • KV cache offloading chuyển lịch sử hội thoại xuống tầng bộ nhớ rẻ hơn.

Những kỹ thuật ấy không làm một model 2,8 nghìn tỷ tham số trở nên nhỏ bé.

Chúng làm cho model không cần xuất hiện toàn bộ ở cùng một nơi, trong cùng một thời điểm.

Naughty Dog từng khiến PlayStation tin rằng vùng nhớ 1,2MB có thể chứa cả một hòn đảo.

Các hệ thống LLM hiện đại đang cố khiến một GPU 16GB tin rằng phía sau nó có một bộ não nặng hàng terabyte.

Cả hai đều không phá vỡ định luật vật lý.

Mỗi byte vẫn phải nằm ở đâu đó. Mỗi tham số vẫn phải đi qua một con đường vật chất. SSD phải quay cuồng với các page dữ liệu, RAM phải giữ những expert đang chờ, PCIe phải vận chuyển hàng tỷ con số và GPU phải đốt điện để biến chúng thành token tiếp theo.

Điều thay đổi không phải là bộ nhớ trở nên vô hạn.

Điều thay đổi là con người ngày càng giỏi hơn trong việc quyết định thứ gì được bước vào vùng nhớ nhanh, thứ gì phải đứng ngoài cửa và thứ gì cần bắt đầu lên đường trước khi phép tính hiện tại kết thúc.

Ngày xưa, một cái cây che khuất vài nghìn polygon để chiếc PlayStation có thời gian nạp khu vực tiếp theo.

Ngày nay, một router chọn 16 trong số 896 expert để một model 2,8 nghìn tỷ tham số không phải đánh thức toàn bộ bộ não chỉ để viết ra một token.

Thế giới phần mềm đã đi rất xa.

Nhưng sâu bên dưới, chúng ta vẫn đang làm một việc cũ:

Chia một thứ quá lớn thành những phần đủ nhỏ.

Đưa đúng phần vào đúng chỗ.

Và cầu mong dữ liệu đến trước deadline.

Tổng kết

Crash Bandicoot không chứa toàn bộ màn chơi trong RAM.

Naughty Dog chia dữ liệu thành các page 64KB, dùng công cụ NPT tính trước nhu cầu của hàng trăm tài nguyên, đóng gói chúng bằng nhiều thuật toán, sắp xếp vị trí vật lý trên CD-ROM, đọc trước bằng DMA và giải phóng page khi camera rời khỏi khu vực.

Camera được thiết kế để dự đoán được. Visibility được tính trước. Animation được nén bằng các compressor viết ở cấp assembly. Main RAM, VRAM và Sound RAM được điều phối như các tầng tài nguyên khác nhau của cùng một hệ thống.

Phía sau mỗi bước chân của Crash là một mắt laser đang chuyển động, một page vừa đi vào RAM, một page khác vừa biến mất và một kế hoạch đã được máy trạm tính từ nhiều giờ trước.

Người chơi nhìn thấy cả một hòn đảo.

Chiếc PlayStation chỉ nhìn thấy một cửa sổ 1,2MB đang trượt qua hòn đảo ấy.

Có lẽ đó là bản chất đẹp nhất của tối ưu hóa.

Không phải làm cho máy có nhiều tài nguyên hơn.

Mà là khiến nó không bao giờ cần toàn bộ thế giới cùng một lúc.

Tham khảo

Bình luận