4 คะแนน โดย GN⁺ 2024-07-09 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • เริ่มจากการจำลองอนุภาคด้วย JavaScript ที่ใช้ CPU ล้วน ตั้งเป้าให้ อนุภาค 1 ล้านตัวบนมือถือที่ 60fps และขยายตัวอย่างสุดท้ายไปได้ถึง 20 ล้านตัว
  • หัวใจของประสิทธิภาพคือการหลีกเลี่ยงอาร์เรย์ของอ็อบเจ็กต์ และเก็บข้อมูลอนุภาคไว้ในหน่วยความจำต่อเนื่องด้วย TypedArray และ SharedArrayBuffer พร้อมแบ่งงานตามคอร์ CPU ด้วย Web Worker
  • คอขวดที่แท้จริงเห็นชัดกว่าในการวาดภาพบนหน้าจอและการเข้าถึงหน่วยความจำ มากกว่าการคำนวณตำแหน่งอนุภาค โดยการเขียนลง pixel buffer แบบสุ่มทำให้เกิด CPU cache miss
  • ลดคอขวดลงทีละขั้นผ่าน pixel buffer แยกตาม Worker, การซิงก์ด้วย postMessage, double buffering, กริดนับจำนวนอนุภาคต่อพิกเซล และการเรนเดอร์ texture ด้วย GPU
  • โครงสร้างสุดท้ายคือ CPU ใช้จำลอง ส่วน GPU ใช้เรนเดอร์ grid texture ตามขนาดความละเอียดหน้าจอ และบน M1 Mac สามารถประมวลผล 20 ล้านตัวได้ราว 20fps

เป้าหมายและแนวทางพื้นฐาน

  • โจทย์ท้าทายคือจำลองอนุภาค 1 ล้านตัวบนมือถือที่ 60fps โดยใช้เพียง JavaScript ล้วน และ CPU โดยไม่พึ่ง WebAssembly
  • มองว่าอาร์เรย์ของอ็อบเจ็กต์แบบ JavaScript ธรรมดามีข้อจำกัดทั้งด้านประสิทธิภาพ single-core บนมือถือและการจัดวางหน่วยความจำ จึงเลือกแนวทางจัดการข้อมูลเป็น อาร์เรย์ต่อเนื่อง
  • ในงานคำนวณขนาดใหญ่ อาร์เรย์หน่วยความจำต่อเนื่องที่จัดแน่นเพื่อให้ดึงข้อมูลขึ้น CPU cache ได้มากที่สุดเป็นสิ่งสำคัญ
  • อาร์เรย์และอ็อบเจ็กต์ทั่วไปของ JavaScript ยากจะรับประกันได้ว่าถูกจัดวางแน่นเหมือนอาร์เรย์หรือ struct ในภาษาระดับล่าง
  • TypedArray สามารถสร้างอาร์เรย์ไบต์ต่อเนื่องขนาดคงที่ได้ จึงทำให้จัดการหน่วยความจำระดับค่อนข้างต่ำใน JavaScript ได้

