1 คะแนน โดย GN⁺ 1 시간 전 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • 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 register
      • indirect คือการอ่านและเขียนตำแหน่งหน่วยความจำที่ 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
    • หลังจากใช้ 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 ต่อไปนี้เพื่อทดสอบสถานการณ์ที่สมจริง
    • Flag: ลูปสั้น ๆ ที่ไม่มีเสียง
    • Roboto: เดโมที่รันยาวเกิน 1 นาที ใช้เอฟเฟกต์ภาพและเสียงจำนวนมาก
    • Merken: คล้ายกับ Roboto แต่ใช้ ROM แบบ memory banking เพื่อทดสอบหน่วยความจำ
  • ประสิทธิภาพ 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 ความคิดเห็น

 
GN⁺ 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
    • ในบทความเองก็พูดถึงเรื่องนี้ในส่วนของการ พอร์ตไป Fable และบอกว่าลองใช้ Blazor ด้วย
  • ในที่สุดก็มีคนลงแรงของมนุษย์จริง ๆ เพื่อ เรียนรู้อะไรบางอย่าง และไม่ใช่แนว “LLM ช่วยให้สร้าง X ได้ใน Y นาที” ซึ่งดีมาก
    ดูเหมือนว่ายังพอมีความหวังให้มนุษยชาติอยู่บ้าง

    • วิธีแบบนั้นจะยังคงอยู่เสมอ ในปี 2026 ก็ยังมีคนทำของด้วยเครื่องมือช่างพื้นฐานอยู่เลย งั้นเรียกสิ่งนี้ว่า งานโค้ดแบบหัตถกรรม ก็แล้วกัน
    • ผมว่าควรเลิกหวังกับมนุษยชาติตั้งแต่ช่วงสหภาพโซเวียตล่มสลายแล้ว
      แต่อีมูเลเตอร์นี่เจ๋งจริง และ อีมูเลเตอร์ GBA ก็เป็นเป้าหมายที่เหมาะจะลองทำเองด้วย
    • ผมทำงานเป็นนักพัฒนา F# มานาน และก็เจอการกลั่นแกล้งในวงการวิชาการสาย STEM มานานเหมือนกัน เลยไม่ใช้ LLM เหตุผลใหญ่คือ ChatGPT-3.5 ให้ความรู้สึกเหมือนคัดลอกจากคลังเก็บโค้ด F# บน GitHub แบบโจ่งแจ้งเกินไป
      ไม่รู้สึกว่าเป็น AGI เลย มันดูเหมือนเครื่องลอกงานที่แค่แต่งหน้าปิดไว้
      สักจุดหนึ่งคงมีใครบางคนใน Microsoft สังเกตเห็นและเปิดสัญญาณเตือน RLHF เลยทำให้ GPT ดีขึ้นมากพอสมควร และดูเหมือนจะใช้กับ F# ได้พอตัว ถ้าเป็นนักพัฒนา F# ที่ไม่ยึดหลักมากนัก ทุกวันนี้อาจใช้เอเจนต์แล้วทำงานได้ดีไปแล้วก็ได้
      แต่สิ่งที่ผมรู้สึกไม่ใช่ “แก้ปัญหาการลอกงานได้แล้ว งั้นมาสร้างขยะกันต่อเถอะ” แต่เป็น “ตอนนี้แม้ ChatGPT จะลอกงาน ก็จะดูไม่โจ่งแจ้งอีกต่อไปสินะ”
      ผมไม่อยากทอย d100 หรือ d1000 กับโอกาสที่จะทำลายหนึ่งในคุณค่าหลักของตัวเองทั้งหมด เพื่อแลกกับประสิทธิภาพที่สูงขึ้น ผมยอมช้าและตกงานต่อไปดีกว่า พูดจริง ๆ ตอนนี้กำลังมองไปทางงานติดตั้งโซลาร์กับเก็บของเก่าอยู่
      ปัญหาแบบ “นักศึกษาไม่อยากคิด” มีมานานกว่า LLM มาก ผมเรียนวิชา PDE ระดับปีท้าย ๆ เมื่อปี 2007 แล้วเพราะผมเป็นคนเดียวที่ตั้งใจเรียน PDE จริง ๆ ผมเลยทำการบ้านได้แทบทั้งหมด และเพราะอ่อนทางจิตใจจนปฏิเสธพวกนักศึกษาคณิตศาสตร์ขี้เกียจนิสัยแย่ไม่ได้ เกือบทุกคนก็ลอกการบ้านผมไปหมด ตอนเรียนคณิตศาสตร์ระดับบัณฑิตศึกษาก็เกิดอีก เหลือเชื่อจริง ๆ ถ้าอย่างนั้นจะมาเรียนหลักสูตรนี้กันทำไมก็ไม่รู้
  • อา F# ความรักอันยิ่งใหญ่ของผม ผมอยากให้คนฝั่ง C# มาดูนี่แทนที่จะคอยทำให้ C# กลายเป็นภาษาที่ทำได้ทุกอย่างแต่ทำได้แบบง่อย ๆ มากขึ้นเรื่อย ๆ
    ถ้าสร้างโปรเจ็กต์ที่ใช้ C# กับ F# ร่วมกัน ก็จะได้สิ่งที่ถูกเพิ่มเข้า C# อยู่เรื่อย ๆ แบบที่ใช้งานได้จริงและถูกหลักสรีรศาสตร์ ทำไมคนถึงมองไม่เห็นจุดนี้ก็ไม่รู้ และการทำงานร่วมกันก็ยอดเยี่ยมมาก

    • แต่ถ้ามาจากโลกของ OCaml ก็จะรู้สึกเสียดายนิดหน่อยที่ F# เหมือนถูกขังอยู่ใต้เงาของ C#
      คุณจะใช้ F# แบบภาษาเชิงฟังก์ชันไปได้ไกลพอสมควร แต่สุดท้ายก็มักอยากทำงานร่วมกับระบบนิเวศ .NET และพอถึงจุดนั้นก็จะลงเอยด้วยการเขียนโค้ดในสไตล์ลูกผสมเชิงวัตถุ/เชิงฟังก์ชันที่แปลก ๆ
  • F# เป็นภาษาที่ดี แต่ให้ความรู้สึกเหมือนอยู่ใต้เงาของ C# ตลอดไป โค้ดไลบรารีจำนวนมากสืบทอดมาจาก C# และ .NET ไม่ได้เป็นอินเทอร์เฟซหรือไลบรารีที่ออกแบบมาโดยคำนึงถึง F# ตั้งแต่แรก และก็มักไม่มีเอกสารการใช้งานสำหรับ F# โดยเฉพาะอย่างชัดเจน

    • การแปลงวิธีใช้ไลบรารีจาก C# มาเป็น F# ค่อนข้างเป็นงานเชิงกลอยู่แล้ว เลยไม่แน่ใจว่าเอกสารแยกจำเป็นขนาดนั้นไหม
      ปัญหาใหญ่กว่าคือชุมชน C# ชอบแนวคิดเชิงวัตถุ ดังนั้นถ้าคุณอยากทำงานในแบบฟังก์ชัน ก็มักต้องห่อไลบรารีพวกนี้ให้มีรูปแบบที่ “ฟังก์ชันกว่าเดิม”
      ถึงอย่างนั้นก็ยังดีกว่าไม่มีอะไรเลยมาก ผมก็ชอบ Haskell กับ OCaml แต่ในแง่นี้มันเทียบกันได้
    • เป็นความจริงที่ปฏิสัมพันธ์ของทั้งสองภาษาทำให้เกิดความขัดเขินบางส่วน แต่ผมคิดว่าประเด็นใหญ่ไม่ใช่ว่าไลบรารีไหนต้องถูกแมปให้เข้ากับ F# ให้ดีเป็นพิเศษ แต่เป็นเรื่องการเข้าใจกฎของ การทำงานร่วมกัน และรูปแบบของผลลัพธ์ภายในที่ถูกสร้างออกมามากกว่า
      การทำงานร่วมกับ C# ทำให้การรับประกันที่โค้ด F# มักพึ่งพา โดยเฉพาะเรื่อง immutability อ่อนลง และเพราะวิธีที่มันถูกแมปไปยัง C# จึงมีข้อจำกัดแปลก ๆ โผล่มาใน generic ด้วย
  • เจ๋งมาก! ผมชอบ F# แต่จากการที่เคยเขียน อินเทอร์พรีเตอร์ Smalltalk ด้วย F# ก็ยืนยันได้ว่า ถ้าใช้มันในแนวทางที่ตั้งใจไว้สำหรับงานประเภทนี้ มันไม่ได้เป็นปีศาจด้านความเร็วเป๊ะ ๆ

    • ผมพบว่าใน F# ถ้าเขียนแบบ imperative จนแทบจะทื่อ ๆ แต่กักผลข้างเคียงไว้ภายในฟังก์ชัน จะได้ประสิทธิภาพดีกว่า แบบนั้นฟังก์ชันก็ยังคง “บริสุทธิ์” ได้ในทางปฏิบัติพร้อมกับความเร็วที่โอเค
      เช่น ปกติผมชอบโครงสร้างข้อมูล Map ซึ่งเป็นโครงสร้าง immutable ที่ดีมากและเพียงพอสำหรับการใช้งานส่วนใหญ่ แต่ถ้าประสิทธิภาพเริ่มสำคัญ การถอยไปใช้ลูปแบบ imperative ธรรมดา ๆ กับแฮชแมปทั่วไปก็ไม่ใช่เรื่องยาก
      ถ้ากักทุกอย่างไว้ในฟังก์ชันเดียว โดยทั่วไปก็ยังพอหลีกเลี่ยงความรู้สึกว่าเขียนเละเทะเกินไปได้
    • อยากรู้ว่าเขียนอินเทอร์พรีเตอร์นั้นเมื่อไหร่ เพราะ ระบบนิเวศ .NET ทั้งหมด เร็วขึ้นมากในช่วงหลายปีที่ผ่านมา และถ้าใครใช้ครั้งสุดท้ายตั้งแต่ยุค Framework จะรู้สึกต่างมาก
      พวกเขายังทุ่มเทกับการปรับปรุง tail call ซึ่งแม้แต่คอมไพเลอร์ C# เองก็ยังไม่ได้ใช้ด้วยซ้ำ แถว ๆ .NET 9 หรือ 10 ยังมีการเพิ่มคุณลักษณะที่ทำให้คอมไพเลอร์แจ้งข้อผิดพลาดเมื่อมี recursive call ที่ไม่ใช่ tail call ใน F# เพื่อกันไม่ให้พลาดทำเสียเอง
    • ถ้าระวังว่าเมื่อไรควรใช้ฟีเจอร์ไหน F# ก็เร็วได้มาก คุณใช้กระบวนทัศน์เชิงฟังก์ชันเมื่ออยากใช้ และเมื่อจำเป็นก็เขียนโค้ด imperative ระดับล่างในฮอตลูปได้
      แค่ถ้าใช้ linked list, sequence และชนิดข้อมูล immutable ไปทั่ว มันก็ไม่ใช่ Rust แน่นอน
  • เป็นโปรเจ็กต์ที่ยอดเยี่ยม! ชอบมากที่ได้เห็นอะไรแบบนี้
    ในอีกด้านหนึ่ง นี่ไม่ใช่การตัดสินผู้เขียนหรือตัวงานนะ แต่พอได้เห็นว่าโค้ด F# ในโปรเจ็กต์จริงหน้าตาเป็นอย่างไร ก็รู้สึกว่าคงเลิกอยาก เรียนและใช้ F# ได้แล้ว
    ส่วนที่เป็นฟังก์ชันล้วนสวยงามมาก แต่พอลงไปในโค้ดที่ imperative มากขึ้นหรือมีการเปลี่ยนแปลงสถานะ ก็รู้สึกว่าหน้าตาค่อนข้างไม่สวย และน่าเสียดายที่ในโปรเจ็กต์จริงส่วนใหญ่ก็คงต้องลงเอยแบบนั้น
    เลยไม่แน่ใจว่าควรเลือกภาษาเชิงฟังก์ชันอื่นแล้วกระโดดลงไปเลย หรือควรโฟกัสกับการนำแนวคิดเชิงฟังก์ชันมาใช้กับภาษาที่ใช้อยู่แล้ว ซึ่งก็คือ C# และมันก็รองรับกระบวนทัศน์นี้เพิ่มขึ้นเรื่อย ๆ ทำให้ตัวเลือกหลังทำได้ค่อนข้างง่าย

  • อีมูเลเตอร์ที่เขียนด้วยภาษาเชิงฟังก์ชันมักน่าประทับใจเสมอ เพราะโดยปกติการแมปฮาร์ดแวร์เข้ากับภาษาเชิงคำสั่งนั้นง่ายกว่ามาก สนุกดีที่จะได้เห็นว่าผู้คนสร้าง นามธรรมเชิงฟังก์ชัน แบบไหนขึ้นมา

    • ไม่รู้ว่าได้ดูโค้ดหรือยัง F# มีทั้งตัวแปรที่เปลี่ยนค่าได้และอาร์เรย์ และโปรเจ็กต์นี้ก็ใช้สิ่งเหล่านั้นกับ หน่วยความจำ เป็นต้น
  • F# เป็นภาษาที่สนุกจริง ๆ และเป็นงานที่ยอดเยี่ยม!

  • F# คือภาษาที่ผมรักในเชิงการเขียนโค้ด แต่ไม่มีวันได้ใช้ในงานจริง นอกเหนือจากโปรเจ็กต์ส่วนตัวก็ไม่มีโอกาสใช้เลย :(

  • เป็นบทความที่น่าสนใจและอ่านเพลิน ชอบส่วนของ การทำแบบจำลองข้อมูล มาก ตอนนี้กำลังลองเล่น OCaml อยู่ และการทำโมเดลแบบนั้นคือส่วนที่ดีที่สุดจริง ๆ
    การได้รู้จัก CAMLBOY ก็เป็นเรื่องที่น่าสนใจด้วย ถ้าจะให้ข้อเสนอแนะกับผู้เขียน ผมว่าน่าจะข้ามขั้นตอนการเกลาโดย AI ไปเลยดีกว่า ผมน่าจะชอบบทความที่มีข้อผิดพลาดทางไวยากรณ์หรือสำนวนที่ยังไม่เนี้ยบอยู่บ้าง มากกว่าบทความที่ราบเรียบแบบตอนนี้