- การประเมินว่า CRDT ช้าในการแก้ไขแบบร่วมกัน มักเป็นผลจากการตัดสินโดยปะปนระหว่าง ตัวอัลกอริทึมเอง กับ วิธีการ implement และประสิทธิภาพอาจต่างกันมากได้เพียงแค่โครงสร้างข้อมูลและการจัดวางหน่วยความจำ
- Automerge v1.0.0-preview2 ใช้เวลา 291 วินาที และ RAM 880MB ในการประมวลผลการติดตามการแก้ไข 260,000 รายการ ขณะที่ Diamond types native ทำงานเดียวกันได้ใน 56ms และ RAM 1.1MB
- Yjs ใช้ ลิสต์แบบแบน แทน tree รวมถึง location cache, doubly linked list และการจัดเก็บ span เพื่อลดการติดตามเดียวกันเหลือ 0.97 วินาที และ RAM 3.3MB
- Diamond types ใช้โครงสร้างที่อิง range tree/B-tree ใน Rust เพื่อจัดการการค้นหาตำแหน่ง·การแทรก·การลบในเวลา
log(n)และแม้บน WebAssembly ก็ทำได้ 193ms ตามเกณฑ์ของ Node.js - benchmark นี้ดูเฉพาะการ replay การแก้ไขในเครื่องของผู้ใช้คนเดียวและปริมาณการใช้ RAM เท่านั้น ดังนั้นในการเลือกใช้จริง ต้องพิจารณาเวลาในการ save/load, ขนาดบน network/disk, binary encoding, protocol, presence และ editor binding ร่วมด้วย
อัลกอริทึมและประสิทธิภาพของ implementation เป็นคนละเรื่องกัน
- งานเปรียบเทียบเชิงวิชาการชิ้นหนึ่ง implement การแก้ไขร่วมกันแบบเรียลไทม์อย่าง Google Docs ด้วย CRDT และอัลกอริทึม OT หลายแบบแล้ว benchmark พบว่าอัลกอริทึมบางแบบใช้เวลามากกว่า 3 วินาทีในการประมวลผล paste แบบง่าย ๆ
- วิธีที่ช้าคืออัลกอริทึมที่ใช้ใน ShareJS และ Google Wave แต่ implementation นั้นแยก paste 1000 ตัวอักษรออกเป็น operation แยกกัน 1000 รายการเพื่อประมวลผล
- กรณีนี้แสดงให้เห็นว่าควรแยกดู พฤติกรรมการแก้ไขพร้อมกัน กับ วิธีการ implement
- พฤติกรรมหมายถึงเมื่อมีการแก้ไขพร้อมกันเข้ามาในบริเวณเดียวกัน จะ merge กันด้วยลำดับและกฎแบบใด
- implementation รวมถึงภาษาโปรแกรม โครงสร้างข้อมูล และระดับการ optimization
- แม้แต่ฟังก์ชัน text OT transform เดียวกัน ใน JavaScript รันได้ราว 100,000 ครั้งต่อวินาที ส่วนใน C รันได้ 20M ครั้งต่อวินาที ทำให้ต่างกันประมาณ 200 เท่า
- implementation ที่ช้าเพียงตัวเดียวไม่ใช่หลักฐานว่า implementation ทั้งหมดของระบบนั้นจะช้า และสามารถมี implementation ที่เร็วกว่าได้
โมเดลพื้นฐานของ CRDT และ Automerge
- CRDT ช่วยให้ผู้ใช้หลายคนแก้ไขข้อมูลเดียวกันพร้อมกันได้ ทำงานในเครื่องได้โดยไม่มี latency แล้วค่อย synchronize ภายหลังจนถึง eventual consistency
- Automerge เป็นไลบรารีแก้ไขร่วมกันที่สร้างโดย Martin Kleppmann และอิงอัลกอริทึม RGA
- CRDT อย่าง Automerge และ Yjs มองเอกสารร่วมเป็นลิสต์ของตัวอักษร และกำหนด ID เฉพาะให้แต่ละตัวอักษร
- หากพิมพ์
abcลงในเอกสารว่าง จะได้ ID เช่น(seph, 0),(seph, 1),(seph, 2) - ตัวอักษรใหม่จะบันทึกไว้ด้วยว่า “ถูกแทรกหลัง item ใด”
- หากพิมพ์
- Automerge/RGA เพิ่ม sequence number ให้แต่ละ item
- item ใหม่จะได้รับค่าที่มากกว่า sequence number สูงสุดที่เคยเห็นจนถึงตอนนั้นอยู่ 1
- หากมี child item หลายรายการ จะเรียงตาม sequence number จากมากไปน้อย และถ้าเท่ากันจะเรียงตาม agent ID
- พฤติกรรมของ Automerge/RGA มองได้เป็นสามขั้นตอน
- เชื่อมแต่ละ item เข้ากับ parent เพื่อสร้าง tree
- item ที่มี child หลายรายการจะถูกเรียงด้วย sequence number และ ID
- flatten tree ด้วย depth-first traversal เพื่อสร้างลิสต์สุดท้ายหรือเอกสารข้อความ
Benchmark และคอขวดของ Automerge
- benchmark ใช้ trace การแก้ไขจาก automerge-perf
- เป็น trace ที่บันทึกเนื้อหาที่ Martin Kleppmann พิมพ์บทความวิชาการในระดับตัวอักษร
- trace มี การแก้ไข 260,000 รายการ และขนาดเอกสารสุดท้ายประมาณ 100,000 ตัวอักษร
- ไม่รวมการแก้ไขพร้อมกัน
- การทดสอบวัดเฉพาะเวลาที่ใช้ในการ apply trace ในเครื่อง
- สภาพแวดล้อมคือเวิร์กสเตชัน Ryzen 5800x, Nodejs v16.1, Rust 1.52
- Automerge v1.0.0-preview2 ใช้เวลา 291 วินาที ในการประมวลผล trace นี้ และเมื่อเสร็จใช้ RAM 880MB
- เท่ากับใช้ RAM ราว 10KB ต่อการกดคีย์หนึ่งครั้ง
- RAM สูงสุดคือ 2.6GB
- ใน spike ที่ช้า การประมวลผลการแก้ไขรายการเดียวใช้เวลา 1.8 วินาที
- baseline ที่ splice โดยตรงกับสตริง JavaScript ประมวลผลการแก้ไขเดียวกันได้ใน 0.61 วินาที และ RAM 0.1MB แต่เป็น baseline เพื่อเปรียบเทียบที่ทิ้งข้อมูลทั้งหมดซึ่งจำเป็นต่อการแก้ไขร่วมกัน
- เหตุผลด้าน implementation หลายอย่างที่ทำให้ Automerge ช้า
- เมื่อเอกสารใหญ่ขึ้น โครงสร้างข้อมูลแบบ tree จะใหญ่และช้าลง
- ใช้ Immutablejs มาก ทำให้ V8 optimizer และ GC optimization ได้ยาก
- ปฏิบัติกับตัวอักษรแต่ละตัวที่แทรกเป็น item แยกกัน ทำให้ paste ถูกประมวลผลเป็น item จำนวนมากด้วย
- ทีม Automerge กำลังทำงานกับ implementation ทางเลือกที่ใช้ automerge-rs ซึ่งเป็น implementation ใน Rust ผ่าน WASM
- ตาม master branch ในตอนนั้น ประสิทธิภาพเฉลี่ยของการทดสอบนี้ยังไม่เร็วขึ้น แต่การใช้หน่วยความจำลดลงครึ่งหนึ่ง และความผันผวนของประสิทธิภาพนุ่มนวลขึ้น
Implementation แบบลิสต์แบนสไตล์ Yjs
- Yjs เป็น implementation CRDT แบบโอเพนซอร์สที่สร้างโดย Kevin Jahns และเก็บ item ทั้งหมดไว้ใน ลิสต์แบนเดียว แทน tree
- การเข้าถึงแบบลิสต์แบนคือการสแกนจากหลัง parent ของ item ใหม่เพื่อหาตำแหน่งแทรก เป็นรูปแบบการ implement list CRDT ด้วยลิสต์
- reference-crdts สำหรับทดลอง implement YATA ของ Yjs และ RGA ของ Automerge ด้วยวิธีเดียวกัน
- ฟังก์ชัน insert ประมวลผลพฤติกรรมของ Yjs และ Automerge ด้วย codepath แทบเดียวกัน ยกเว้นความต่างเล็กน้อยบางอย่าง
- ตรวจสอบความเท่าเทียมเชิงความหมายด้วย fuzzer
- ข้อดีของวิธีนี้มีสามอย่าง
- ใช้ flat array แทน tree ที่ไม่สมดุล จึงเล็กกว่าและเร็วกว่า
- โค้ดเรียบง่าย
- ใช้ได้กับ list CRDT หลายแบบ เช่น Yjs, Automerge, Sync9
- ในเชิงทฤษฎี หากมีการแทรกพร้อมกันจำนวนมากที่ตำแหน่งเดียวกัน อาจช้าลงได้ แต่ในการแก้ไขจริง ส่วนใหญ่จะแทรกถัดจาก parent ทันที
- implementation ของ reference-crdts เร็วกว่า Automerge ประมาณ 10 เท่า และใช้ RAM น้อยกว่าประมาณ 30 เท่า
| การทดสอบ | เวลาประมวลผล | การใช้ RAM |
|---|---|---|
| automerge v1.0.0-preview2 | 291s | 880MB |
| reference-crdts Automerge/Yjs | 31s | 28MB |
| Plain string edits in JS | 0.61s | 0.1MB |
ลดต้นทุนการสแกนและการแทรก
- การ implement ด้วย array แบบแบนยังเหลือคอขวดอยู่สองอย่าง
- ต้องสแกนเอกสารเพื่อหาตำแหน่งที่จะแทรก
- เมื่อต้องแทรกกลาง array ด้วย
doc.content.splice(destIdx, 0, newItem)จะต้องเลื่อนรายการด้านหลัง
- รายการที่ถูกลบอาจถูกอ้างอิงโดยการแทรกอื่นได้ จึงลบออกจาก array ไม่ได้ และต้องเหลือเครื่องหมายอย่าง
isDeletedไว้- แม้เอกสารปัจจุบันจะมี 100,000 อักขระ แต่เมื่อรวมรายการในอดีตแล้ว อาจมี array item 150,000 รายการ
- หากจะแทรกที่ตำแหน่งเอกสาร 50,000 อาจต้องสแกน item ประมาณ 75,000 รายการโดยข้ามรายการที่ถูกลบไป
- ในโครงสร้างแบบนี้ เมื่อแทรกลงในเอกสารที่เคยมีรายการ
nรายการ จะต้องใช้ประมาณnขั้นตอน และถ้าแทรกอักขระnตัว ก็จะเป็น O(n²) - Yjs แคชคู่
(index, position)ของตำแหน่งแก้ไขล่าสุดให้สอดคล้องกับวิธีที่มนุษย์แก้ไขเอกสาร- การแก้ไขถัดไปมีแนวโน้มจะอยู่ใกล้ตำแหน่งแก้ไขก่อนหน้า จึงสแกนไปข้างหน้าหรือถอยหลังเพียงสั้น ๆ
- เก็บชุดตำแหน่งที่แคชไว้ด้วย เพื่อรองรับกรณีผู้ใช้หลายคนแก้ไขคนละตำแหน่ง
- Yjs ใช้ ลิงก์ลิสต์สองทาง แทน array ทำให้เมื่อหาตำแหน่งเจอแล้วสามารถแทรกได้ในเวลาคงที่
- อีกทั้งยังใช้ประโยชน์จากข้อเท็จจริงที่ว่าคนมักพิมพ์อักขระต่อเนื่องกัน โดยเก็บ
helloเป็น span เดียว ไม่ใช่รายการอักขระ 5 รายการ- จะ collapse ได้ก็ต่อเมื่อ ID และ parent ต่อเนื่องกันตามลำดับเท่านั้น
- ในชุดข้อมูลนี้ จำนวน array entry ลดจาก 180,000 รายการเหลือ 12,000 รายการ หรือ ลดลง 14 เท่า
| การทดสอบ | เวลาในการประมวลผล | การใช้ RAM |
|---|---|---|
| automerge v1.0.0-preview2 | 291s | 880MB |
| reference-crdts Automerge/Yjs | 31s | 28MB |
| Yjs v13.5.5 | 0.97s | 3.3MB |
| Plain string edits in JS | 0.61s | 0.1MB |
แนวทาง range tree ของ Rust และ Diamond types
- อ็อบเจ็กต์ JavaScript มักกลายเป็นโครงสร้างที่ content, deletion flag, ID, seq, parent ฯลฯ กระจัดกระจายด้วย pointer ทำให้เกิด memory fragmentation และมีต้นทุน cache miss สูง
- Rust สามารถควบคุมการจัดวางหน่วยความจำได้โดยตรง และใช้งานบนเว็บได้ผ่าน WebAssembly
- Diamond types เป็น implementation ของ CRDT ที่เขียนด้วย Rust และแทบเหมือนกับ Yjs แต่ภายในใช้ range tree แทนลิงก์ลิสต์
- range tree ภายในเป็น B-tree ที่ปรับแก้เล็กน้อย
- แทนที่จะเก็บ key แบบ
BTreeMapทั่วไป โหนดภายในจะเก็บผลรวมจำนวนอักขระที่อยู่ในลูก - ประมวลผลการค้นหา การแทรก และการลบที่ตำแหน่งใด ๆ ในเอกสารได้ในเวลา
log(n)
- แทนที่จะเก็บ key แบบ
- trace การแก้ไข 260,000 รายการถูกเก็บใน tree นี้ด้วย leaf node ประมาณ 3 ระดับ ทำให้หา item ใด ๆ ได้ด้วยการอ่านหน่วยความจำราว 3 ครั้ง
- สำหรับการ merge remote edit ยังมี index ขนาดเล็กที่ค้นหา B-tree ด้วย ID ด้วย แต่ codepath ดังกล่าวไม่รวมอยู่ใน benchmark นี้
- leaf node เก็บบล็อก entry 32 รายการอย่างแน่นหนาใน array ขนาดคงที่
- ระบุว่า bucket size 32 รายการทำงานได้ดีจากผล benchmark หลายขนาด แต่ไม่ทราบว่าทำไมจึงเหมาะที่สุด
- เมื่อคอมไพล์ Diamond เป็น WASM ด้วย diamond-js แล้วเรียกจาก Node.js จะประมวลผล trace เดียวกันได้ใน 193ms
- เร็วกว่า Yjs ประมาณ 5 เท่า
- เร็วกว่า baseline สตริง JavaScript ประมาณ 3 เท่า
- การรัน Rust แบบ native บันทึกไว้ที่ 56ms ใน benchmark
- เร็วกว่า Automerge มากกว่า 5000 เท่า
- ประมวลผลได้ 4.6M operation ต่อวินาที
- ระหว่างประมวลผลการแก้ไขทั้งหมด 260,000 รายการ มีการเรียก
malloc1394 ครั้ง
| การทดสอบ | เวลาในการประมวลผล | การใช้ RAM |
|---|---|---|
| automerge v1.0.0-preview2 | 291s | 880MB |
| reference-crdts Automerge/Yjs | 31s | 28MB |
| Yjs v13.5.5 | 0.97s | 3.3MB |
| Plain string edits in JS | 0.61s | 0.1MB |
| Diamond WASM via Node.js | 0.19s | ไม่ทราบ |
| Diamond native | 0.056s | 1.1MB |
การแยก Ropey และ tradeoff
- การ implement ของ Diamond ไม่ได้ใส่เนื้อหาข้อความของเอกสารลงในรายการ CRDT item โดยตรง แต่เก็บไว้ในโครงสร้างข้อมูลแยกต่างหาก
- สำหรับเนื้อหาข้อความ ใช้ไลบรารี Rust Ropey และ Ropey เองก็ implement B-tree เพื่อจัดการข้อความด้วย
- วิธีนี้มี tradeoff ทางวิศวกรรม
- Ropey ทำ byte packing ที่เฉพาะทางกับข้อความ จึงลดการใช้ RAM ได้
- ตอนแทรกต้องอัปเดตโครงสร้างข้อมูลสองชุด ทำให้ช้าลงมากกว่า 2 เท่า และ WASM bundle ก็ใหญ่ขึ้นจาก 60KB เป็น 120KB
- หากเชื่อมกับ editor อย่าง VS Code ตัว editor จะเก็บสำเนาเอกสารไว้เอง ดังนั้นอาจไม่จำเป็นต้องเก็บเนื้อหาเอกสารไว้ในโครงสร้าง CRDT
- หากประมวลผล trace ด้วย Ropey เพียงอย่างเดียว จะใช้เวลา 29ms
- เมื่อปิดการอัปเดต document content ใน Diamond native จะบันทึกได้ 23ms และ RAM 0.96MB
- เร็วกว่า Automerge ประมาณ 14,000 เท่า
- ประมวลผลได้ 11M operation ต่อวินาที
- ผลลัพธ์นี้ใกล้เคียงกับการทดลองเพื่อดูขีดจำกัดการประมวลผล CRDT metadata มากกว่าประโยชน์ใช้งานจริง
| การทดสอบ | เวลาในการประมวลผล | การใช้ RAM | โครงสร้างข้อมูล |
|---|---|---|---|
| automerge v1.0.0-preview2 | 291s | 880MB | naive tree |
| reference-crdts Automerge/Yjs | 31s | 28MB | array |
| Yjs v13.5.5 | 0.97s | 3.3MB | linked list |
| Plain string edits in JS | 0.61s | 0.1MB | ไม่มี |
| Diamond WASM via Node.js | 0.20s | ไม่ทราบ | B-tree |
| Diamond native | 0.056s | 1.1MB | B-tree |
| Ropey Rust baseline | 0.029s | 0.2MB | ไม่มี |
| Diamond native, no doc content | 0.023s | 0.96MB | B-tree |
เกณฑ์การเลือกไลบรารีจริง
- หากกำลังสร้างแอปทำงานร่วมกันบนเอกสารในตอนนี้ การใช้ Yjs จะได้เปรียบกว่า
- Yjs มีประสิทธิภาพดี ใช้หน่วยความจำน้อย และมี ecosystem สนับสนุนที่ดี
- Kevin Jahns ยังให้บริการสนับสนุนการ integrate Yjs แบบมีค่าใช้จ่ายด้วย
- ทีม Automerge ก็จัดให้ประสิทธิภาพเป็นประเด็นสำคัญอันดับแรกในปี 2021 และมีแผนทำให้ Automerge เร็วขึ้นด้วยหลายเทคนิค
- Diamond เร็วมาก แต่ยังเหลืองานอีกมากกว่าจะมี feature parity ระดับเดียวกับ Yjs และ Automerge
- ไลบรารี CRDT ต้องมีสิ่งอื่นนอกจากความเร็วของ operation เช่น binary encoding, network protocol, โครงสร้างข้อมูลที่ไม่ใช่ list, presence, editor binding เป็นต้น
- หากต้องการ semantics แบบฐานข้อมูล ยังไม่รู้จัก implementation ที่ทำได้ดีบน CRDT และสามารถใช้ ShareDB ที่อิง OT ได้
- Redwood เป็นโปรเจกต์ที่รองรับ P2P editing และมีแผนรองรับ CRDT เต็มรูปแบบ
ข้อจำกัดของวิธีการวัด
- benchmark นี้วัดเฉพาะ เวลาในการ replay trace การแก้ไขแบบ local และปริมาณการใช้ RAM เท่านั้น
- input จากผู้ใช้ในเครื่องขอแค่เร็วพอก็พอ และถ้า CRDT ประมวลผล local edit หนึ่งครั้งได้ในประมาณ 1ms หรือน้อยกว่า ความเร็วที่มากกว่านั้นอาจไม่ได้สำคัญมากนัก
- Automerge เองก็โดยทั่วไปทำได้ในระดับนี้ หากไม่นับ GC pause ที่เกิดขึ้นอย่างโชคร้าย
- ตัวชี้วัดที่สำคัญกว่าในทางปฏิบัติมีอย่างอื่น
- จำนวน byte ที่เอกสารกินพื้นที่บนดิสก์หรือบนเครือข่าย
- เวลาที่ใช้ในการบันทึกและโหลด
- เวลาที่ใช้ในการอัปเดตเอกสารที่บันทึกไว้ภายในฐานข้อมูล
- trace ที่ใช้มีเพียงการแก้ไขจากผู้ใช้คนเดียว จึงอาจยังมี pathological case ที่มีการแก้ไขพร้อมกันจำนวนมากหลงเหลืออยู่
- ปัจจุบัน หากต้องการอัปเดต object เดี่ยวในฐานข้อมูลด้วย Yjs หรือ Automerge โดยทั่วไปต้องโหลดเอกสารทั้งชุดเข้า RAM, แก้ไข แล้วบันทึกเอกสารทั้งชุดกลับไปใหม่ ซึ่งอาจช้าได้
- Kevin กล่าวว่า หากปรับ Yjs provider อย่างเหมาะสม ก็สามารถ implement ได้ด้วยวิธีที่สมเหตุสมผล
- list CRDT โดยพื้นฐานจะโตขึ้นเรื่อย ๆ เพราะ tombstone ของรายการที่ถูกลบ และ pruning เป็นแนวทางแยกต่างหาก
- อัลกอริทึม GC ของ Yjs และ Antimatter ถูกยกเป็นตัวอย่าง
- pruning เป็นปัญหาที่ตั้งฉากกับการปรับแต่งโครงสร้างข้อมูลที่กล่าวถึงในบทความ
การเปรียบเทียบไม่ใช่การทดลองที่ควบคุมอย่างสมบูรณ์
- แต่ละขั้นของการปรับแต่งเปลี่ยนหลายตัวแปรพร้อมกัน จึงไม่ได้แยกสาเหตุที่แท้จริงของความเร็วที่เพิ่มขึ้น
- ตอนเปลี่ยนจาก Automerge ไปเป็น reference-crdts มีหลายองค์ประกอบที่เปลี่ยนไป
- โครงสร้างข้อมูลหลักเปลี่ยนจาก tree เป็น list
- Immutablejs ถูกนำออก
- Automerge frontend/backend protocol และโครงสร้าง Uint8Array หลายส่วนหายไป
- สไตล์ JavaScript เปลี่ยนจาก functional เป็น imperative
- ตอนเปลี่ยนจาก reference-crdts ไปเป็น Yjs และจาก Yjs ไปเป็น Diamond ก็ไม่ได้แยกความเปลี่ยนแปลงออกเป็นสาเหตุเดียวเช่นกัน
- ข้อเท็จจริงที่ว่า automerge-rs ไม่ได้เร็วกว่า Automerge ในการทดสอบนี้ เป็นหลักฐานว่าประสิทธิภาพของ Diamond ไม่ได้มาจาก Rust เพียงอย่างเดียว แต่ไม่สามารถรู้สัดส่วนการมีส่วนร่วมที่แน่ชัดได้
- การเปรียบเทียบ RGA กับ YATA ด้วยวิธี implement แบบเดียวกันก็อิงสมมติฐานว่า “พฤติกรรมการ merge พร้อมกันโดยแทบจริงแล้วคล้ายกัน และแม้เปลี่ยนพฤติกรรม ประสิทธิภาพของ implementation ก็ยังคงอยู่”
- ใน reference CRDT implementation พฤติกรรมของ Yjs และ Automerge ใช้ codepath ที่แทบเหมือนกันและมีประสิทธิภาพเท่ากัน
- ใน trace ที่มี conflict หนัก ๆ อาจมีความต่างด้านประสิทธิภาพได้ แต่ในทางปฏิบัติมองว่าเป็นกรณีที่พบได้น้อยมาก
- Yjs ไม่ได้บันทึกว่าแต่ละ item ถูกลบเมื่อใด แต่บันทึกเพียงว่าถูกลบหรือไม่
- ใน Diamond หากบันทึกเวลาที่ถูกลบ ปริมาณการใช้หน่วยความจำจะเพิ่มจาก 1.12MB เป็น 2.34MB และช้าลงประมาณ 5%
- benchmark ของ Diamond ทั้งหมดในบทความนี้ใช้ yjs-style branch ที่ปรับให้ตรงกับวิธีของ Yjs
โค้ด benchmark และข้อมูลสำหรับ reproduce
- โค้ดทดสอบ JS string baseline, Yjs, Automerge, reference-crdts อยู่ใน GitHub gist
- การทดสอบส่วนใหญ่ต้องใช้
automerge-paper.json.gzจาก josephg/crdt-benchmarks - benchmark ของ reference-crdts พึ่งพา josephg/reference-crdts เวอร์ชันดังกล่าว
- benchmark ของ Diamond รันจาก josephg/diamond-types เวอร์ชันดังกล่าว
- คำสั่งที่ใช้รันคือ
RUSTFLAGS='-C target-cpu=native' cargo criterion yjs - ตรวจสอบ memory statistics ด้วย
cargo run --release --features memusage --example stats
- คำสั่งที่ใช้รันคือ
- Diamond WASM wrapper ใช้ diamond-js และ wasm bundle ถูกปรับแต่งด้วย
wasm-opt - กราฟสร้างด้วย ObservableHQ
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
ถ้าใช้จำนวนเต็ม 2 ไบต์ ก็น่าจะเป็นผลจาก cache line 64 ไบต์ อย่างมาก
รายการ 32 รายการจะพอดีกับ cache line หนึ่งเส้น ทำให้แต่ละ cache line บรรจุทั้ง bucket ได้ครบ และลดการส่งข้อมูลกับหน่วยความจำหลักที่มีต้นทุนสูง
โดยพื้นฐานคือใส่ตัวนับเพื่อคอยนับว่าต้องอ่านอะไรจากหน่วยความจำกี่ครั้ง และก็สงสัยว่าเราจะประมาณค่าในทำนองเดียวกันว่า ต้องล้าง cache line กี่ครั้ง ได้ไหม
มักจะเด้งตรงขอบเขตอย่างขนาด word (64 บิต), ขนาดการดึงข้อมูลตามแนว cache alignment (ปกติก็คือ 64 ไบต์ที่พูดถึงข้างบน), ขนาด page ของ OS (4~16KB), ขนาด L1 (ราว 80KB ต่อคอร์), L2 (ระดับหลาย MB)
สงสัยว่ามีแอปไหนบ้างที่ใช้ CRDT ในบริการจริงแล้วให้ประสบการณ์ที่ดีมาก
จำได้ว่า Notion เป็นหนึ่งในนั้น แต่เอาเข้าจริง การที่คนสองคนจดโน้ตร่วมกันใน Notion แทบใช้งานไม่ได้เมื่อเทียบกับ Google Docs
มันเป็น IDE สำหรับงานและการวางแผน เป็นแอปแบบหลายผู้ใช้ รองรับการเข้ารหัสแบบ end-to-end, offline-first และ self-hosting แบบเลือกได้ และเพราะทั้ง workspace เป็นกราฟเดียว CRDT เลยเป็นตัวเลือกที่เป็นธรรมชาติ
การกระทำทั้งหมดใน Thymer ย่อเหลือเพียง การแปลง CRDT ไม่กี่แบบ การย้าย/คัดลอกข้อความ, เปลี่ยนพร็อพเพอร์ตีของ "frontmatter", ลากการ์ด, อัปโหลดไฟล์, เพิ่มแท็ก ล้วนจัดการด้วย CRDT operation ชุดเล็ก ๆ เดียวกัน
ช่วงแรกต้องลงแรงมากเพราะทำเองโดยไม่มีไลบรารี แต่ถ้าสถานะแอปเป็นกราฟเดียว ฟีเจอร์อย่างการย้ายข้อความข้ามหน้า, ลิงก์หน้าแบบมี backlink, transclusion ก็ทำได้โดยไม่ต้องกังวลเรื่องการซิงก์ จึงคุ้มค่ามาก
CRDT รับประกันว่าทุกไคลเอนต์จะลู่เข้าสู่สถานะเดียวกัน และเพราะโดยเนื้อแท้เป็นแบบ append-only จึงได้การจัดการเวอร์ชันตามช่วงเวลาแบบแทบฟรีไปด้วย
แต่ก็มีการประนีประนอมบางอย่างเพราะเรื่องประสิทธิภาพ ประวัติเวอร์ชันไม่ให้ใช้งานออฟไลน์เพราะข้อมูลเยอะเกินไป และในบางกรณีก็ใช้การแก้ conflict แบบ last writer wins
โดยรวมแล้วมันคุ้มค่ามาก โดยเฉพาะถ้าออกแบบแอปโดยคำนึงถึง CRDT ตั้งแต่ต้น แต่ถ้าเป็นกรณีเอาฟีเจอร์หลายผู้ใช้ไปแปะเพิ่มทีหลังในแอป AJAX แบบดั้งเดิม ผมคงไม่ใช้ CRDT
การเปลี่ยนแปลงของ CRDT จะถูกนำไปใช้แบบมองโลกในแง่ดีก่อน แล้วเมื่อได้ลำดับเหตุการณ์ที่เป็น authoritative แล้ว ทุกไคลเอนต์ต้องย้อนกลับไปยังสถานะร่วมล่าสุด แล้ว replay เหตุการณ์ทั้งหมดใหม่ตามลำดับที่ถูกต้อง
ถ้าออฟไลน์อยู่นาน ก็อาจต้องย้อนและนำการเปลี่ยนแปลงหลายวันกลับมาใช้ใหม่ ผู้ใช้ไม่รู้หรอกว่ามี tree transformation เกิดขึ้นเบื้องหลังมากแค่ไหน แต่ CRDT ส่งผลต่อการออกแบบทั้งแอปพลิเคชัน
แอปยอดนิยมส่วนใหญ่ในทุกวันนี้ถูกออกแบบมาตั้งแต่ยุคที่การแปลงแบบ CRDT ยังไม่เป็นที่เข้าใจกันดี
[1] https://thymer.com (เกือบพร้อมเบต้าแล้ว)
ตัวข้อความเป็นแบบ last writer wins และข้อความหรือพร็อพเพอร์ตีของแต่ละบล็อกก็เป็น last writer wins register ตอนนี้กำลังทำฟอร์แมต CRDT ใหม่สำหรับข้อความในบล็อก
มีทั้ง Notes, Reminders และ Photos ก็อาจใช่ด้วย เคยได้ยินจากอดีต SRE ของ Apple ที่เมาอยู่ในบาร์ว่าบางส่วนของแบ็กเอนด์ก็ใช้ FoundationDB
งานพูดใน Local First Conf ก็น่าดูเหมือนกัน: https://youtu.be/VLgmjzERT08
เกมเครือข่าย ทุกแบบที่มี rollback หรือการแก้ให้ตรงไม่ทางใดก็ทางหนึ่งก็ใกล้เคียงกับสิ่งนี้ มันอาจเป็นรูปแบบของ CRDT ที่ใช้กันแพร่หลายและให้ประสบการณ์ผู้ใช้ดีที่สุดก็ได้ คือใช้แนวทาง best effort และมี rollback เป็นเส้นทางสำรอง
มันไม่ใช่ CRDT ในความหมายเชิงวิชาการแบบเคร่งครัด เพราะในทางเทคนิคแล้วสถานะเกมไม่ได้ถูกจำลองครบทั้งก้อนไปยังทุกไคลเอนต์ แต่ละไคลเอนต์ได้รับเพียงบางส่วนของสถานะเกมเท่านั้น
อีกอย่าง ไคลเอนต์เกมต้องการการซิงก์ที่หน่วงต่ำ และในเชิงวิชาการอาจมองสิ่งนี้ว่าเป็น "coordination" ก็ได้ แต่ในทางปฏิบัติ ไคลเอนต์จะยอมรับและเรนเดอร์ผลของอินพุตบนเครื่องตัวเองแบบคาดการณ์ล่วงหน้า ก่อนที่การแก้ conflict หรือการชดเชยจาก rollback จะกลับมา
คนที่ชอบจับผิดอาจเถียงได้ แต่ถ้าดูถึงตัวอย่างที่สามก็น่าจะเห็นธีมร่วมกัน แอป CRDT ที่ดังที่สุด ใช้งานดีที่สุด และทำออกมาดีที่สุดนั้น จริง ๆ แล้วมัก ทำผิดกฎเชิงวิชาการ
นี่เป็นกับดักคลาสสิกของกรอบคิดที่เป็นวิชาการเกินไป อัลกอริทึมและชนิดข้อมูลในโลกจริงมักสร้างสรรค์กว่าหนังสือกฎทางวิชาการมาก ตัวอย่างหนึ่งคือ Timsort
ถ้าคุณกำลังสร้างผลิตภัณฑ์สำหรับผู้ใช้จริง ไม่ใช่เขียนบทความส่งตรวจ ก็ไม่ควรตกหลุมพรางของการออกแบบเกินจำเป็นหรือหลุมพรางแบบวิชาการ ควรเรียนรู้กฎเชิงวิชาการไว้ แต่ตั้งใจแหกมันเมื่อเหมาะสม และแทนที่จะพยายามทำแนวคิดที่นิยามเข้มงวดจนมีประโยชน์เฉพาะในหมู่นักวิชาการให้สมบูรณ์แบบ ก็ควรทำให้มันสร้างคุณค่าและใช้งานได้ดีจริง
แม้ CRDT จะทรงพลัง แต่ข้อเสียคือไม่ว่าจะเป็นแบบอิงการดำเนินการหรือแบบอิงสถานะ ก็ยังทิ้ง ร่องรอยของการดำเนินการหรือองค์ประกอบในอดีต ไว้
ต่อให้บีบอัดแล้วก็ยังเป็นข้อด้อย และเป็นจุดที่ทำให้ลังเลจะนำไปใช้
ถึงอย่างนั้น การถกเถียงนี้ก็ทำให้เริ่มสนใจความเป็นไปได้ในการนำอัลกอริทึมแบบไม่มีความขัดแย้ง หรืออัลกอริทึมแก้ความขัดแย้งแบบละเอียด ไปใช้งานบนสตอเรจแบบไฟล์อย่าง Dropbox หรือ Syncthing
โปรเจกต์ post-CRDT ของผมอย่าง Diamond Types โดยพื้นฐานแล้วจะโตขึ้นไม่สิ้นสุดเมื่อเวลาผ่านไป แต่โอเวอร์เฮดปกติก็น้อยกว่า 1 ไบต์ต่ออักขระที่พิมพ์ทั้งหมดจนถึงตอนนี้
ถ้าเปิดใช้การบีบอัด LZ4 กับข้อความที่เก็บไว้ เอกสารที่แก้ไขด้วย Diamond Types มักจะมีขนาดเล็กกว่าสถานะสุดท้ายของเอกสารเสียอีก ทั้งที่เก็บประวัติการแก้ไขทั้งหมดไว้
ในเชิงเทคนิค ผมรู้หลายวิธีที่จะแก้เรื่องนี้ได้ แต่ก็ยังไม่แน่ใจว่าสำหรับระบบส่วนใหญ่มันเป็น ปัญหาจริง หรือเปล่า
เคยได้ยินมาว่ามีคนใช้ yjs กับเครื่องมือทำโมเดล 3D แล้วเจอปัญหา เพราะมันสร้างการแก้ไขแบบถาวรทุกครั้งที่เมาส์ขยับทีละพิกเซลระหว่างลากวัตถุ
สำหรับงานแบบนั้น ผมคิดว่าปกติจะฉลาดกว่าถ้าใช้การแก้ไขแบบชั่วคราว ซึ่งไลบรารี CRDT ส่วนใหญ่ไม่รองรับ
อีกอย่าง Git ก็มีปัญหาเดียวกัน รีโพซิทอรีจะใหญ่ขึ้นเรื่อย ๆ ตามเวลา และโตเร็วกว่าตอนใช้ไลบรารี CRDT สมัยใหม่เสียอีก แต่ดูเหมือนไม่ค่อยมีใครใส่ใจมากนัก
แม้ Git จะทำ shallow clone ได้ แต่แทบไม่มีใครทำ ถ้าต้องการ ในฝั่ง CRDT ก็ทำแบบเดียวกันได้เหมือนกัน
ตัวอย่างเช่น ถ้ารับประกันได้ว่าไคลเอนต์ทุกตัวได้รับการเปลี่ยนแปลงหลังวันที่ X แล้ว ก็สามารถทิ้งการดำเนินการก่อนวันนั้นได้อย่างปลอดภัย
ผมกำลังสร้าง Fireproof[1] เพื่อใช้ประโยชน์จากงานวิจัยล่าสุดในด้านนี้
เมื่อทำ immutable data ให้เป็น content-addressed ก็มีข้อดีเพิ่มคือการดำเนินการแต่ละครั้งจะลงเอยเป็น proof หรือ diff ที่รับประกันได้ทางคริปโตกราฟี ซึ่งช่วยบังคับ causal consistency และสร้างการอ้างอิงที่เสถียรไปยัง snapshot ได้
กล่าวคือ คุณสามารถรันฐานข้อมูลที่ทำงานร่วมกันได้ ใช้งานออฟไลน์ได้ และ merge ได้โดยไม่สูญเสียข้อมูล บน edge หรือในเบราว์เซอร์ พร้อมยังคงมีความสมบูรณ์ของข้อมูลแบบที่ในอดีตคาดหวังจากฐานข้อมูลศูนย์กลางหรือบล็อกเชน
ตัวอย่างเช่น สามารถใส่ snapshot CID ลงใน PDF สำหรับการลงนามหรือใน smart contract เพื่อขจัดความกำกวมเกี่ยวกับสถานะที่ถูกอ้างอิง
[1] https://github.com/fireproof-storage/fireproof
ท้ายที่สุดแล้ว แทนที่จะวิจารณ์แบบกว้าง ๆ ถึงชุดกฎทางคณิตศาสตร์ที่ชนิดข้อมูลหรือฐานข้อมูลบางตัวปฏิบัติตาม การวิจารณ์การนำไปใช้จริงแบบเฉพาะเจาะจงน่าจะถูกต้องกว่า
เป็นบทความปี 2021 และดูเหมือนว่าจะรวม implementation ภาษา Rust ของ Automerge ด้วย ดังนั้นถ้าได้เห็นเบนช์มาร์กที่อัปเดตก็น่าจะน่าสนใจ
ผมยังมีแนวทางใหม่อีกแบบที่ต่างออกไปโดยสิ้นเชิงสำหรับแก้ปัญหานี้
ถ้าอัปเดตเบนช์มาร์กก็คงดีแน่นอน ทุกอย่างเร็วขึ้นหมดแล้ว
ผมเข้าใจเนื้อหาได้ไม่มากนัก แต่บทความเขียนดีมากจนเป็นงานเขียนหายากที่อ่านต่อได้ไม่สะดุด
นี่คือการอภิปรายที่เกี่ยวข้องก่อนหน้านี้
https://news.ycombinator.com/item?id=28017204 (3 ปีก่อน, 151 ความเห็น)
https://news.ycombinator.com/item?id=33903563 (2 ปีก่อน, 22 ความเห็น)
https://news.ycombinator.com/item?id=41372833 (โพสต์ปัจจุบัน)
https://news.ycombinator.com/item?id=41373288 (ความเห็นปัจจุบัน)
อ้างจาก GitHub Readme[0] ปัจจุบัน ระบุว่าหลังจากโพสต์บล็อกนั้น ประสิทธิภาพก็ดีขึ้นอีก 10–80 เท่า
[0] https://github.com/josephg/diamond-types
น่าจะดีถ้าอธิบายได้ว่า ทำไม CRDT ถึงช้า
บทความนี้ดูเหมือนจะชี้ให้เห็นอนาคต: https://joelgustafson.com/posts/2023-05-04/merklizing-the-ke...
คงดีถ้าเอาสิ่งนี้มาเทียบกับ Y.js หรือ Automerge: https://github.com/canvasxyz/okra-js
หลังจากที่ผมเขียนบทความนี้เมื่อหลายปีก่อน ไลบรารี CRDT หลัก ๆ ทุกตัวก็เร็วขึ้นในระดับ หลายหลัก แล้ว
จำได้ลาง ๆ ว่าเคยบังเอิญเจอบทความนี้เมื่อหลายปีก่อน
เป็นบทความที่สนุกมาก และเป็นหนึ่งในบทความที่ผมชอบที่สุดในช่วงหลายปีที่ผ่านมา
สำหรับคำถามว่า "ทำไม WASM ถึงช้ากว่าการรันแบบเนทีฟ 4 เท่า?" ผมคิดว่าเป็นเพราะการจัดการสตริงทั้งหมดต้องถูกคัดลอกเข้าไปในหน่วยความจำของ WASM แล้วเมื่อคำนวณผลเสร็จก็ต้องคัดลอกกลับไปที่ JS อีกครั้ง
ผมเข้าใจผิดหรือเปล่า? หรือผมเข้าใจบริบทผิด? อยากรู้จริง ๆ
ก่อนเริ่มจับเวลา ผมโหลดบันทึกทั้งหมดเข้าไปใน wasm ก่อน แล้วประมวลผลมันในลูปภายในที่เขียนด้วย Rust ซึ่งรันอยู่ในคอนเท็กซ์ของ wasm เอง การเรียก wasm มีแค่ประมาณสองครั้งเท่านั้น
สาเหตุที่ช้าลง 4 เท่าไม่ใช่ FFI แต่เป็นเพราะโค้ดของอัลกอริทึมเองรันช้ากว่าจริง ๆ 4 เท่า
ถ้าลองรันเบนช์มาร์กใหม่ตอนนี้ก็น่าจะน่าสนใจ output wasm จากคอมไพเลอร์ก็น่าจะดีขึ้นแล้ว และ runtime ของ wasm ก็น่าจะเร็วขึ้นด้วย น่าจะยังมีโค้ดเบนช์มาร์กเก็บไว้อยู่ที่ไหนสักแห่ง
อีกปัญหาหนึ่งที่เจอบ่อยในพื้นที่อื่นคือเรื่อง มัลติเธรด ยังไม่ใช่เรื่องง่าย และการรองรับจากไลบรารีกับเครื่องมือก็ยังไม่สมบูรณ์
ผมเคยลองรัน game engine หรือยูทิลิตีไบนารีอย่าง ffmpeg กับ zip บนเบราว์เซอร์ ซึ่งก็ช้ามากเพราะเรื่องนี้
ผมไม่เคยทำงานกับ WASM interpreter หรือ JIT โดยตรง แต่มีกรณีบ่อยแค่ไหนที่การผ่านหลายชั้นจะดีกว่าผ่านชั้นแปลเพียงชั้นเดียว?
เมื่อแปลโค้ดระดับสูงไปเป็น WASM หรือภาษาแอสเซมบลีบางอย่าง เจตนา จำนวนมากที่อยู่ในโค้ดระดับสูงจะหายไป
ในโค้ดระดับต่ำ เรามักเห็นการเรียงกันของสำนวนเฉพาะภาษาที่ใช้เพื่อให้บรรลุเป้าหมาย ซึ่งบางอย่างอาจตรงกับเครื่องจริงโดยตรง หรืออาจไม่ตรงก็ได้
x86-64 สมัยใหม่มีคำสั่งที่ทรงพลังกว่าสิ่งที่ WASM ทำได้อยู่มาก
แน่นอนว่าก็มี decompiler อยู่ และอาจมีรายการของ macro-op fusion ที่ทำให้ WASM JIT สร้างโค้ดเนทีฟที่ดีได้ด้วยการจับแพตเทิร์นที่ค่อนข้างเรียบง่าย อาจจะไม่ใช่แบบนั้น และการทำ optimization ข้ามแพลตฟอร์มก็น่าจะยากกว่า
LLVM เองก็ไม่สมบูรณ์แบบ ดังนั้นย่อมมีจุดง่าย ๆ ที่ตัว optimizer หลังการประมวลผลสามารถปรับปรุงได้ ดังนั้นในทางทฤษฎี การทำให้ WASM เร็วกว่าผลลัพธ์เนทีฟของ LLVM จึงไม่ใช่เรื่องเป็นไปไม่ได้
แต่ถ้าไม่มีแผนที่ออกแบบมาอย่างดีมาก ๆ หรือไม่ได้สร้างชุดคำสั่งที่ในทางปฏิบัติเป็นซูเปอร์เซ็ตของสิ่งที่สถาปัตยกรรมชุดคำสั่งเป้าหมายรองรับ ผมคิดว่าการได้ผลลัพธ์ในระดับเดียวกันนั้นยากมาก
สำหรับผม WASM ดูใกล้เคียงกับการเป็นซับเซ็ตมากกว่า ดังนั้นการทำให้โอเปอเรชันเป็นมาตรฐานแล้วค่อยประกอบกลับแบบเรียลไทม์จึงไม่ง่าย ถึงจะไม่ถึงกับเป็นไปไม่ได้ แต่ก็ต้องใช้ความสำเร็จทางวิศวกรรมอย่างมาก
ถ้ามองแบบสัญชาตญาณ มันก็เหมือนแปลหนังสือภาษาอังกฤษเป็นอีกภาษาหนึ่งที่แตกต่างมากและถูกจำกัดไว้แค่ไม่กี่พันคำ แล้วค่อยแปลกลับมาเป็นอังกฤษ คุณจะไม่ได้ข้อความเดิมกลับมาแบบเป๊ะ ๆ
บางกรณีแนวคิดที่ภาษาอังกฤษใช้แค่คำเดียว อาจต้องอธิบายเป็นทั้งย่อหน้าในอีกภาษา ถ้าจะกู้ภาษาอังกฤษต้นฉบับกลับมา คุณต้องมีการแปลแบบ 1:1 สำหรับทุกอย่าง หรือไม่ก็มีรายการเทียบกันที่นักแปลทั้งสองฝั่งตกลงกันไว้ว่า ย่อหน้าหนึ่ง ๆ จะแทนด้วยคำเดียว