การติดตั้งครั้งแรก: SharedArrayBuffer และ Web Worker

  • ข้อมูลอนุภาคถูกเก็บโดยวาง view แบบ Float32Array บน SharedArrayBuffer
    • อนุภาคหนึ่งตัวแทนด้วยค่า floating point 32 บิต 4 ค่า คือ x, y, dx, dy
    • ใช้ stride = 4, byte_stride = stride * 4 เพื่อ index บน buffer แบบแบน
  • การจำลองเริ่มจากการอัปเดตตำแหน่งอย่างง่ายในแต่ละเฟรมด้วย x += dx, y += dy
  • การทำงานหลายเธรดประกอบด้วย Web Worker และ SharedArrayBuffer
    • main thread แบ่งอนุภาคเป็น chunk แล้วส่งให้แต่ละ Worker
    • Worker อ่านและเขียนเฉพาะ chunk ที่ได้รับมอบหมาย เพื่อหลีกเลี่ยงการให้หลายเธรดเขียนลงตำแหน่งหน่วยความจำเดียวกัน
  • การใช้ SharedArrayBuffer ในเบราว์เซอร์ต้องมี header ต่อไปนี้
    • Cross-Origin-Embedder-Policy: require-corp
    • Cross-Origin-Opener-Policy: same-origin
  • การซิงก์ในช่วงแรกใช้ SharedArrayBuffer สำหรับส่งสัญญาณ แยกต่างหาก
    • main thread เขียนสัญญาณให้ Worker เริ่มทำงาน
    • Worker ตรวจสัญญาณด้วย setInterval(..., 1) และเมื่อทำงานเสร็จจะบันทึกสถานะพร้อม
  • การเรนเดอร์ใช้ ImageData และทำงานคล้าย rasterizer บน CPU
    • วาดอนุภาคหนึ่งตัวเป็นหนึ่งพิกเซลบนหน้าจอ
    • ข้ามอนุภาคที่อยู่นอกหน้าจอเพื่อหลีกเลี่ยงการเข้าถึงเกินขอบเขตอาร์เรย์
    • ใช้ putImageData วาดผลลัพธ์ลง Canvas
  • จากการโปรไฟล์เบื้องต้น งานของ Worker ใช้เวลาเพียงไม่กี่ ms และเวลาส่วนใหญ่หมดไปกับ การเรนเดอร์บน main thread
  • หน่วยความจำถูกจัดสรรครั้งเดียวตอนเริ่ม และไม่สร้างอ็อบเจ็กต์ใหม่ในลูปจำลอง จึงแทบไม่เกิด garbage

การติดตั้งครั้งที่สอง: อินพุตและปฏิสัมพันธ์คล้ายแรงโน้มถ่วง

  • ข้อมูลอินพุตถูกเพิ่มลงใน SharedArrayBuffer สำหรับสถานะการจำลองที่มีอยู่เดิม
    • เก็บ delta time, mouse x/y, สถานะการสัมผัส, ความกว้าง/ความสูงหน้าจอ
    • สถานะการสัมผัสมีลักษณะใกล้เคียง boolean แต่ใช้ 4 ไบต์
  • เพิ่มเอฟเฟกต์ให้เมื่อกดหน้าจอ อนุภาคจะถูกดึงเข้าหาจุดสัมผัส
    • อ้างอิงรูปแบบ 1 / r² ของสมการแรงโน้มถ่วงจริง แต่ปรับให้เน้นความสนุกของปฏิสัมพันธ์มากกว่าความแม่นยำทางฟิสิกส์
    • เพิ่มแรงหน่วงคล้ายแรงเสียดทานเพื่อป้องกันไม่ให้อณูเร่งไม่สิ้นสุดจนหลุดออกนอกจอ
  • สีในการเรนเดอร์ถูกปรับให้เปลี่ยนตามตำแหน่งของอนุภาค
    • ใช้ค่า x / width, y / height เพื่อปรับค่าของช่อง RGB
    • หากเก็บสีแยกต่ออนุภาคจะทำให้ขนาดข้อมูลต่ออนุภาคใหญ่ขึ้น จึงใช้สีตามตำแหน่งเพื่อลดขนาดข้อมูล
  • ทดลองได้ถึง 2 ล้านอนุภาค และสามารถเปลี่ยนจำนวนอนุภาคผ่าน query parameter count ใน URL
  • ที่ 10 ล้านอนุภาค เฟรมเรตเริ่มลดลง และการโปรไฟล์ชี้ว่า การเขียนพิกเซลลง ImageData ช้ากว่า Worker
  • เมื่อเปลี่ยนจากการเขียน RGB ครบทุกช่อง ไปเขียนเฉพาะช่องสีน้ำเงินและอัลฟา เวลาการเรนเดอร์ในบางสภาพแวดล้อมลดจากราว 80~90ms เหลือ 16~18ms
    • ความต่างนี้ขึ้นกับ CPU และเห็นชัดบน Ryzen เดสก์ท็อปมากกว่า M1 MacBook Air

