ทำ visual assembly canvas มา 2 เดือนแล้ว~ รู้สึกว่า technical challenge ในโปรเจคนี้มันส์มาก แต่ก็ยังคงความ follow the fun ที่อินกับเรื่องอะไรก็สร้างเลย
ต้นเดือนเราไป music festival เลยได้ไอเดียว่าอยากเขียน assembly มาสร้างอะไรที่เป็น audio-visual ได้ ลองจูน performance ให้เร็วจนใช้กับงาน real-time ได้สบาย จากเมื่อก่อนที่เคยมีแค่ text ตอนนี้มี synthesizer, midi, visual ให้ใช้แล้ว~
ก่อนหน้านี้เราไม่ได้มี use case ที่ต้องการความเร็ว เพราะแค่อยากให้ machine คุยกัน แต่พอเราจะต่อกับ launchpad ให้ synthesizer เล่นโน๊ตแบบ real-time หรือเล่น bad apple at 60FPS ทำให้ performance budget มันน้อยมาก ถ้าเกิน 12ms ก็สังเกตได้เลยว่ามันแลค
คอขวดหลักคือก่อนหน้านี้เรา expose tick() method ให้ฝั่ง JavaScript เรียก ซึ่งพอ JavaScript เรียก Rust ทุก 10ms มันจะช้ามากๆ เพราะ FFI boundary มันมี overhead เราเลยให้มัน batch ได้ว่า 1 method call จะรันกี่ instruction cycles และจะให้ tick พวก visual nodes กี่ครั้ง
อันนี้ทำให้ setInterval จากฝั่ง JS ไม่ต้องถี่มาก เพราะเรา batch 10k ops เข้าไปใน tick เดียว หรือ batch 10k block ticks เข้าใน call เดียวก็ได้
แต่ด้วยความที่เราใช้วิธี message passing มันจะจบ tick นั้นทันทีที่เจอคำสั่งให้ suspend เพื่อรอข้อความเข้ามาในคิว ทำให้แต่ละ tick call มันจะช้าเร็วต่างกันพอสมควร เพราะ WebAssembly มัน sleep ไม่ได้ สุดท้ายก็ต้องให้ event loop ของ JS engine จัดการอยู่ดี
นอกจากนั้น เราทำให้มัน batch process side effects ต่างๆ เช่น midi notes หรือ synthesizers กับแยก render tasks ให้ไปรันใน requestAnimationFrame ออกจาก compute tasks ซึ่งอันไหนที่ไม่ได้อยากให้อัพเดตบ่อย เราก็ให้มันอัพเดตแค่บาง frame ก็พอ กับสร้าง wavetables ให้มันไม่ต้องคำนวณ sin/cos/tan ใหม่ อ่านจากแคชเอา
[mapped memory]
ฟีเจอร์อันนึงที่เพิ่มให้ engine คือทำให้มี memory-mapped addresses ได้ เพราะในโลกจริงเวลาเราจะคุยกับ hardware ปกติเราก็เขียนลง address ทั้งนั้น เช่นจะเล่นเพลงเราก็อาจจะเขียน bytes ลง 0x4000 - 0x4012 ในแต่ละ clock cycle
ตอนนี้เราเลยทำให้ instructions ที่เขียน memory อย่าง load และ store สามารถเขียนเข้าไปใน mapped memory address ให้มันคุยกับ hardware อื่นได้ กับเพิ่ม instruction read กับ write ให้อ่านและเขียนข้อมูลจำนวนมากใน instruction เดียวได้ แนว SIMD แต่ง่ายกว่านั้น
วิธีก็คือเช็คว่า address อยู่ใน mapped regions มั้ย ถ้าอยู่ก็ส่ง message ไปหา actor ตัวอื่น ตอนนี้รองรับ synthesizer, pixel, plotter, midi in, midi out แล้วเรียบร้อย เช่นถ้าเราเขียนข้อมูลไปที่ 0x2205 ก็จะเป็นการส่ง write message ไปที่บล็อกที่เสียบ port 2 ไว้ ซึ่งมันอาจจะไป update pixel บนภาพ หรืออาจจะไปเล่นโน๊ตบน synthesizer ก็ได้
ตอนแรกว่าจะให้มัน write ตรงๆ ไปที่ block ปลายทางเลย แต่เพราะว่า machine block เราจะไม่สามารถดึงข้อมูลจาก block อื่นได้ เลยต้องใช้ท่า message passing เอา ซึ่งจริงๆ ก็ดีแล้ว มัน debug ง่ายกว่า
[backpressuring]
ล่าสุดไปเจอว่าติด memory leak จาก backpressuring เพราะเรามีบล็อกชื่อ clock generator ที่มันจะส่งเวลา (0 - 255) เข้ามาทุก engine ticks ปัญหาคือถ้าเราตั้ง instructions per tick ของ machine ไว้ต่ำกว่าเรทที่ clock มันจะส่ง clock message ไป แน่นอนว่า clock มันส่งถี่เกินกว่าที่ machine จะประมวลผลทัน ทำให้ข้อมูลมันกองเยอะมาก
เอาจริงๆ ตอนแรกไม่รู้ว่าติด backpressure ด้วยซ้ำ แต่เราสร้าง profiler ขึ้นมา ให้มัน monitor execution time ของแต่ละงานด้วย performance.now แล้วเอามาพล็อตกราฟ เจอว่ามีแค่ task เดียวที่ time + space บวมเร็วมาก ในขณะที่ task อื่นมัน constant space กันหมด เพราะว่า message มันกอง
วิธีแก้คือเราให้มัน drop oldest message ด้วยการใช้ VecDeque (double-ended queue) แทน จะได้ drop ข้อความด้านหน้าได้ ตอนแรกเราจะใช้ circular ring buffer แต่ติดปัญหาเรื่อง serde serializer ก็เลยใช้เช็คขนาดแล้ว pop_front เอา ก็ได้เหมือนกันแหละ
นอกจากนั้นในเชิง UX เราทำ status indicator ให้มันบอกสีไปเลยว่าตอนนี้อยู่สถานะอะไร เช่น halted สีเทา, sending สีชมพู, receiving สีม่วง, backpressuring สีส้ม, error สีแดง จะได้รู้ว่าติดปัญหาอะไร กับทำ inbox & outbox indicator ไว้ด้วย จะได้ดูเองได้ว่าตอนนี้ message กองเยอะมั้ย
[stack machine operations]
นอกจากนั้นพออยากทำ bad apple เลยเจอว่ามันเขียนลำบาก ลองไปดูภาษา forth แล้วเห็นว่า stack-based languages นี่เค้ามี convenience instructions หลายตัวเลย พวก nip, tuck, rot, roll, pick ที่ตอนแรกเราไม่ได้ใส่เข้าไป พอเพิ่มไปก็ทำงานกับ stack ได้ง่ายขึ้นจริงๆ
เอาเข้าจริงๆ พอมี working stack อันเดียวก็ลำบากอยู่ดี ก่อนหน้านี้คิดอยู่ว่าจะใส่ locals กับ arguments ให้ call stack ด้วยดีมั้ย ตอนนี้ใช้วิธีเซ็ต address ไว้แล้วก็ LOAD / STORE เป็นตัวแปรเอาไปก่อน
จริงๆ ระหว่างนี้อ่านเจอว่าเมื่อก่อน wasm ในสเปคคิดเป็น register machine มาก่อน แล้วมาเปลี่ยนเป็น stack machine เอาวินาทีสุดท้ายก่อนจะ finalize spec ทำให้มันมีฟีเจอร์ที่ตกค้างมาจากสเปคเก่า เช่น locals ซึ่งมันทำให้ไม่เป็น SSA form และทำ liveness analysis ไม่ได้
[slash commands & autosave]
ฟีเจอร์ที่ใช้สนุกมากตอนนี้คือ slash commands เลย ลากเมาส์ไปที่ไหนก็ได้ กด slash (/) แล้วพิมพ์คำสั่งได้เลย จะเพิ่ม block ตรงนั้นเลยก็ได้ ทำระบบ dynamic hints ให้ดูได้ด้วยว่า state ปัจจุบันเป็นยังไง เช่นมี saves กี่อัน หรือ config ตอนนี้ตั้งค่าไว้ยังไง ใช้ฟินมาก
นอกจากนี้ ในที่สุดก็มี autosave, save & load, import & export file แบบดีๆ ไว้ใช้งานแล้ว ตอนแรกใช้วิธี serialize ทั้ง engine struct state กับ canvas state มา แต่เจอว่าไม่เวริค เพราะมันมี source of truth สองอัน แล้วก็เราไม่ควรผูก internal representation ไว้กับ save format ที่มันเปลี่ยนบ่อย
สุดท้ายเลยใช้ reactflow state เป็น source of truth เอา แล้ว reconstruct engine state จากตรงนั้น ด้วยความที่ทำ API ของ engine ฝั่ง Rust ไว้ดี โค้ดการ serialize & restore ตรงนี้ไม่ถึง 10 บรรทัดเลย
ปล. ทำโปรเจคนี้มาตั้งแต่ September 23 จนจะจบปีแล้ว เร็วๆ นี้อยากเขียน full writeup เหมือนกัน ว่าเริ่มสร้างมาได้ยังไง
Day 20 - 28 of From Opcodes to Algorithms in Rust