สร้างอีมูเลเตอร์ Game Boy ด้วย F#
(nickkossolapov.github.io)- Fame Boy คืออีมูเลเตอร์ Game Boy ที่พัฒนาด้วย F# รองรับทั้งเดสก์ท็อปและเว็บพร้อมเสียง โดยเปิดให้ลอง เล่นบนเบราว์เซอร์ และเผยแพร่ ซอร์สบน GitHub
- แกนอีมูเลเตอร์และฟรอนต์เอนด์ถูกทำให้เรียบง่าย โดยแชร์กันเพียง
framebuffer,audiobuffer,stepEmulator(),getJoypadState(state)และให้stepperรัน CPU·ตัวจับเวลา·ซีเรียล·APU·PPU ตามลำดับเพื่อให้ ซิงก์กันแบบเธรดเดียว - การพัฒนา CPU ใช้ discriminated union และ
matchของ F# เพื่อสร้างแบบจำลอง opcode 512 รายการเป็นคำสั่ง 58 แบบ และออกแบบให้ชนิดFrom·Toป้องกันสถานะผิดกฎหมายอย่างการเขียนค่าใส่ immediate ได้ตั้งแต่ระดับ type - PPU เลือกเรนเดอร์เป็นรายสแกนไลน์แทน pixel FIFO แบบ Game Boy จริง ทำให้เร็วและง่ายขึ้น แต่บางเกมที่อาศัยจังหวะเวลาของคิวพิกเซลอาจทำงานได้ไม่ถูกต้อง
- การพอร์ตลงเว็บทำได้ด้วย Fable และหลังแก้ปัญหาที่บิตโอเปอเรชัน 8 บิต·16 บิตไปยึดตาม semantics 32 บิตของ JavaScript ก็สามารถรันได้ด้วย JS bundle ขนาดราว 100KB อีกทั้งหลังปรับแต่งประสิทธิภาพและใช้ release build ก็ทำความเร็วบนเดสก์ท็อปได้ราว 1000FPS
ที่มาและเป้าหมายของโปรเจกต์
- แม้จะทำงานเป็นวิศวกรซอฟต์แวร์มานานกว่า 8 ปี แต่ผู้เขียนรู้สึกว่ายังไม่เข้าใจว่าคอมพิวเตอร์ทำงานจริงอย่างไร จึงตัดสินใจเรียนรู้ด้วยการสร้างอีมูเลเตอร์ขึ้นมาเอง
- เพราะเคยเล่น Pokémon มากในวัยเด็ก จึงเลือก Game Boy เป็นเป้าหมาย เนื่องจากเป็นฮาร์ดแวร์จริงที่ขอบเขตค่อนข้างเรียบง่ายและมีความผูกพันส่วนตัวสูง
- ก่อนลงมือกับ Game Boy โดยตรง ได้เรียน From NAND to Tetris เพื่อทำความเข้าใจองค์ประกอบพื้นฐานของคอมพิวเตอร์อย่างรีจิสเตอร์ หน่วยความจำ และ ALU
- เพื่อให้คุ้นเคยกับการสร้างอีมูเลเตอร์ จึงทำอีมูเลเตอร์ CHIP-8 ชื่อ Fip-8 ด้วย F# ขึ้นมาก่อน
- หลังทำงานอยู่หลายเดือน ในที่สุดก็สร้างอีมูเลเตอร์ Game Boy ชื่อ Fame Boy ที่รองรับเสียงและทำงานได้ทั้งบนเดสก์ท็อปกับเว็บสำเร็จ
- สามารถ เล่นบนเบราว์เซอร์ ได้ และซอร์สโค้ดเปิดเผยอยู่บน GitHub
โครงสร้างของอีมูเลเตอร์
- เพื่อให้ทำงานได้ทั้งบนเดสก์ท็อปและเว็บ จึงคงอินเทอร์เฟซระหว่างแกนอีมูเลเตอร์กับฟรอนต์เอนด์ไว้ให้เรียบง่าย
- อินเทอร์เฟซหลักระหว่างฟรอนต์เอนด์กับแกนประกอบด้วยอาร์เรย์ 2 ตัวและฟังก์ชัน 2 ตัว
framebuffer: อาร์เรย์เฉดสีขนาด 160×144 ที่เก็บค่าสีขาว สีอ่อน สีเข้ม และสีดำaudiobuffer: ring audio buffer ที่มี sample rate 32768Hz พร้อมหัวอ่านและหัวเขียนstepEmulator(): รันคำสั่ง CPU หนึ่งคำสั่งและคืนค่าจำนวน cycle ที่ใช้ไปgetJoypadState(state): callback ที่ฟรอนต์เอนด์ใช้ส่งสถานะจอยแพดเข้าอีมูเลเตอร์ โดยปกติจะถูกเรียกหนึ่งครั้งต่อเฟรม
- Fame Boy ถูกสร้างแบบจำลองให้คล้ายฮาร์ดแวร์ Game Boy จริง
- CPU ไม่รับรู้ฮาร์ดแวร์นอกเหนือจาก memory map เช่นเดียวกับ Sharp LR35902 ของ Game Boy จริง และใช้เพียง IoController สำหรับสัญญาณ interrupt
- CPU เป็นส่วนที่มีความเป็น F# มากที่สุดในโค้ดเบส และใช้การทำ domain modeling เชิงฟังก์ชันจำนวนมาก
- Memory.fs เก็บ RAM ส่วนใหญ่ของ Game Boy และทำหน้าที่เป็นทั้ง memory map และบัสระหว่าง CPU, IO Controller และตลับเกม
- เพื่อประสิทธิภาพ Memory.fs จึงแชร์การอ้างอิงไปยังอาร์เรย์ VRAM·OAM RAM กับ PPU เป็นต้น
- IoController.fs ถูกแยกออกมาเมื่อ Memory.fs มีลอจิกมากเกินไป และแม้ในฮาร์ดแวร์ Game Boy จริงจะไม่มี IO controller ตัวเดียว แต่การรวมการจัดการ hardware register ไว้ที่เดียวช่วยให้อินเทอร์เฟซของแต่ละคอมโพเนนต์เรียบง่ายและปลอดภัยขึ้น
- ฟังก์ชัน
stepperใน Emulator.fs ทำหน้าที่เป็นกาวที่ยึดทั้งอีมูเลเตอร์เข้าด้วยกัน โดยประกอบฟังก์ชันรันทีละขั้นของแต่ละคอมโพเนนต์
let stepper () =
// Execute a single instruction
// Each instruction uses a different amount of cycles
let mCycles = stepCpu cpu io
for _ in 1..mCycles do
stepTimers timer io
stepSerial serial io
// The APU technically runs at 4x CPU-cycles, but can be batched
stepApu apu
let tCycles = mCycles * 4
// The PPU operates at 4x CPU-cycles. The APU should be here too
for _ in 1..tCycles do
stepPpu ppu
// Return cycles taken so the frontend runs the emulator at the right speed
mCycles
- คอมโพเนนต์ฮาร์ดแวร์จริงทำงานขนานกันโดยอิงกับ master oscillator กลาง แต่ Fame Boy เป็นแบบเธรดเดียว จึงต้องรันคอมโพเนนต์ตามลำดับ
- ฟังก์ชัน
stepperรวมศูนย์การทำงานไว้เพื่อให้ทุกคอมโพเนนต์ซิงก์กัน - หากต้องการความเร็วระดับเล่นได้ จะต้องรันด้วยจำนวน cycle ที่ถูกต้องต่อวินาที โดยที่ 60FPS ต้องใช้ราว 17500 CPU cycle ต่อเฟรม
- ฟรอนต์เอนด์จะขับอีมูเลเตอร์ตามอัตราสุ่มตัวอย่างเสียงเมื่อเปิดเสียง และจะขับตามเฟรมเรตเมื่อปิดเสียง
การพัฒนา CPU และ F#
-
อีมูเลเตอร์ CHIP-8 เขียนแบบ pure โดยไม่มีสมาชิก
mutableและถึงขั้นคัดลอกอาร์เรย์ แต่ Fame Boy ใช้สถานะที่แก้ไขได้อย่างจริงจัง -
Game Boy เร็วกว่า CHIP-8 มาก และแนวทางคัดลอกหน่วยความจำมากกว่า 16KB หลายล้านครั้งต่อวินาทีไม่เหมาะสม
-
เหตุผลที่ใช้ F# กับ Fame Boy คือระบบ type ที่ทรงพลังของ F# เหมาะกับการทำแบบจำลองคำสั่ง CPU และผู้เขียนก็ชอบ F# อยู่แล้ว
-
การทำ domain modeling
- ตอนพัฒนา CPU ผู้เขียนอ้างอิง Gekkio’s Complete Technical Reference และจัดกลุ่มคำสั่งตามเอกสารนั้น
- ในช่วงแรกได้สร้าง discriminated union แยกตามชนิดคำสั่งไว้ใน Instructions.fs
-
- type LoadInstr = | Load8Immediate of uint8 | Load8Direct of Register | Load8Indirect // ... other load instructions
-
type ArithmeticInstr = | IncrementDirect of uint8 | IncrementIndirect of Register // ... other arithmetic instructions
-
-
หลายคำสั่งใช้แนวคิดร่วมกันคือ "ตำแหน่งของโอเปอแรนด์"
immediateคือการอ่านค่าไบต์จากหน่วยความจำถัดจากคำสั่งโดยตรงdirectคือการอ่านและเขียน CPU registerindirectคือการอ่านและเขียนตำแหน่งหน่วยความจำที่ CPU register HL ชี้อยู่
-
แยกแนวคิดเรื่องตำแหน่งออกมาเป็นชนิด
FromและToทำให้แสดงคำสั่งโหลดได้กระชับขึ้น -
-
type To = | Direct of Register | Indirect
-
type From = | Immediate of uint8 | Direct of Register | Indirect
-
type LoadInstr = | Load of From * To // These form a tuple, like Load<From, To> in C# // ... other instructions
-
-
วิธีนี้ลดคำสั่ง CPU จาก 512 opcode เหลือ 58 คำสั่ง
-
การทำโดเมนให้เป็นนามธรรมมากขึ้นมีความเสี่ยงว่าจะยอมให้เกิดสถานะที่ไม่ถูกต้องได้ แต่ป้องกันได้ด้วย type system
-
ถ้าใช้ชนิดตำแหน่งแบบเดียว
LocแทนFromและToก็อาจคอมไพล์คำสั่งที่ผิดอย่างLoad(Loc.Direct D, Loc.Immediate)ซึ่งเป็นการเก็บค่า register ลงในตำแหน่ง immediate ได้ -
ฮาร์ดแวร์ของ Game Boy ไม่รองรับการเขียนไปยัง immediate ดังนั้นถ้าสร้างโมเดลโดเมนให้ถูกต้องด้วย type ของ F# ก็รับประกันได้ว่าจะไม่สามารถแสดงสถานะที่ผิดกฎหมายในระบบได้
-
มีข้อยกเว้นอยู่เพียงหนึ่งเดียวคือ opcode
0x76- ถ้าดูจากรูปแบบ opcode อย่างเดียว มันจะกลายเป็นรูปแบบอย่าง
Load(From.Indirect, To.Indirect)ซึ่งหมายถึงโหลดค่า 8 บิตจากตำแหน่ง HL ไปยังตำแหน่ง HL เดิม - type ของ Fame Boy อนุญาตสิ่งนี้ แต่ใน Game Boy จริงไม่มีคำสั่งนี้
- ในเชิงตรรกะมันคือ NOP และไม่เป็นอันตราย อีกทั้งไปไม่ถึงในทางปฏิบัติ เพราะตัวอ่าน opcode จะถอดรหัส
0x76เป็นHALT
- ถ้าดูจากรูปแบบ opcode อย่างเดียว มันจะกลายเป็นรูปแบบอย่าง
-
หลังจากใช้
matchและ Option ของ F# แล้ว พอกลับไปใช้switchปกติจะรู้สึกว่ามันทื่อและพลาดได้ง่าย จึงแนะนำให้ลองใช้ภาษาสายฟังก์ชันนัล
-
-
ทำให้เรียบง่าย
-
เป้าหมายของโปรเจกต์นี้ไม่ใช่การสร้างอีมูเลเตอร์ที่ดีที่สุด แต่เป็นการเรียนรู้ฮาร์ดแวร์คอมพิวเตอร์ จึงไม่ได้ไปศึกษาซอร์สของอีมูเลเตอร์อื่นอย่างลึกมาก
-
เมื่อเห็นโค้ดลักษณะนี้ในซอร์สของ CAMLBOY ก็ชอบตรงที่สามารถส่งเฉพาะ flag ที่ต้องการได้ในลำดับใดก็ได้
-
-
set_flags ~h:false ~z:(!a = zero) ();
-
-
F# ทำแบบเดียวกันไม่ได้ เพราะ type system ที่รองรับ partial application ทำให้หลีกเลี่ยง method overloading และ default parameter
-
ตอนแรกจึงทำเป็นแบบส่ง array กับชนิดของ flag เข้าไปดังนี้
-
-
cpu.setFlags [ Half, false; Zero, a = 0uy ]
-
-
ต่อมาระหว่างการรีแฟกเตอร์ จึงเปลี่ยนเป็นการทำด้วย pure function ตาม Cpu/State.fs L81
-
-
module Flags = let inline setZ (v: bool) (f: uint8) = if v then f ||| ZMask else f &&& ~~~ZMask
let inline setH (v: bool) (f: uint8) = // ... the other flag functions and definitions
-
// Other files
-
cpu.Flags <- cpu.Flags |> setH false |> setZ (a = 0uy)
-
-
ฟังก์ชันใหม่เหล่านี้นำไปประกอบต่อและทดสอบได้ง่าย และเป็น pure function ที่เรียบง่าย
-
แบบเดิมต้องยกระดับค่าไปเป็น discriminated union type และใส่ลงใน array จึงเขียนยืดยาวกว่า
-
ฟังก์ชันใหม่เป็น inline และไม่ต้องมี heap allocation จึงให้ประสิทธิภาพดีกว่าด้วย ทำให้ FPS ของอีมูเลเตอร์เพิ่มขึ้นราว 10%
-
-
การทดสอบ
- ในช่วงแรก การทำ CPU ใช้วิธีรัน Tetris ROM แล้วพอไปถึง opcode ที่ยังไม่ได้ทำ ก็ค่อยกลับมาเขียนคำสั่งนั้น
-
- match opcode with
- | 0x00 -> Nop
- | _ -> failwith "Unimplemented opcode"
-
- วิธีนี้ต้องสลับไปมาระหว่างเอกสารเทคนิคแบบสุ่ม ทำซ้ำแล้วค่อนข้างน่าเบื่อ และก็ยากจะรู้ด้วยว่าคำสั่งที่เขียนนั้นถูกต้องจริงหรือไม่
- เพื่อแก้ทั้งสองปัญหา จึงนำ unit test เข้ามาใช้
- โค้ดอีมูเลเตอร์เขียนเองเพื่อการเรียนรู้ แต่ใช้ AI ช่วยสร้าง test case
- นำสเปกจากเอกสารเทคนิคใส่ในพรอมป์ต์ แล้วให้เขียนการทดสอบตามสเปกโดยไม่ดูโค้ดอีมูเลเตอร์
- ระหว่างที่ AI สร้างการทดสอบ ก็อ่านสเปกเองและลงมือเขียนลอจิกจนกว่าการทดสอบจะผ่าน เป็นการทำ test-driven development แบบจริงจัง
- การทดสอบยังช่วยเจอบั๊กหลายจุดในคำสั่งที่เคยเขียนไปแล้วด้วย
- มีการทบทวนและปรับปรุงการทดสอบอย่างสม่ำเสมอ และมันช่วยให้เอาพลังไปใช้กับส่วนที่น่าสนใจได้มากขึ้น แทนที่จะมาขัดขวางการเรียนรู้
คอมโพเนนต์หลังจาก CPU
-
PPU
- ใน Game Boy ไม่ได้มี GPU แต่มี PPU หรือ picture processing unit
- บทความเกี่ยวกับการทำอีมูเลเตอร์ Game Boy อื่น ๆ มักโฟกัสที่ CPU และกล่าวถึง PPU เพียงไม่กี่ย่อหน้า แต่ใน Fame Boy การทำความเข้าใจ PPU ใช้เวลานานกว่า
- CPU ให้ความรู้สึกเป็นธรรมชาติอยู่แล้วจากประสบการณ์กับ From NAND to Tetris และ CHIP-8 แต่ PPU คล้ายงานเชิงกลไกที่ทำตามขั้นตอนเพื่อนำพิกเซลขึ้นสู่หน้าจอมากกว่า
- ในตอนแรก แทนที่จะพยายามทำความเข้าใจ pixel FIFO และ pipeline ของ PPU ทั้งหมดพร้อมกัน จึงเริ่มจากการอ่านและพาร์เซ tile กับ background map จากหน่วยความจำแล้วแสดงผลบนหน้าจอ
- วิธีนี้ทำให้มองเห็นการทำงานของ CPU ได้ และด้วยความเรียบง่ายของ Tetris ก็ได้ผลลัพธ์ที่ดูเกือบเหมือนเกม Game Boy จริง
- แนวทางที่เริ่มจากมุมมอง tile และ background นี้ยังช่วยต่อเนื่องตั้งแต่การทำหน้าจอจริงไปจนถึงการดีบักบั๊กละเอียดในข้อมูล sprite
- PPU ของ Fame Boy มีความไม่แม่นยำจากฮาร์ดแวร์จริงค่อนข้างมาก
- Game Boy จริงใช้ FIFO queue แบบจอ CRT เพื่อวางพิกเซลลงหน้าจอทีละจุด
- Fame Boy เรนเดอร์ทั้งสแกนไลน์ตั้งแต่เริ่มช่วงวาดของบรรทัดนั้น
- วิธีนี้เร็วกว่า โค้ดเรียบง่ายกว่า และเกมที่ต้องการเล่นก็ใช้งานได้ทั้งหมด จึงไม่รู้สึกจำเป็นต้องย้ายไปใช้ pixel queue
- เกมที่ใช้ฮาร์ดแวร์ Game Boy จนถึงขีดจำกัดและอาศัยจังหวะเวลาแบบ pixel queue จะทำงานได้ไม่ถูกต้องใน Fame Boy แต่เกมส่วนใหญ่ไม่ได้ใช้ฮาร์ดแวร์แบบสุดโต่งเช่นนั้น จึงน่าจะยังทำงานได้โดยรวม
-
Joypad
- นอกจาก PPU และ APU แล้ว ยังทำส่วน joypad ด้วย
- การติดตั้งใช้งานครั้งแรกง่ายมาก และการเขียนเทสต์ก็ไม่ยาก
- แต่หลังการรีแฟกเตอร์ครั้งใหญ่ก็มักพังเกือบทุกครั้ง
- รีจิสเตอร์ฮาร์ดแวร์ของ joypad มีความโต้ตอบซับซ้อน เพราะทั้ง CPU และเกมต่างก็อ่านและเขียนมัน
- ช่วงแรกให้ CPU เขียนสถานะ joypad ลงรีจิสเตอร์ทุกไซเคิล แต่เนื่องจากมนุษย์ไม่ได้เปลี่ยนปุ่มหลายล้านครั้งต่อวินาที จึงเปลี่ยนให้อัปเดตเพียงครั้งเดียวต่อเฟรม
- ผลคือ D-pad ใช้งานไม่ได้
- ฮาร์ดแวร์ Game Boy อ่านได้ครั้งละเพียงครึ่งหนึ่งของปุ่ม และเกมแทบทั้งหมดอาศัยการอ่านรีจิสเตอร์ joypad สองครั้งขึ้นไปในช่วงเวลาสั้น ๆ โดยคาดหวังให้รีจิสเตอร์เปลี่ยนระหว่างการอ่านทั้งสอง
- รีจิสเตอร์ที่แคชไว้ครั้งเดียวต่อเฟรมจึงไม่เปลี่ยนระหว่างการอ่านทั้งสอง ทำให้ปุ่มครึ่งหนึ่งใช้งานไม่ได้
- สุดท้ายจึงทำให้
IoControllerอัปเดตรีจิสเตอร์ joypad เฉพาะตอนที่ CPU อ่านเท่านั้น - ดูรายละเอียดเพิ่มเติมได้ใน เอกสาร joypad ของ Pandocs
-
เสียง
- หลังจากสร้างอีมูเลเตอร์ที่ใช้งานได้แล้ว พอลองเล่นเวอร์ชันเว็บก็รู้สึกว่าหากไม่มีเสียงมันดูว่างเปล่า จึงเพิ่ม APU หรือ audio processing unit
- พบว่าอีมูเลเตอร์หลายตัวไม่ได้ขับเคลื่อนตามเฟรมเรต แต่ขับเคลื่อนตามอัตราการสุ่มตัวอย่างเสียงของฟรอนต์เอนด์
- ตอนแรกสิ่งนี้ดูย้อนแย้ง จึงไปศึกษาการสุ่มตัวอย่างแบบไดนามิก และพยายามทำให้เฟรมเรตเป็นตัวขับเคลื่อนอีมูเลเตอร์
- เสียงเป็นคอมโพเนนต์ที่ยากที่สุดในเชิงแนวคิด และต้องใช้เวลาในการทำความเข้าใจการทำงานของรีจิสเตอร์เสียงและแชนเนลต่าง ๆ
- ในส่วนนี้ AI ช่วยได้มากในบทบาทคล้ายครู โดยมีการถามตอบกันหลายรอบก่อนเริ่มเขียนโค้ด
- คล้ายกับ PPU การทำแต่ละแชนเนลให้เสร็จทีละตัวให้ความรู้สึกน่าพอใจมาก และระหว่างที่ฟังเพลง Tetris ค่อย ๆ สมบูรณ์ขึ้น ก็เริ่มเข้าใจด้วยว่าดนตรีถูกประกอบขึ้นอย่างไร
- CPU และ PPU เป็นลักษณะที่ในแต่ละเฟรมจะทำงานจำนวน X ครั้งอย่างแน่นอน และคำนวณค่า X ได้ง่าย แต่ APU มีค่าจำนวนมากที่ต้องเลือกและจูน
- มีเพียงอัตราการสุ่มตัวอย่างของ APU เท่านั้นที่ตัดสินใจได้ง่าย
- APU ของ Game Boy จริงมีความยืดหยุ่น จึงสามารถใช้อัตราการสุ่มตัวอย่างใดก็ได้ตามที่อีมูเลเตอร์ต้องการ
- Fame Boy เลือก 32768Hz
- เมื่อใช้กับสัญญาณนาฬิกา CPU 1048576Hz ค่า 32768Hz จะเท่ากับ 1 sample ต่อ 128 CPU cycle ทำให้ซิงก์สถานะ APU ได้สมบูรณ์แบบด้วยจำนวนเต็มล้วน
- 128 ยังหารด้วย 4 ลงตัวด้วย จึงสามารถประมวลผลขั้นของ APU ทีละ 4 ขั้นได้โดยไม่ทำให้การจัดแนวกับคำสั่ง CPU เพี้ยน
- ค่าตัวอื่นไม่เสถียรกว่ามาก และเพราะไม่ใช่วิศวกรเสียง จึงต้องลองปรับค่าไปเรื่อย ๆ
- แต่ละฟรอนต์เอนด์และแต่ละแพลตฟอร์มมีปัญหาเฉพาะของตัวเอง
- บน PC เสียงทำงานดี แต่บน MacBook กลับฟังเหมือนเสียงน้ำตก
- พอแก้ปัญหาบน MacBook ได้ เวอร์ชันเดสก์ท็อป PC กลับรันไม่ขึ้นเพราะ race condition
- ในที่สุดก็เลิกพยายามแก้แบบชาญฉลาดด้วยการสุ่มตัวอย่างแบบไดนามิก แล้วเปลี่ยนให้เสียงเป็นตัวขับเคลื่อนอีมูเลเตอร์แทน ทำให้เสียงเสถียรกว่ามากในหลายอุปกรณ์
- เสียงเป็นส่วนที่รั่วไหลมากที่สุดในอินเทอร์เฟซระหว่างอีมูเลเตอร์กับฟรอนต์เอนด์ แต่หากต้องการหลีกเลี่ยงเสียงเพี้ยนก็จำเป็นต้องซิงก์ให้แม่นยำ
วิธีขับเคลื่อนอีมูเลเตอร์
- ความต่างระหว่างการขับเคลื่อนด้วยเสียงกับการขับเคลื่อนด้วยเฟรมเกี่ยวข้องกับการรับรู้ของมนุษย์
- หากสัญญาณเสียงขาดหาย ลำโพงจะเคลื่อนตัวแรงเพราะสัญญาณเปลี่ยนฉับพลัน จนเกิดเสียงป๊อป
- หากวิดีโอสะดุด โปรแกรมเล่นวิดีโอจะข้ามไปหนึ่งหรือสองเฟรมเพราะข้อมูลมาไม่ทัน แต่ไม่ได้ไปผลักสิ่งที่เป็นกายภาพ จึงรบกวนความรู้สึกน้อยกว่า
- ภายใน Fame Boy เสียงและวิดีโอซิงก์กันอย่างสมบูรณ์โดยการออกแบบ
- แต่เสียงและวิดีโอของคอมพิวเตอร์ที่กำลังรันนั้นเป็นอิสระต่อกัน และบางครั้งอย่างใดอย่างหนึ่งอาจช้ากว่าอีกฝ่าย
- หากเสียงและวิดีโอของฟรอนต์เอนด์ไม่ตรงกัน ก็มีอยู่สองทางเลือก
- ซิงก์เสียงของฟรอนต์เอนด์กับเสียงของอีมูเลเตอร์ แล้วดรอปเฟรมเป็นครั้งคราว
- ซิงก์วิดีโอของฟรอนต์เอนด์กับเฟรมของอีมูเลเตอร์ แล้วดรอปเสียงเป็นครั้งคราว
- ฝั่งที่เลือกจะเป็นตัว “ขับเคลื่อน” อีมูเลเตอร์ และอีกฝั่งจะถูกพยายามรักษาให้ใกล้เคียงที่สุด
- การขับเคลื่อนตามเฟรมเรตค่อนข้างเรียบง่าย
let mutable cycles = 0
while (runEmulator) do
cycles <- cycles + targetCyclesPerMs * lastFrameTime
while cycles > 0 do
let cyclesTaken = stepEmulator ()
cycles <- cycles - cyclesTaken
draw ppu.framebuffer
- การขับเคลื่อนด้วยเสียงซับซ้อนกว่า เพราะวิธีประมวลผลเสียงของ Raylib และ Web Audio ต่างกัน
- โฟลว์ทั่วไปเป็นดังนี้
let tryQueueAudio apu stepEmulator =
if frontend.audioBuffer.hasSpace () then
while apu.writeHead - apu.readHead < samplesNeeded do
stepEmulator ()
frontend.audioBuffer.fill apu.audioBuffer
while (runEmulator) do
tryQueueAudio apu stepEmulator
draw ppu.framebuffer
- ความต่างสำคัญคือ
stepEmulatorไม่ได้ถูกควบคุมด้วยlastFrameTimeอีกต่อไป แต่ขับเคลื่อนตามความต้องการของบัฟเฟอร์เสียงฝั่งฟรอนต์เอนด์ samplesNeededต้องคำนวณจำนวนครั้งที่ต้องเรียกstepEmulatorให้รองรับอัตราการสุ่มตัวอย่างที่ต่างกันและยังคงได้ 60FPS- บัฟเฟอร์เสียงของฟรอนต์เอนด์สนใจแค่การเติมตัวเองให้เต็ม จึงอาจเรียก
stepEmulatorมากเกินไปหรือน้อยเกินไปในหนึ่งเฟรม ส่งผลให้framebufferอาจไม่อัปเดตทันเวลา - ฟรอนต์เอนด์บนเว็บสามารถทดลองเวอร์ชันขับเคลื่อนด้วยเฟรมได้โดยเพิ่ม ?frame-driven ลงใน URL
- เวอร์ชันขับเคลื่อนด้วยเฟรมลื่นตากว่า แต่มีเสียงป๊อปเป็นบางครั้ง
- ฟรอนต์เอนด์เว็บแบบขับเคลื่อนด้วยเสียงก็จะสลับไปใช้แบบขับเคลื่อนด้วยเฟรมเมื่อกดปุ่มปิดเสียง เพราะจะไม่ได้ยินเสียงป๊อปอยู่แล้ว
- แม้การติดตั้งใช้งานจะยังไม่สมบูรณ์ แต่เพราะเสียงป๊อปให้ความรู้สึกแย่กว่าภาพสะดุด และสถานะปิดเสียงก็ดูว่างเปล่า จึงตั้งค่าเริ่มต้นของฟรอนต์เอนด์เว็บให้เป็นแบบขับเคลื่อนด้วยเสียง
- เสียงเป็นหนึ่งในไม่กี่จุดของ Fame Boy ที่ยังไม่ค่อยน่าพอใจ และเป็นส่วนที่อยากกลับมาปรับปรุงใหม่ในสักวันหนึ่ง
นำขึ้นเว็บด้วย Fable
- หลังจากที่ PPU เริ่มทำงานได้พอสมควรจนเริ่มมีบางอย่างแสดงบนหน้าจอเดสก์ท็อปแล้ว ก็พยายามย้าย Fame Boy ไปไว้บนเว็บ
- ดูเอกสารของ Fable ติดตั้งแพ็กเกจ ตั้งค่า main loop และเพิ่มสไตล์ จนพร้อมรันได้ภายในหนึ่งถึงสองชั่วโมง
- เวอร์ชัน Fable ที่ลองรันครั้งแรกแสดงผลหน้าจอผิดปกติ จึงลองดีบักอยู่เล็กน้อย แต่เพื่อไม่ให้ใช้เวลามากเกินไปก็หันไปลอง WebAssembly ของ Blazor
- Blazor เองก็รันได้ง่าย และครั้งนี้ใช้งานได้จริง แต่ได้เพียงประมาณ 8FPS จนแทบเล่นไม่ได้
- ยังไม่แน่ชัดว่าเป็นปัญหาของ Blazor เองหรือไม่ และแม้จะลองทำตามคู่มือปรับแต่งประสิทธิภาพของทีม .NET แล้วก็ไม่ช่วยอะไร
- การดีบักก็ไม่สะดวก จึงกลับไปใช้ Fable อีกครั้งเพื่อตรวจดูว่ามีอะไรผิดพลาดในกระบวนการแปลงเป็น JavaScript
- Fable จะวางไฟล์ JS ที่แปลงแล้วไว้ข้าง ๆ ซอร์สโค้ด และอ่านได้ง่ายพอสมควรจริง ๆ
- ทำให้เข้าใจโค้ดใหม่และดีบักใน browser developer tools ได้ง่าย
- ใน developer tools พบว่าค่ารีจิสเตอร์ของ CPU ผิดปกติ
- รีจิสเตอร์ CPU ของ Fame Boy และ Game Boy เป็นจำนวนเต็ม unsigned 8 บิต ดังนั้นค่าควรอยู่ในช่วง 0–255
- แต่กลับเห็นค่าอย่าง
-15565461
- จึงไปเจอ เอกสารความเข้ากันได้ของ numeric types ในเอกสารของ Fable
(non-standard) Bitwise operations for 16 bit and 8 bit integers use the underlying JavaScript 32 bit bitwise semantics. Results are not truncated as expected, and shift operands are not masked to fit the data type.
- เนื้อหาอธิบายว่าการคำนวณบิตของจำนวนเต็ม 16 บิตและ 8 บิตใช้ semantics ของการคำนวณบิตแบบ 32 บิตของ JavaScript และผลลัพธ์จะไม่ถูกตัดให้สั้นตามที่คาดไว้ ซึ่งตรงกับอาการที่พบพอดี
- หลังจากหาจุดในโค้ดที่ค่าขนาด 8 บิตควรถูกตัดให้พอดี และ แก้ปัญหาที่เกี่ยวข้อง แล้ว เว็บฟรอนต์เอนด์ก็ทำงานได้ถูกต้อง
- เนื่องจากใช้แค่ JS โดยไม่มี .NET runtime เว็บบันเดิลจึงมีขนาดประมาณ 100KB
- นอกจากปัญหา
uint8ที่ค่อนข้างแปลกแล้ว ประสบการณ์การใช้ Fable ถือว่าค่อนข้างราบรื่น และสามารถคงซอร์สโค้ดทั้งหมดไว้เป็น F# ได้
ปรับปรุงประสิทธิภาพ
- หลังจากเริ่มเห็นผลลัพธ์บนหน้าจอ ก็เพิ่มการล็อก FPS แบบง่าย ๆ ลงในคอนโซล
- ช่วงแรกในโหมดดีบักได้ประมาณ 55–60FPS และน่าจะเป็นผลจากที่ Raylib พยายามรักษา v-sync
- พอปิด v-sync ก็ขึ้นไปได้ราว 70FPS แต่เกิดอาการ jitter
- จากนั้นเมื่อเพิ่มฟีเจอร์มากขึ้น ประสิทธิภาพก็ค่อย ๆ ลดลงจนเหลือ 45FPS และแม้ปิด v-sync ก็ไม่ช่วย
- เมื่อรัน profiler ของ JetBrains Rider ก็พบว่า
mapAddressเป็นคอขวดที่น่าสงสัย - เนื่องจากแทบทุกคอมโพเนนต์เข้าถึงหน่วยความจำ จึงยืนยันได้ว่าต้นทุนของการเข้าถึงหน่วยความจำสูงกว่าที่คาดไว้
- โค้ดที่เป็นปัญหาใช้วิธีแมปที่อยู่หน่วยความจำไปยัง discriminated union ชื่อ
MemoryRegionก่อน แล้วค่อยอ่านหรือเขียน
type MemoryRegion =
| RomBase of offset: int
// ... others
let mapAddress (addr: int) : MemoryRegion =
match addr with
| a when a < 0x4000 -> RomBase a
// ... others
type DmgMemory(arr: uint8 array) =
// Arrays for romBase etc
member this.read address =
match mapAddress address with
| RomBase i -> romBase[i]
// ... others
member this.write address value =
match mapAddress address with
| RomBase _ -> ()
// ... others
- เดิมพยายามขยายแนวทางการทำ domain modeling ที่ได้จาก CPU ไปสู่หน่วยความจำด้วย ผลก็คือทุกครั้งที่มีการอ่านหรือเขียนหน่วยความจำจะต้องสร้างและแมปอ็อบเจ็กต์
MemoryRegion - วิธีนี้ทำให้มีการจัดสรรอ็อบเจ็กต์ลง heap หลายล้านชิ้นต่อวินาที และยังเพิ่มจำนวนกิ่งเงื่อนไขที่ JIT compiler ต้องจัดการ
- การเปลี่ยนแปลง เพียงครั้งเดียว โดยลบ discriminated union และฟังก์ชันแมปออก แล้วเข้าถึงอาร์เรย์โดยตรง ทำให้ FPS เพิ่มเป็นสองเท่า
- จากนั้นเมื่อทำ benchmark ก็ดูเหมือนว่าการปรับปรุงประสิทธิภาพส่วนใหญ่มาจากการเพิ่มประสิทธิภาพของ JIT ต่อกิ่งเงื่อนไขและจุดเรียกใช้ที่มีการแปลเป็นโค้ดเฉพาะมากขึ้น
- แม้จะเปลี่ยน
MemoryRegionให้เป็น struct DU เพื่อให้จัดสรรบนสแตก ประสิทธิภาพก็เพิ่มขึ้นเพียงราว 15% และอีก 85% ที่เหลือมาจากการลบ DU และฟังก์ชันแมป - หลังจากนั้นก็มีอีกหลายกรณีที่ย้ายไปใช้ struct DU หรือเลือกแนวทางที่ไม่ค่อยเป็นมิตรกับ F#
- ตั้งแต่ช่วงที่เริ่มทำ PPU ก็จำเป็นต้องปรับแต่งประสิทธิภาพ และต้องยอมลดความเป็น F# แบบ idiomatic ลงบ้าง
- ค่อย ๆ ปรับปรุงประสิทธิภาพโดยดู profiler เป็นประจำ จนขึ้นไปได้ประมาณ 120FPS
- การเพิ่ม FPS ที่ใหญ่ที่สุดคือการปิด debug build และในโหมด release ขึ้นไปได้ราว 1000FPS
- มีการติดตามและปรับแต่งประสิทธิภาพอย่างต่อเนื่องจนถึงท้ายที่สุด
Benchmark
- มองว่าการดูแค่ตัวเลข FPS ในคอนโซลไม่ใช่วิธีวัดประสิทธิภาพที่ดีนัก จึงเพิ่ม โปรเจ็กต์ BenchmarkDotNet เข้ามากลางทางเพื่อวัดประสิทธิภาพบนเดสก์ท็อป
- ต่อมาสร้าง เว็บ benchmarker แบบง่ายที่ใช้ Node.js เพื่อประเมินประสิทธิภาพของเว็บเบราว์เซอร์ในลักษณะใกล้เคียงกัน
- ใน benchmark ใช้เดโม ROM ต่อไปนี้เพื่อทดสอบสถานการณ์ที่สมจริง
- ประสิทธิภาพ FPS บนเดสก์ท็อปของ Windows PC ที่ใช้ Ryzen 9 7900 และ M4 MacBook Air มีดังนี้
| CPU | Flag | Roboto | Merken |
|---|---|---|---|
| Ryzen 9 7900 | 1785 | 1943 | 1422 |
| Apple M4 | 1907 | 2508 | 1700 |
- ประสิทธิภาพ FPS บนเว็บมีดังนี้
| CPU | Flag | Roboto | Merken |
|---|---|---|---|
| Ryzen 9 7900 | 646 | 883 | 892 |
| Apple M4 | 779 | 976 | 972 |
- Fame Boy ทำงานได้ในระดับน่าพอใจบนทั้งสองแพลตฟอร์ม
- ต่างจากที่คาดไว้ APU หรือระบบเสียงส่งผลต่อประสิทธิภาพของอีมูเลเตอร์มากกว่า PPU
- เมื่อปิด PPU ประสิทธิภาพบนเดสก์ท็อปจะเพิ่มขึ้นประมาณ 250FPS แต่เมื่อปิด APU จะเพิ่มขึ้นประมาณ 500FPS
การใช้ AI
- มองว่าแม้แต่ในโปรเจกต์เพื่อการเรียนรู้ก็ยากจะหลีกเลี่ยงอิทธิพลของ AI ได้อย่างสิ้นเชิง จึงบันทึกวิธีใช้ AI ไว้อย่างโปร่งใส
- ตลอดทั้งกระบวนการ AI ถูกใช้เป็นเครื่องมือเสริมเป็นหลัก
- ขอให้ช่วยรีวิวโค้ด
- เป็นคู่สนทนาไว้ช่วยตรวจสอบไอเดีย
- ช่วยตีความเอกสารเทคนิคแบบกระชับ
- พยายามลดปริมาณโค้ดที่ AI เขียนให้มากที่สุด
- เพราะอยากสร้างผลงานที่สามารถเอาไปให้คนอื่นดูแล้วภูมิใจได้ จึงไม่ได้เลือกวิธีแชร์แค่พรอมป์ต์ แต่ต้องการให้เป็นโค้ดที่ลงมือทำเอง
-
PR ปรับปรุงประสิทธิภาพ
- ช่วงท้ายของโปรเจกต์ได้ส่งรีโพซิทอรีให้ CLI แล้วให้มันลองหาวิธีปรับปรุงประสิทธิภาพ
- มอบไอเดียบางส่วนให้ และเปิดให้ลองวิธีอื่นตามต้องการด้วย ซึ่งทำให้ประสิทธิภาพในบางเบนช์มาร์กเพิ่มขึ้นมากกว่าสองเท่า
- รายละเอียดอยู่ใน PR
- อย่างไรก็ดีมีบั๊กแทรกเข้ามาด้วย และต้องตามหาแล้วแก้เอง
- หนึ่งในการปรับปรุงประสิทธิภาพครั้งใหญ่คือ “อัปเดต STAT เฉพาะตอนสลับ mode/LY” แต่สิ่งนี้ทำให้เกมและเดโมบางตัวที่พึ่งพาการอัปเดตถี่กว่านั้นพัง และได้แก้ด้วย commit แก้ไข
-
“ฤดูหนาวของตัวจับเวลา”
-
ใน Git history มีช่วงว่างยาวอยู่ และเรียกช่วงนั้นว่า “timer winter”
-
ไม่ใช่ว่าไม่ได้ทำงานกับอีมูเลเตอร์ แต่ติดอยู่กับบั๊กที่ทำให้ผ่านหน้าจอลิขสิทธิ์ของ Tetris ไม่ได้
-
ใช้เวลาดีบักเกิน 20 ชั่วโมง ค้นหาใน emu-dev Discord สร้างเทสต์ และโยนปัญหาให้โมเดล AI ยุคแรก ๆ แล้วก็ยังแก้ไม่ได้
-
พักไปหลายสัปดาห์ก่อน แล้วลองใช้ Claude Opus ซึ่งหาปัญหาเจอได้ภายในไม่กี่นาที
-
ปัญหาคือตัวจับเวลาถูก tick แค่ครั้งเดียวต่อหนึ่งคำสั่ง แทนที่จะ tick ตามจำนวน cycle ที่คำสั่งนั้นใช้ไป
-
-
let stepEmulator () = let cyclesTaken = stepCpu cpu
// Before stepTimers timer memory // only once per instruction
// The fix for _ in 1..cyclesTaken do // cpuCycles can vary between 1 and 6 stepTimers timer memory
-
-
เนื่องจาก CPU cycle อาจแตกต่างกันได้ตั้งแต่ 1 ถึง 6 ในการทำงานแบบเดิมตัวจับเวลาจึงทำงานช้ากว่าความเป็นจริงโดยเฉลี่ยราว 2–3 เท่า
-
หน้าจอลิขสิทธิ์ไม่ได้ค้าง แต่แค่แสดงอยู่นานกว่าปกติ และปัญหาคือไม่เคยลองรอ 1–2 นาที
-
ตัวบทความหลักส่วนใหญ่เขียนเอง
-
สิ่งที่ได้เรียนรู้และบทสรุป
- เป้าหมายหลักคือการเรียนรู้ว่าคอมพิวเตอร์ทำงานอย่างไร และในแง่นั้นก็ถือว่าประสบความสำเร็จมาก
- งานนี้สนุกมาก และมักเริ่มจากหลังเลิกงานด้วยความคิดว่า “วันนี้เอาแค่ฟีเจอร์เดียว” ก่อนจะจบลงด้วยการจมอยู่กับมันจนถึงตีสองเพราะอยากแก้บั๊กอีกแค่อันเดียว
- เคยคิดว่าจะลองทำ Game Boy Advance ต่อไหม แต่พอดูสเปกแล้วเหมือนว่าความเข้าใจด้านฮาร์ดแวร์จะเพิ่มขึ้นแค่ราว 20% ขณะที่ความพยายามที่ต้องใช้กลับมากขึ้นประมาณ 3 เท่า
- Game Boy เป็นจุดสมดุลที่ดีสำหรับการเรียนรู้ และตอนนี้ก็น่าจะหยุดไว้แค่นี้ได้สักพัก
- ไม่แน่ใจว่าตัวเองกลายเป็นซอฟต์แวร์เอนจิเนียร์ที่ดีขึ้นหรือไม่ แต่แน่นอนว่าเข้าใจเครื่องมือที่ใช้ทุกวันมากขึ้นเล็กน้อย
- หากมีคำถามหรือความเห็น สามารถส่งมาได้ทางอีเมล
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
ดีใจที่ได้เห็น F# ที่นี่! อีมูเลเตอร์เป็นวิธีที่ดีในการเรียนรู้ภาษา และพอดูคร่าว ๆ ครั้งแรกก็ดูเหมือนว่าจะเลือกใช้ F# แบบเป็นธรรมเนียมและแบบไม่ค่อยเป็นธรรมเนียมได้เหมาะกับแต่ละงานดี
จุดที่ปรับปรุงได้ง่ายเพื่อลดการจัดสรรคือใส่
[<Struct>]ให้กับ discriminated union ในInstructions.fsและสามารถใช้ชื่อฟิลด์ซ้ำเพื่อให้มีการใช้ฟิลด์ภายในร่วมกันได้เป็นข้อสังเกตเล็กน้อย แต่การจัดการรีจิสเตอร์บางส่วนชวนให้งงนิดหน่อย เนื่องจากเป็นชนิด
byteอยู่แล้ว การทำa &&& 0xFFuyใน setter ก็ดูไม่ได้เพิ่มอะไรจากmember val A = 0uy with get, setเลย น่าจะเป็นร่องรอยจากการเปลี่ยนระหว่างพัฒนาRegisterมีคอมเมนต์แบบนี้อยู่: รีจิสเตอร์ต้องตัดค่าให้เหลือ 8 บิตตอนเขียน จึงไม่สามารถเป็น record type ได้และต้องมี setterคำอธิบายคือเป็นเพราะเว็บเรนเดอเรอร์ โดย Fable จะแปลง
uint8เป็นNumberใน JS ทำให้เกิน 8 บิตได้และไม่ได้บังคับให้ตัดทอนดังนั้นสำหรับเป้าหมายเว็บ โค้ดนี้จึงดูเป็นการจัดระเบียบข้อมูลแบบเผื่อไว้ เพราะ Fable บน JS จะขยายเป็น
Numberในที่สุดก็มีคนลงแรงของมนุษย์จริง ๆ เพื่อ เรียนรู้อะไรบางอย่าง และไม่ใช่แนว “LLM ช่วยให้สร้าง X ได้ใน Y นาที” ซึ่งดีมาก
ดูเหมือนว่ายังพอมีความหวังให้มนุษยชาติอยู่บ้าง
แต่อีมูเลเตอร์นี่เจ๋งจริง และ อีมูเลเตอร์ GBA ก็เป็นเป้าหมายที่เหมาะจะลองทำเองด้วย
ไม่รู้สึกว่าเป็น AGI เลย มันดูเหมือนเครื่องลอกงานที่แค่แต่งหน้าปิดไว้
สักจุดหนึ่งคงมีใครบางคนใน Microsoft สังเกตเห็นและเปิดสัญญาณเตือน RLHF เลยทำให้ GPT ดีขึ้นมากพอสมควร และดูเหมือนจะใช้กับ F# ได้พอตัว ถ้าเป็นนักพัฒนา F# ที่ไม่ยึดหลักมากนัก ทุกวันนี้อาจใช้เอเจนต์แล้วทำงานได้ดีไปแล้วก็ได้
แต่สิ่งที่ผมรู้สึกไม่ใช่ “แก้ปัญหาการลอกงานได้แล้ว งั้นมาสร้างขยะกันต่อเถอะ” แต่เป็น “ตอนนี้แม้ ChatGPT จะลอกงาน ก็จะดูไม่โจ่งแจ้งอีกต่อไปสินะ”
ผมไม่อยากทอย d100 หรือ d1000 กับโอกาสที่จะทำลายหนึ่งในคุณค่าหลักของตัวเองทั้งหมด เพื่อแลกกับประสิทธิภาพที่สูงขึ้น ผมยอมช้าและตกงานต่อไปดีกว่า พูดจริง ๆ ตอนนี้กำลังมองไปทางงานติดตั้งโซลาร์กับเก็บของเก่าอยู่
ปัญหาแบบ “นักศึกษาไม่อยากคิด” มีมานานกว่า LLM มาก ผมเรียนวิชา PDE ระดับปีท้าย ๆ เมื่อปี 2007 แล้วเพราะผมเป็นคนเดียวที่ตั้งใจเรียน PDE จริง ๆ ผมเลยทำการบ้านได้แทบทั้งหมด และเพราะอ่อนทางจิตใจจนปฏิเสธพวกนักศึกษาคณิตศาสตร์ขี้เกียจนิสัยแย่ไม่ได้ เกือบทุกคนก็ลอกการบ้านผมไปหมด ตอนเรียนคณิตศาสตร์ระดับบัณฑิตศึกษาก็เกิดอีก เหลือเชื่อจริง ๆ ถ้าอย่างนั้นจะมาเรียนหลักสูตรนี้กันทำไมก็ไม่รู้
อา F# ความรักอันยิ่งใหญ่ของผม ผมอยากให้คนฝั่ง C# มาดูนี่แทนที่จะคอยทำให้ C# กลายเป็นภาษาที่ทำได้ทุกอย่างแต่ทำได้แบบง่อย ๆ มากขึ้นเรื่อย ๆ
ถ้าสร้างโปรเจ็กต์ที่ใช้ C# กับ F# ร่วมกัน ก็จะได้สิ่งที่ถูกเพิ่มเข้า C# อยู่เรื่อย ๆ แบบที่ใช้งานได้จริงและถูกหลักสรีรศาสตร์ ทำไมคนถึงมองไม่เห็นจุดนี้ก็ไม่รู้ และการทำงานร่วมกันก็ยอดเยี่ยมมาก
คุณจะใช้ F# แบบภาษาเชิงฟังก์ชันไปได้ไกลพอสมควร แต่สุดท้ายก็มักอยากทำงานร่วมกับระบบนิเวศ .NET และพอถึงจุดนั้นก็จะลงเอยด้วยการเขียนโค้ดในสไตล์ลูกผสมเชิงวัตถุ/เชิงฟังก์ชันที่แปลก ๆ
F# เป็นภาษาที่ดี แต่ให้ความรู้สึกเหมือนอยู่ใต้เงาของ C# ตลอดไป โค้ดไลบรารีจำนวนมากสืบทอดมาจาก C# และ .NET ไม่ได้เป็นอินเทอร์เฟซหรือไลบรารีที่ออกแบบมาโดยคำนึงถึง F# ตั้งแต่แรก และก็มักไม่มีเอกสารการใช้งานสำหรับ F# โดยเฉพาะอย่างชัดเจน
ปัญหาใหญ่กว่าคือชุมชน C# ชอบแนวคิดเชิงวัตถุ ดังนั้นถ้าคุณอยากทำงานในแบบฟังก์ชัน ก็มักต้องห่อไลบรารีพวกนี้ให้มีรูปแบบที่ “ฟังก์ชันกว่าเดิม”
ถึงอย่างนั้นก็ยังดีกว่าไม่มีอะไรเลยมาก ผมก็ชอบ Haskell กับ OCaml แต่ในแง่นี้มันเทียบกันได้
การทำงานร่วมกับ C# ทำให้การรับประกันที่โค้ด F# มักพึ่งพา โดยเฉพาะเรื่อง immutability อ่อนลง และเพราะวิธีที่มันถูกแมปไปยัง C# จึงมีข้อจำกัดแปลก ๆ โผล่มาใน generic ด้วย
เจ๋งมาก! ผมชอบ F# แต่จากการที่เคยเขียน อินเทอร์พรีเตอร์ Smalltalk ด้วย F# ก็ยืนยันได้ว่า ถ้าใช้มันในแนวทางที่ตั้งใจไว้สำหรับงานประเภทนี้ มันไม่ได้เป็นปีศาจด้านความเร็วเป๊ะ ๆ
เช่น ปกติผมชอบโครงสร้างข้อมูล
Mapซึ่งเป็นโครงสร้าง immutable ที่ดีมากและเพียงพอสำหรับการใช้งานส่วนใหญ่ แต่ถ้าประสิทธิภาพเริ่มสำคัญ การถอยไปใช้ลูปแบบ imperative ธรรมดา ๆ กับแฮชแมปทั่วไปก็ไม่ใช่เรื่องยากถ้ากักทุกอย่างไว้ในฟังก์ชันเดียว โดยทั่วไปก็ยังพอหลีกเลี่ยงความรู้สึกว่าเขียนเละเทะเกินไปได้
พวกเขายังทุ่มเทกับการปรับปรุง tail call ซึ่งแม้แต่คอมไพเลอร์ C# เองก็ยังไม่ได้ใช้ด้วยซ้ำ แถว ๆ .NET 9 หรือ 10 ยังมีการเพิ่มคุณลักษณะที่ทำให้คอมไพเลอร์แจ้งข้อผิดพลาดเมื่อมี recursive call ที่ไม่ใช่ tail call ใน F# เพื่อกันไม่ให้พลาดทำเสียเอง
แค่ถ้าใช้ linked list, sequence และชนิดข้อมูล immutable ไปทั่ว มันก็ไม่ใช่ Rust แน่นอน
เป็นโปรเจ็กต์ที่ยอดเยี่ยม! ชอบมากที่ได้เห็นอะไรแบบนี้
ในอีกด้านหนึ่ง นี่ไม่ใช่การตัดสินผู้เขียนหรือตัวงานนะ แต่พอได้เห็นว่าโค้ด F# ในโปรเจ็กต์จริงหน้าตาเป็นอย่างไร ก็รู้สึกว่าคงเลิกอยาก เรียนและใช้ F# ได้แล้ว
ส่วนที่เป็นฟังก์ชันล้วนสวยงามมาก แต่พอลงไปในโค้ดที่ imperative มากขึ้นหรือมีการเปลี่ยนแปลงสถานะ ก็รู้สึกว่าหน้าตาค่อนข้างไม่สวย และน่าเสียดายที่ในโปรเจ็กต์จริงส่วนใหญ่ก็คงต้องลงเอยแบบนั้น
เลยไม่แน่ใจว่าควรเลือกภาษาเชิงฟังก์ชันอื่นแล้วกระโดดลงไปเลย หรือควรโฟกัสกับการนำแนวคิดเชิงฟังก์ชันมาใช้กับภาษาที่ใช้อยู่แล้ว ซึ่งก็คือ C# และมันก็รองรับกระบวนทัศน์นี้เพิ่มขึ้นเรื่อย ๆ ทำให้ตัวเลือกหลังทำได้ค่อนข้างง่าย
อีมูเลเตอร์ที่เขียนด้วยภาษาเชิงฟังก์ชันมักน่าประทับใจเสมอ เพราะโดยปกติการแมปฮาร์ดแวร์เข้ากับภาษาเชิงคำสั่งนั้นง่ายกว่ามาก สนุกดีที่จะได้เห็นว่าผู้คนสร้าง นามธรรมเชิงฟังก์ชัน แบบไหนขึ้นมา
F# เป็นภาษาที่สนุกจริง ๆ และเป็นงานที่ยอดเยี่ยม!
F# คือภาษาที่ผมรักในเชิงการเขียนโค้ด แต่ไม่มีวันได้ใช้ในงานจริง นอกเหนือจากโปรเจ็กต์ส่วนตัวก็ไม่มีโอกาสใช้เลย :(
เป็นบทความที่น่าสนใจและอ่านเพลิน ชอบส่วนของ การทำแบบจำลองข้อมูล มาก ตอนนี้กำลังลองเล่น OCaml อยู่ และการทำโมเดลแบบนั้นคือส่วนที่ดีที่สุดจริง ๆ
การได้รู้จัก CAMLBOY ก็เป็นเรื่องที่น่าสนใจด้วย ถ้าจะให้ข้อเสนอแนะกับผู้เขียน ผมว่าน่าจะข้ามขั้นตอนการเกลาโดย AI ไปเลยดีกว่า ผมน่าจะชอบบทความที่มีข้อผิดพลาดทางไวยากรณ์หรือสำนวนที่ยังไม่เนี้ยบอยู่บ้าง มากกว่าบทความที่ราบเรียบแบบตอนนี้