การติดตั้งครั้งที่สาม: ให้ Worker เขียน pixel buffer

  • เพื่อใช้คอร์ CPU ได้มากขึ้น จึงเปลี่ยนให้ Worker รับผิดชอบไม่ใช่แค่การจำลอง แต่รวมถึง การวาดพิกเซล ด้วย
  • ใช้ pixel buffer แยกตาม Worker เพื่อไม่ให้หลาย Worker เขียนลง buffer เดียวกันพร้อมกัน
    • ใช้ SharedArrayBuffer ขนาด CPU_CORES * window.innerWidth * window.innerHeight * 3
    • แต่ละ Worker มี RGB buffer ของทั้งหน้าจอหนึ่งชุดเป็นของตัวเอง
  • main thread รวม pixel buffer ของแต่ละ Worker เข้าด้วยกัน แล้วเขียนลง ImageData
  • เวอร์ชันนี้เกิดอาการหน้าจอกะพริบ
    • ระหว่างที่ Worker ล้าง pixel buffer ของตัวเองด้วย fill(0) main thread กำลังอ่าน buffer เดียวกันอยู่
    • การเรนเดอร์ก่อนหน้านี้ก็อาจอ่านข้อมูลเก่าได้เช่นกัน แต่ความต่างระหว่างเฟรมไม่ชัดเจน ขณะที่คราวนี้การล้างของ Worker ทำให้เห็นเป็นการกะพริบบางส่วนบนหน้าจอ
  • แนวทางแก้ที่พิจารณาคือการซิงก์ด้วย Atomics, การรอ Worker เสร็จผ่าน postMessage, และ double buffering

การติดตั้งครั้งที่สี่: การซิงก์ด้วย postMessage

  • เพื่อกำจัดอาการกะพริบ จึงเลิกใช้ signal array แล้วเปลี่ยนให้ Worker แจ้งงานเสร็จด้วย postMessage
  • main thread จัดการค่าของ activeWorkers
    • ทุกครั้งที่ Worker เสร็จจะส่งข้อความมาและทำให้ activeWorkers ลดลง
    • เมื่อ Worker ทุกตัวเสร็จแล้วจึงเรนเดอร์และขอ requestAnimationFrame ถัดไป
  • วิธีนี้แก้อาการกะพริบได้
  • แต่เวลาเฟรมไม่ได้ดีขึ้นมาก และอาจแย่ลงด้วยซ้ำ
    • เพราะระหว่างที่ main thread รอให้ Worker เสร็จ มันไม่ได้ทำงานอย่างอื่นเลย
    • ที่ 2 ล้านอนุภาค มีกรณีที่ลดได้ราว 7ms หาก Worker ไม่ต้องรอ main thread เรนเดอร์
  • เพื่อลดคอขวดนี้ จึงนำ double buffering แบบที่ใช้กันทั่วไปในกราฟิกไดรเวอร์มาใช้

การติดตั้งครั้งที่ห้า: double buffering และคอขวดจาก cache

  • double buffering คือการสร้าง pixel buffer สองชุด แล้วสลับ buffer ที่ใช้งานในแต่ละเฟรม
    • ระหว่างที่ main thread วาดจาก buffer หนึ่ง Worker จะเตรียมเฟรมถัดไปลงอีก buffer หนึ่ง
  • หลังใช้งาน Worker ไม่ต้องรอให้ main thread เรนเดอร์อีกต่อไป และบางเฟรมที่เคยเกิน 50ms ก็ใกล้ 16ms มากขึ้น
  • โค้ดที่ยังช้าต่อเนื่องคือสามบรรทัดที่บวกค่าเข้าไปในช่อง RGB ของ pixel buffer
  • สาเหตุคือแม้ข้อมูลจะถูกเก็บต่อเนื่อง แต่ลำดับการเข้าถึงไม่ต่อเนื่อง
    • pixelIndex ที่คำนวณจาก x, y ของอนุภาคไม่ได้เรียงตามลำดับของอาร์เรย์อนุภาค
    • แต่ละรอบของลูปจึงกระโดดไปตำแหน่งสุ่มของ pixel buffer กลายเป็นแพตเทิร์นที่ใกล้เคียง การเข้าถึงแบบสุ่ม
  • รูปแบบการเข้าถึงนี้ทำให้เกิด cache miss เมื่อข้อมูลอนุภาคและข้อมูลพิกเซลไม่สามารถอยู่ใน cache พร้อมกันได้
  • ตัวอย่างการคำนวณสำหรับ iPhone มีดังนี้
    • สูตร: screen width * screen height * bytes per pixel + bytes per particle * total particles / number of workers
    • 2532 * 1170 * 3 + 16 * 2,000,000 / 5
    • ผลลัพธ์เกิน 15MB ซึ่งใหญ่เกินกว่าจะใส่ใน L1 cache
  • ในเวอร์ชันนี้ บนชิป M1 การจำลอง 2 ล้านอนุภาคใช้เวลาราว 4ms, การเข้าถึงข้อมูลพิกเซลราว 3.5ms และการเขียน pixel buffer ราว 7ms
  • บนมือถือยังรักษา 60fps ที่ 1 ล้านอนุภาคได้ แต่บนเดสก์ท็อปที่มีมากกว่า 20 เธรด main thread ต้องใช้ 30ms ในการรวม pixel buffer มากกว่า 20 ชุดทุกเฟรม

การติดตั้งครั้งที่หก: แรงดึงกลับสู่ตำแหน่งเริ่มต้น

  • เอฟเฟกต์ใหม่คือทำให้อณูยิ่งอยู่ไกลจากตำแหน่งเริ่มต้น ก็ยิ่งถูกดึงกลับแรงขึ้น
  • เพื่อทำเช่นนี้ จึงเพิ่มค่า sx, sy ซึ่งเป็นตำแหน่งเริ่มต้นของอนุภาค
    • อนุภาคหนึ่งตัวมี float 6 ค่า คือ x, y, dx, dy, sx, sy
    • ทำให้ particleStride = 6
  • ในโค้ดของ Worker เพิ่มฟังก์ชันคำนวณแรง และใช้ cache object เพื่อหลีกเลี่ยงการสร้าง garbage
  • ผลลัพธ์ทำให้เกิดเอฟเฟกต์ภาพคล้ายเจลลี่หรือของไหล และเห็นการหมุนวนคล้ายกระดาษหรือผ้าที่ยับย่น
  • ระหว่างกระบวนการนี้ ได้แนวคิดว่าแทนที่จะเก็บ pixel buffer แยกตาม Worker อาจเปลี่ยนเป็นกริดที่นับเฉพาะจำนวนอนุภาคต่อพิกเซล

การติดตั้งครั้งที่เจ็ด: กริดนับจำนวนอนุภาคต่อพิกเซล

  • แทนที่จะเก็บค่า RGB หากเก็บเพียง จำนวนอนุภาค ที่อยู่ในแต่ละตำแหน่งพิกเซล จะลดการใช้หน่วยความจำลงเหลือ 1/3
  • เนื่องจากสีคำนวณจากตำแหน่ง x, y ไม่ได้มาจากข้อมูลเฉพาะของอนุภาค จึงเรนเดอร์ได้จากเพียง count ต่อพิกเซล
  • เปลี่ยนให้ Worker ทุกตัวแชร์กริดจำนวนอนุภาคเพียงชุดเดียว
    • งานรวม buffer ของแต่ละ Worker บน main thread หายไป
    • แก้ปัญหาที่ต้นทุนการรวมผลเรนเดอร์เพิ่มขึ้นตามจำนวน Worker
  • สูตรคำนวณ cache แบบใหม่คือ
    • screen width * screen height + bytes per particle * total particles / number of workers
    • สำหรับ iPhone คือ 2532 * 1170 + 16 * 2,000,000 / 5
    • ได้ราว 9.3MB ลดลงจากเดิมประมาณ 30% แต่ก็ยังใหญ่สำหรับ L1 cache
  • การเรนเดอร์จะวนครบทุกพิกเซล อ่านจำนวนอนุภาคของพิกเซลนั้น คำนวณค่า RGB แล้วรีเซ็ตกริดด้วย fill(0)
  • หลังจำลองเสร็จ Worker จะคำนวณ pCountIndex ของอนุภาคที่ยังอยู่ในหน้าจอ แล้วทำ activeGrid[pCountIndex]++
  • การเปลี่ยนนี้ช่วยได้มากโดยเฉพาะเมื่อมี Worker จำนวนมาก และทำให้บนเดสก์ท็อปสามารถใช้ครบทั้ง 24 เธรดได้
  • ถึงอย่างนั้น M1 MacBook Air ก็ยังเร็วกว่า Ryzen 9 3900X เดสก์ท็อปในบางกรณี
    • Ryzen 9 3900X มี L3 cache 64MB แต่ L1 มีเพียง 64KB
    • M1 รุ่นแรกมี L1 cache 320KB โดยในนั้น 128KB สำหรับข้อมูล และ 192KB สำหรับคำสั่ง
    • คาดว่า L1 data cache ที่ใหญ่กว่าช่วยลดเวลาในการรอหน่วยความจำได้ดีกว่า

ข้อจำกัดของการปรับแต่งแบบ CPU ล้วน

  • จากการปรับแต่งหลายรอบ ได้ความเร็วเพิ่มขึ้นราว 2 เท่าเมื่อเทียบกับเวอร์ชัน multi-thread แรก
  • เป้าหมายการประมวลผลอนุภาค 1 ล้านตัวบนมือถือที่ 60fps ด้วย JavaScript บน CPU ล้วนทำสำเร็จแล้ว
  • ผลรวมทั้งหมดไม่ได้ถึงกับน่าทึ่งมาก และถ้าเป็นภาษาคอมไพล์ก็คาดว่าน่าจะเร็วกว่าได้ 10 เท่า
  • ถ้าสามารถใช้คำสั่ง SIMD ใน tight loop ได้ ก็ยังมีโอกาสเร็วขึ้นอีก
  • ขั้นต่อไปจึงลองคงการจำลองไว้บน CPU แล้วโยกเฉพาะการเรนเดอร์ไปยัง GPU pipeline แบบดั้งเดิมเพื่อเปรียบเทียบ

20 ล้านอนุภาคและการเรนเดอร์ด้วย GPU

  • การเปรียบเทียบการเรนเดอร์ด้วย GPU ครั้งแรกใช้ GPU instancing ของ threejs โดยวาด plane/quad หนึ่งชิ้นต่อหนึ่งอนุภาค
  • วิธีนี้ให้ประสิทธิภาพต่ำกว่าที่คาด
    • instancing ของ threejs ต้องอัปเดตเมทริกซ์ตามตำแหน่งของแต่ละอนุภาค และงานนี้ทำบน CPU แบบ single-thread
    • ต้องอัปโหลดข้อมูลการแปลงของอนุภาคไปยัง GPU ทุกเฟรม และการส่งข้อมูลนี้ช้า
  • บนชิป M1 เมื่อจำลอง 4 ล้านอนุภาค เวลาของเฟรมกว่า 80% หมดไปกับการส่งข้อมูลไปยัง GPU
  • บนเดสก์ท็อป สัดส่วนการส่งข้อมูลอยู่ที่ 30% แต่ก็ยังเป็นคอขวดที่ใหญ่ที่สุด
  • ทางเลือกคือใช้ กริดนับจำนวนอนุภาค ที่สร้างไว้ก่อนหน้าเป็น texture แล้วเรนเดอร์ full-screen quad
    • ปริมาณข้อมูลที่ส่งไป GPU จะผูกกับความละเอียดหน้าจอ ไม่ได้ผูกกับจำนวนอนุภาค
    • สำหรับการจำลองขนาดใหญ่จะลดปริมาณการส่งข้อมูล แต่สำหรับการจำลองขนาดเล็กอาจต้องส่งข้อมูลมากกว่า เป็นการแลกเปลี่ยนกัน
  • เมื่อลองทำอย่างรวดเร็วด้วย threejs และ custom pixel shader พบว่าเวลาเรนเดอร์บน main thread เหลือเพียงไม่กี่ ms โดยไม่ขึ้นกับขนาดของการจำลอง
  • ในโครงสร้างนี้ Worker จะทำงานเร็วที่สุดเท่าที่ทำได้ และการจำลองจะผูกตรงกับจำนวนคอร์ CPU ทั้งในงานขนาดใหญ่และเล็ก
  • แต่ละ Worker ใช้เวลาราว 50% ไปกับการคำนวณตัวเลข และอีกราว 50% ไปกับการอัปเดตจำนวนในกริดของอนุภาค
  • ประสิทธิภาพของเวอร์ชันสุดท้ายเป็นดังนี้
    • บน M1 Mac ที่ใช้แบตเตอรี่ สามารถรัน 20 ล้านอนุภาคได้ราว 20fps
    • เดสก์ท็อปประมวลผลได้ราว 30 ล้านอนุภาคที่ fps ใกล้เคียงกัน
    • จากการทดสอบของเพื่อนที่มี CPU 32 คอร์ ไปได้ถึงราว 40 ล้านอนุภาค
  • เดโมที่เกี่ยวข้อง:

สรุป

  • CPU และ GPU สามารถประมวลผลงานเชิงตัวเลขได้เร็วมาก
  • ส่วนที่ช้าคือการย้ายข้อมูล และโดยเฉพาะอย่างยิ่ง การเข้าถึงหน่วยความจำแบบสุ่ม
  • หากอยากเขียนโค้ดให้เร็ว ต้องเข้าใจว่าฮาร์ดแวร์ cache และเคลื่อนย้ายข้อมูลอย่างไร
  • SharedArrayBuffer และ Web Worker มีประโยชน์สำหรับการทดลองใช้หลายคอร์ใน JavaScript
  • หาก WebGPU ถูกนำมาใช้อย่างแพร่หลายมากขึ้น ก็มีแผนจะลองใช้ compute shader ต่อไป

1 ความคิดเห็น

 
GN⁺ 2024-07-09
ความคิดเห็นจาก Hacker News
  • น่าจะดีถ้า ฝังซิมูเลชันให้เล่นได้โดยตรง ไว้ในบล็อก
    ต้องเลื่อนขึ้นลงอยู่นานกว่าจะเจอลิงก์ไปยังซิมูเลชันจริง
    อาจเลือกค่าที่รันได้พอเหมาะแม้บนมือถือเก่า ๆ หรือทำให้ปรับตามเฟรมเรต หรือไม่ก็วางลิงก์ไว้สักสองสามอันที่ด้านบนของบทความก็ได้
    https://ciechanow.ski/ เป็นตัวอย่างระดับสุดยอดของโลกที่แสดงให้เห็นว่าการใส่ซิมูเลชันไว้ในบทความนั้นเจ๋งได้แค่ไหน
    สมัยก่อนเว็บไซต์ทุกเว็บเคยฝังองค์ประกอบอินเทอร์แอ็กทีฟเจ๋ง ๆ แบบนี้กัน
    และผมคิดว่าสามารถรัน ซิมูเลชันอนุภาคบน GPU ได้แม้ไม่มี WebGPU
    ตัวอย่าง: https://news.ycombinator.com/item?id=19963640

    • นี่เป็นหนึ่งใน ตัวอย่างระดับดีที่สุด ของบล็อกเชิงอธิบายที่เคยเห็นมา
      อยากให้มาตรฐานของการแบ่งปันข้อมูลเป็นแบบนี้ ถ้าสิ่งนั้นมีค่าพอจะแชร์ ก็มีค่าพอที่จะทำให้เข้าใจง่ายด้วย
    • ซิมูเลชันของไหลอันนั้นน่าทึ่งจริง ๆ
    • เห็นด้วยว่าการฝังไว้ในบทความเป็นเรื่องดี
      เคยคิดว่าจะฝังแต่ละเวอร์ชัน แต่กังวลว่าจะมี worker รันพร้อมกันมากเกินไป
      ตั้งใจจะอัปเดตให้ฝังเวอร์ชันสุดท้ายไว้ท้ายบทความ
      บล็อกนั้นตัวอย่างแต่ละอันทำมาเนี้ยบมากและดีจริง ๆ
      แก้ไข: ลองเพิ่มเวอร์ชันแบบฝังแล้ว แต่ header ที่ต้องใช้เข้ากันได้ไม่ดีกับ embed อื่น ๆ และเวอร์ชันเก่า ๆ ก็ยังผูกอยู่กับ codesandbox
    • แค่บอกว่า “ถ้าอยากลองเล่นแอปสุดท้าย ให้ข้ามไปท้ายบทความ” ก็พอ
  • ลองเล่นเดโมบนมือถือ [0] แล้วค่อนข้างน่าทึ่ง
    [0] https://dgerrells.com/sabby

    • ใช้หลายนิ้วพร้อมกันก็ทำงานได้
      น่าทึ่งที่ทำได้ขนาดนี้ด้วย JavaScript ล้วน ๆ และพอนิ้วเยอะขึ้นดีเลย์ก็เพิ่มขึ้น ดูเหมือนว่ามีข้อมูลส่งข้ามไปมาระหว่างเธรดมากขึ้น
    • มันดูเป็นอินทรีย์อย่างประหลาด เหมือน โครงสร้างลิพิด ในซุปดึกดำบรรพ์
    • บนมือถือของผม Firefox รันได้ดีกว่า Chrome เลยรู้สึกพอใจ
  • สงสัยล้วน ๆ ว่าแทนที่จะแทนแต่ละอนุภาคด้วย เลขทศนิยมแบบ 32 บิต สี่ตัวคือ x, y, dx, dy จะสามารถเข้ารหัสข้อมูลนี้ลงในตัวเลข JS ตัวเดียวได้ไหม
    ช่วงจำนวนเต็มที่ปลอดภัยของ JS คือ 2^53 - 1 = 9,007,199,254,740,991 ส่วนช่วงของ Float32Array ที่บทความใช้คือ -3.4e38 ถึง 3.4e38
    เช่น ถ้าตำแหน่งบนหน้าจอเป็นแคนวาส 1000x1000 ก็แทนด้วยตัวเลข 0~1,000,000 ได้ และต่อให้เพิ่มความละเอียดระดับซับพิกเซลอีก 10 ขั้น ก็เป็น 100,000,000 ค่า ยังอยู่ในช่วงของ JS แบบเหลือ ๆ
    ความเร็ว dx, dy ก็ถูกคำนวณเป็นเลขทศนิยมสุ่มระหว่าง -10 ถึง +10 ด้วย (Math.random()*2-1)*10 แต่ถ้าจำกัดให้เหลือทศนิยมหนึ่งตำแหน่งแล้วยังพอ ก็เปลี่ยนเป็นช่วงจำนวนเต็ม -100~+100 ได้ และต้องการค่าที่เป็นไปได้แค่ 10,000 ค่า
    รวมสองอย่างเข้าด้วยกันจะได้ 10,000 * 100,000,000 = 1,000,000,000,000 หรือหนึ่งล้านล้านตัวเลขเพื่อแทนอนุภาคหนึ่งตัว ซึ่งยังอยู่ใน MAX_SAFE_INTEGER ของ JS
    ดังนั้นน่าจะบรรจุข้อมูลของอนุภาคเดี่ยวลงใน MAX_SAFE_INTEGER หนึ่งตัวหรือองค์ประกอบหนึ่งตัวของ Float32Array ได้ แบบนี้ก็ไม่ต้องมี stride และความสอดคล้องของข้อมูลก็น่าจะแน่นอนขึ้น
    แต่ต้นทุนการเข้ารหัส/ถอดรหัสอาจสูงกว่าประโยชน์ด้านการประหยัดหน่วยความจำ จนอาจไม่มีคุณค่าด้านประสิทธิภาพเลยก็ได้

    • การดู bit field และการคำนวณเวกเตอร์ที่ JS optimize แล้วอาจช่วยได้
      ใน ecosystem ของ @thi.ng มี implementation ที่เป็นประโยชน์อยู่ และมี implementation แบบ WASM SIMD ด้วย
      [0]: https://github.com/thi-ng/umbrella/tree/develop/packages/vec...
      [1]: https://github.com/thi-ng/umbrella/tree/develop/packages/vec...
      [2]: https://github.com/thi-ng/umbrella/tree/develop/packages/bit...
      [3]: https://github.com/thi-ng/umbrella/tree/develop/packages/sim...
      ยังมี Structura ที่ implement โครงสร้างข้อมูลประสิทธิภาพสูงไว้หลายอย่างด้วย
      [4]: https://github.com/zandaqo/structurae/blob/master/README.md#...
    • อย่างที่พูดไว้ ต้นทุนการเข้ารหัส/ถอดรหัส มีโอกาสสูงที่จะกินประสิทธิภาพไปหมด
      ถ้าใช้ Float16Array ก็ลดความต้องการหน่วยความจำลงครึ่งหนึ่งได้ทันที
      อีกวิธีคือแยก array ตามระดับความแม่นยำ
      เช่น x, y ใช้ Float16Array ส่วน dx/dy ใช้ Int8Array ได้ แต่ทั้งสองกรณีอาจเกิดอาร์ติแฟกต์ในการเคลื่อนไหว และโดยเฉพาะ Int8Array อาจสังเกตเห็นได้จากการ clamp และ aliasing ของ dx/dy
  • เจ๋งมากจริง ๆ แต่สงสัยตรงส่วนนี้
    Javascript does support an Atomics API but it uses promises which are gross. Eww sick.
    ถ้าไม่นับ waitAsync[1] แล้ว Atomics API ดูเหมือนจะไม่ได้ใช้ Promise
    เมื่อก่อนเคยลองใช้ Atomics แต่ก็ไม่จำเป็นต้องจัดการโค้ดแบบ async/Promise
    เลยสงสัยว่ามันใช้ Promise ภายในหรือเปล่า หรือมีส่วนไหนที่ผมพลาดไป
    [1] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...

  • มีซิมูเลชันที่จัดการกับ เกือบ 20 ล้านอนุภาค ด้วย
    รันและเขียนโปรแกรมได้ที่: https://tinlizzie.org/~ohshima/shadama2/
    https://tinlizzie.org/~ohshima/shadama2/live2017/
    https://www.youtube.com/watch?v=tqBO_5GMfWw

  • วิดีโอนั้นเจ๋ง แต่ลิงก์ codesandbox ใน try it out here ใช้งานไม่ได้บน เดสก์ท็อป MacOS Chrome
    มี CORS error อย่าง Uncaught ReferenceError: SharedArrayBuffer is not defined และ ERR_BLOCKED_BY_RESPONSE.NotSameOriginAfterDefaultedToSameOriginByCoep ขึ้นมา

    • เพราะ inline editor ของ codesandbox ทำให้ตั้งค่า header ไม่ได้ จึงต้องเปิด preview ใน แท็บเฉพาะ
      ถ้าใช้เบราว์เซอร์ที่เน้นความเป็นส่วนตัวก็อาจถูกบล็อกได้
      ต่อไปจะลองใส่ตัวอย่างแบบ embed ไว้ด้วย
      เวอร์ชันสุดท้ายลองได้ที่นี่: https://dgerrells.com/sabby
  • ยอดเยี่ยม
    ใช้เวลาไปหนึ่งชั่วโมงเพื่อทำความเข้าใจและลองเล่นกับโค้ด แล้วทำ implementation แบบเรียลไทม์ ไว้ที่นี่: https://particules.kapochamo.com/index.html

    • ตอนเปิดครั้งแรกตกใจเลย
      สวยมาก และแทบไม่น่าเชื่อว่าจะจำลองอนุภาคได้มากขนาดนี้ใน JS
    • เจ๋ง
      น่าประทับใจที่เจาะลึกโค้ดไปขนาดนั้น
  • น่าลองดูว่า chrome://tracing จะให้ insight เพิ่มขึ้นหรือไม่
    รู้มาจากที่นี่: https://youtu.be/easvMCCBFkQ?t=114

  • อยากรีบส่งสิ่งนี้ให้ทีม UI ที่ทำแอป React ช้า ๆ ดู
    JS นั้นเร็วจริง ๆ และโดยเฉพาะถ้าเขียนดี ๆ ก็ เร็วมาก

    • ปัญหาคือ JS แบบ idiomatic กับ JS ที่เร็วนั้น ในทางปฏิบัติแทบจะอยู่คนละขั้วกัน
      JS ที่เร็วจะดูเหมือนภาษาถิ่น C แย่ ๆ และต้องไม่ทำ การจัดสรรอ็อบเจ็กต์ที่ต้องพึ่ง garbage collection แต่ตัวภาษาก็ขาดทางเลือกดี ๆ ที่ไม่ต้อง allocate
      ในแอป JS ที่ซับซ้อน แทบไม่มีใครจะจัดการ allocation ของหน่วยความจำทั้งหมดเป็น pointer ภายใน ArrayBuffer ขนาดยักษ์ และถ้าจะทำแบบนั้น ย้ายไป WebAssembly น่าจะง่ายกว่า
    • JavaScript น่าจะเป็นภาษาที่มี ชั่วโมงแรงงานมนุษย์ ทุ่มลงไปกับการปรับแต่ง engine มากที่สุด
      ที่น่าเสียดายคือเราไม่สามารถพึ่ง JS อย่างเดียวได้ และต้องผสมการจัดการ DOM เข้าไปเยอะ ซึ่งส่วนที่มักช้าใน UI ที่เราทำกันก็คือการจัดการ DOM นั่นเอง