วิธีสร้างอินเทอร์พรีเตอร์ภาษาดายนามิกที่รวดเร็ว
(zef-lang.dev)- แม้แต่อินเทอร์พรีเตอร์แบบ เดิน AST โดยตรง ก็ยังเพิ่มประสิทธิภาพได้มาก ด้วยการแทนค่า, inline cache, object model, watchpoint และการปรับแต่งรายละเอียดซ้ำๆ
- Zef เวอร์ชันตั้งต้นที่แทบไม่ได้คำนึงถึงประสิทธิภาพนั้น ช้ากว่า CPython 3.10 ถึง 35 เท่า, ช้ากว่า Lua 5.4.7 80 เท่า และช้ากว่า QuickJS-ng 0.14.0 23 เท่า แต่หลังผ่านการปรับแต่ง 21 ขั้นก็ทำได้ถึง เร็วขึ้น 16.646 เท่า
- ก้าวกระโดดที่ใหญ่ที่สุดเกิดจาก การออกแบบ object model ใหม่และผสานกับ inline cache และยังได้เพิ่มอีก 4.55 เท่าจากการเข้าถึงแบบอิง Storage และ Offsets, การทำ AST specialization แบบแคชไว้, และการใช้ watchpoint เพื่อตรวจจับการ override ชื่อ
- การปรับปรุงเพิ่มเติมรวมถึง การตัด string-based dispatch ออก, การนำ Symbol มาใช้, การเปลี่ยนโครงสร้างการส่งอาร์กิวเมนต์, การทำ getter และ setter specialization, เส้นทางลัดของ hash table, ตลอดจนการทำ specialization สำหรับ array literal และ
sqrt·toStringแบบสะสมต่อเนื่อง - เมื่อนับรวมการพอร์ตไปยัง Yolo-C++ ด้วย จะทำสถิติได้ว่า เร็วกว่า baseline 66.962 เท่า, เร็วกว่า CPython 3.10 อยู่ 1.889 เท่า และเร็วกว่า QuickJS-ng 0.14.0 อยู่ 2.968 เท่า แต่ไม่เหมาะกับเวิร์กโหลดที่รันยาวเพราะไม่มีการคืนหน่วยความจำ
บทนำและวิธีการประเมิน
- เป้าหมายของการปรับแต่งคือ อินเทอร์พรีเตอร์แบบเดิน AST โดยตรง โดยมีเป้าหมายจะยกระดับภาษาไดนามิก Zef ที่ทำเล่นๆ ให้ แข่งขันกับ Lua, QuickJS และ CPython ได้
- แทนที่จะไปโฟกัสที่ JIT compiler หรือการจูน GC ที่สุกงอมแล้ว ผู้เขียนเลือกเน้นการปรับแต่งที่ใช้ได้แม้เริ่มจากจุดที่ยังไม่มีพื้นฐาน
- เทคนิคที่กล่าวถึงคือ การแทนค่า, inline caching, object model, watchpoint และ การทำ optimization พื้นฐานซ้ำๆ อย่างมีเหตุผล
- ด้วยเทคนิคในบทความเพียงอย่างเดียว ก็ทำให้ประสิทธิภาพเพิ่มขึ้นมากได้ โดยไม่ต้องมี SSA, GC, bytecode หรือ machine code
- ตามเนื้อหาในบทความ เร็วขึ้น 16 เท่า
- หากรวม การพอร์ตไปยัง Yolo-C++ ที่ยังไม่เสร็จสมบูรณ์ จะได้ เร็วขึ้น 67 เท่า
- การวัดประสิทธิภาพใช้ชุดเบนช์มาร์ก ScriptBench1
- เบนช์มาร์กที่รวมอยู่คือ Richards OS scheduler, DeltaBlue constraint solver, N-Body physics simulation และ Splay binary tree test
- ใช้พอร์ตที่มีอยู่เดิมสำหรับ JavaScript, Python และ Lua
- พอร์ตของ Splay สำหรับ Python และ Lua สร้างด้วย Claude
- สภาพแวดล้อมการทดลองคือ Ubuntu 22.04.5, Intel Core Ultra 5 135U, RAM 32GB, Fil-C++ 0.677
- Lua 5.4.7 คอมไพล์ด้วย GCC 11.4.0
- QuickJS-ng 0.14.0 ใช้ไบนารีจาก GitHub releases
- CPython 3.10 ใช้เวอร์ชันที่มากับ Ubuntu โดยค่าเริ่มต้น
- การทดลองทั้งหมดใช้ ค่าเฉลี่ยจากการรัน 30 ครั้งแบบสุ่มลำดับ
- การเปรียบเทียบส่วนใหญ่ทำระหว่าง อินเทอร์พรีเตอร์ Zef ที่คอมไพล์ด้วย Fil-C++ กับ อินเทอร์พรีเตอร์อื่นที่บิลด์ด้วยคอมไพเลอร์ Yolo-C
อินเทอร์พรีเตอร์ Zef เดิม
- ระบุชัดว่าเขียนขึ้นโดยแทบไม่ได้คำนึงถึงประสิทธิภาพ และมีทางเลือกที่ใส่ใจเรื่องประสิทธิภาพจริงๆ เพียงสองอย่างเท่านั้น
-
การแทนค่า
- ใช้ tagged value แบบ 64 บิต
- ค่าที่เก็บได้คือ double, จำนวนเต็ม 32 บิต และ
Object*
- ค่าที่เก็บได้คือ double, จำนวนเต็ม 32 บิต และ
- double ถูกแทนด้วยวิธี offset
0x1000000000000- อธิบายว่าเป็นเทคนิคที่เรียนมาจาก JavaScriptCore
- ในเอกสารมักเรียกว่า NuN tagging
- จำนวนเต็มและพอยน์เตอร์ใช้การแทนแบบเนทีฟ
- อาศัยสมมติฐานว่าค่าพอยน์เตอร์จะ ไม่ต่ำกว่า
0x100000000 - ผู้เขียนระบุเองว่าเป็นทางเลือกที่เสี่ยง
- กล่าวถึงทางเลือกว่าจริงๆ แล้วสามารถใส่แท็กบิตบนของจำนวนเต็มเป็น
0xffff000000000000ได้
- อาศัยสมมติฐานว่าค่าพอยน์เตอร์จะ ไม่ต่ำกว่า
- การแทนค่านี้ทำให้ทำ fast path สำหรับการคำนวณตัวเลขแบบอิงการทดสอบบิตได้
- ข้อดีที่สำคัญยิ่งกว่าคือ หลีกเลี่ยงการจองหน่วยความจำบน heap สำหรับตัวเลข
- เมื่อต้องสร้างอินเทอร์พรีเตอร์ใหม่ การเลือกการแทนค่าพื้นฐานให้ดีตั้งแต่ต้น เป็นเรื่องสำคัญมาก เพราะเปลี่ยนทีหลังได้ยากมาก
- ผู้เขียนเสนอให้ใช้ tagged value แบบ 32 บิตหรือ 64 บิต เป็นจุดเริ่มต้นสำหรับการทำภาษาไดนามิก
- ใช้ tagged value แบบ 64 บิต
-
การเลือกภาษาในการพัฒนา
- เลือกใช้สาย C++ เป็นภาษาที่สามารถรองรับการปรับแต่งได้เพียงพอ
- ระบุชัดว่าจะไม่เลือก Java เพราะมีเพดานด้านการปรับแต่งระดับล่าง
- ระบุว่าจะไม่เลือก Rust เพราะการทำภาษาแบบ GC ต้องมี สถานะแปรผันแบบโกลบอล และการแทน heap ที่มี circular reference
- อย่างไรก็ดี หากยอมรับสถาปัตยกรรมหลายภาษา หรือยอมให้มีโค้ด
unsafeจำนวนมาก ก็ยังพอมีความเป็นไปได้ที่จะใช้ Rust ในบางส่วนหรือทั้งหมด
- อย่างไรก็ดี หากยอมรับสถาปัตยกรรมหลายภาษา หรือยอมให้มีโค้ด
-
ทางเลือกที่ผิดจากมุมมองวิศวกรรมประสิทธิภาพ
- ใช้ Fil-C++
- ทำให้พัฒนาได้เร็ว และ ได้ GC มาฟรี
- รายงานการละเมิด memory safety พร้อมข้อมูลวินิจฉัยและ stack trace
- ไม่มี undefined behavior
- แต่ต้นทุนด้านประสิทธิภาพโดยทั่วไปคือ ราว 4 เท่า
- ใช้ recursive AST walking interpreter
- เป็นโครงสร้างเมธอด virtual
Node::evaluateที่ถูก override ในหลายจุด
- เป็นโครงสร้างเมธอด virtual
- ใช้สตริงมากเกินไป
- AST node แบบ
Getเก็บstd::stringที่อธิบายชื่อตัวแปร - และใช้สตริงนั้นทุกครั้งที่เข้าถึงตัวแปร
- AST node แบบ
- ใช้ hash table มากเกินไป
- ระหว่างรัน
Getจะค้นหาในstd::unordered_mapด้วยคีย์แบบสตริง
- ระหว่างรัน
- ค้นหา scope ด้วยสาย recursive call
- อนุญาตให้มีการซ้อนกันของโครงสร้างเกือบทุกแบบและมี closure
- ในการซ้อนกันอย่างฟังก์ชัน F ภายในมีคลาส A และในคลาส B มีฟังก์ชัน G เมธอดของ A จะมองเห็นได้ทั้ง ฟิลด์ของ A, ตัวแปรโลคัลของ F, ฟิลด์ของ B และตัวแปรโลคัลของ G
- การทำงานเดิมจัดการสิ่งนี้ด้วย ฟังก์ชัน recursive ของ C++ ที่คอยไต่ถาม scope object คนละตัว
- ใช้ Fil-C++
-
คุณลักษณะของการใช้งานเดิม
- แม้จะมีทางเลือกที่ผิดหลายอย่าง แต่ก็ยังสามารถสร้าง อินเทอร์พรีเตอร์ภาษาที่ค่อนข้างซับซ้อน ได้ด้วยโค้ดไม่มาก
- โมดูลที่ใหญ่ที่สุดคือ parser
- ส่วนที่เหลือค่อนข้างเรียบง่ายและชัดเจน
-
ประสิทธิภาพเริ่มต้น
- อินเทอร์พรีเตอร์เดิม ช้ากว่า CPython 3.10 อยู่ 35 เท่า
-
ช้ากว่า Lua 5.4.7 อยู่ 80 เท่า
- ช้ากว่า QuickJS-ng 0.14.0 อยู่ 23 เท่า
ตารางความคืบหน้าการปรับแต่งทั้งหมด
- ตารางนี้สรุปการเปลี่ยนแปลงด้านประสิทธิภาพตั้งแต่ Zef Baseline ไปจนถึง Zef Change #21: No Asserts และ Zef in Yolo-C++
- คอลัมน์เปรียบเทียบคือ vs Zef Baseline, vs Python 3.10, vs Lua 5.4.7, vs QuickJS-ng 0.14.0
- เมื่อดูจากแถวสุดท้าย Zef Change #21: No Asserts นั้น เร็วกว่า baseline 16.646 เท่า
-
ยังช้ากว่า Python 3.10 อยู่ 2.13 เท่า
-
ยังช้ากว่า Lua 5.4.7 อยู่ 4.781 เท่า
- ยังช้ากว่า QuickJS-ng 0.14.0 อยู่ 1.355 เท่า
-
-
Zef in Yolo-C++** เร็วกว่า baseline อยู่**66.962 เท่า
-
เร็วกว่า Python 3.10 อยู่ 1.889 เท่า
-
ยังช้ากว่า Lua 5.4.7 อยู่ 1.189 เท่า
- เร็วกว่า QuickJS-ng 0.14.0 อยู่ 2.968 เท่า
-
ขั้นตอนการปรับแต่งประสิทธิภาพช่วงต้น
-
การปรับแต่ง #1: เรียกตัวดำเนินการโดยตรง
- พาร์เซอร์ไม่สร้างตัวดำเนินการเป็น โหนด
DotCallที่มีชื่อตัวดำเนินการ อีกต่อไป แต่สร้าง โหนด AST แยกสำหรับตัวดำเนินการแต่ละตัว - ใน Zef นั้น
a + bและa.add(b)เหมือนกัน- เดิมทีจะพาร์ส
a + bเป็นDotCall(a, "add")พร้อมอาร์กิวเมนต์b - ทุกการคำนวณเลขคณิตจะเกิด การค้นหาสตริงชื่อเมธอดของตัวดำเนินการ
DotCallจะส่งสตริงไปยังValue::callMethodValue::callMethodทำ การเปรียบเทียบสตริงหลายครั้ง
- เดิมทีจะพาร์ส
- หลังการเปลี่ยนแปลง พาร์เซอร์จะสร้างโหนด
Binary<>,Unary<>- ใช้เทมเพลตและแลมบ์ดาเพื่อให้
Node::evaluateoverride ที่แตกต่างกัน สำหรับตัวดำเนินการแต่ละตัว - แต่ละโหนดจะเรียก เส้นทางลัด
Valueของตัวดำเนินการนั้นโดยตรง - ตัวอย่างเช่น
a + bจะเรียกBinary<lambda for add>::evaluateแล้วตามด้วยValue::add
- ใช้เทมเพลตและแลมบ์ดาเพื่อให้
- ผลด้านประสิทธิภาพคือ ดีขึ้น 17.5%
- ณ จุดนี้ประสิทธิภาพยัง ช้ากว่า CPython 3.10 อยู่ 30 เท่า
- ช้ากว่า Lua 5.4.7 อยู่ 67 เท่า
- ช้ากว่า QuickJS-ng 0.14.0 อยู่ 19 เท่า
- พาร์เซอร์ไม่สร้างตัวดำเนินการเป็น โหนด
-
การปรับแต่ง #2: เรียกตัวดำเนินการ RMW โดยตรง
- ตัวดำเนินการทั่วไปเร็วขึ้นแล้ว แต่รูปแบบ RMW อย่าง
a += bยังใช้การดิสแพตช์แบบอิงสตริงอยู่ - เปลี่ยนให้พาร์เซอร์สร้าง โหนดแยกสำหรับแต่ละกรณี RMW
- พาร์เซอร์จะขอให้ โหนด LValue แทนที่ตัวเองเป็น RMW ผ่านการเรียก virtual
makeRMW - LValue ที่เปลี่ยนเป็น RMW มี Get, Dot, Subscript
- Get ตรงกับการอ่านตัวแปร
id - Dot ตรงกับ
expr.id - Subscript ตรงกับ
expr[index]
- Get ตรงกับการอ่านตัวแปร
- แต่ละการเรียก virtual ใช้แมโคร
SPECIALIZE_NEW_RMW- SetRMW คือ
id += value - DotSetRMW คือ
expr.id += value - SubscriptRMW คือ
expr[index] += value
- SetRMW คือ
- การ specialize ตัวดำเนินการในข้อเปลี่ยนแปลง #1 ใช้การดิสแพตช์ด้วยแลมบ์ดา
- ส่วน RMW ใช้ enum
- เลือกแบบนี้เพราะต้องรองรับทั้ง 3 เส้นทางคือ
get,dot,subscriptและต้องส่ง enum นี้ไปหลายตำแหน่ง - สุดท้าย ฟังก์ชันเทมเพลต
Value::callRMW<>จะทำหน้าที่ดิสแพตช์การเรียกตัวดำเนินการ RMW จริง
- เลือกแบบนี้เพราะต้องรองรับทั้ง 3 เส้นทางคือ
- ผลด้านประสิทธิภาพคือ ดีขึ้น 3.7%
- ณ จุดนี้ประสิทธิภาพยัง ช้ากว่า CPython 3.10 อยู่ 29 เท่า
- ช้ากว่า Lua 5.4.7 อยู่ 65 เท่า
- ช้ากว่า QuickJS-ng 0.14.0 อยู่ 18.5 เท่า
- เร็วขึ้น 1.22 เท่า เมื่อเทียบกับจุดเริ่มต้น
- ตัวดำเนินการทั่วไปเร็วขึ้นแล้ว แต่รูปแบบ RMW อย่าง
-
การปรับแต่ง #3: หลีกเลี่ยงการตรวจสอบ IntObject
- คอขวดอยู่ที่เส้นทางลัดของ
Valueใช้isInt()และภายในisIntSlow()มีการเรียก virtualObject::isInt() - เดิมทีรูปแบบการแทนค่ามีอยู่ 4 กรณี
- tagged int32
- tagged double
- IntObject สำหรับ int64 ที่ไม่สามารถแทนด้วย int32 ได้
- อ็อบเจ็กต์อื่นทั้งหมด
- แม้ในกรณี IntObject การดิสแพตช์เมธอดของจำนวนเต็มก็ยังให้
Valueรับผิดชอบ- เพื่อให้ implementation ของการคำนวณเลขคณิตทั้งหมดอยู่รวมกันที่เดียว คือใน
Value
- เพื่อให้ implementation ของการคำนวณเลขคณิตทั้งหมดอยู่รวมกันที่เดียว คือใน
- หลังปรับแต่ง เส้นทางลัดของ
Valueจะ พิจารณาเฉพาะ int32 และ double- ย้ายตรรกะจัดการ IntObject ไปไว้ใน
IntObjectเอง - จึง หลีกเลี่ยงการเรียก
isInt()ที่เคยเกิดขึ้นทุกครั้งที่มีการดิสแพตช์เมธอด
- ย้ายตรรกะจัดการ IntObject ไปไว้ใน
- ผลด้านประสิทธิภาพคือ ดีขึ้น 1%
- ณ จุดนี้ประสิทธิภาพยัง ช้ากว่า CPython 3.10 อยู่ 29 เท่า
- ช้ากว่า Lua 5.4.7 อยู่ 65 เท่า
- ช้ากว่า QuickJS-ng 0.14.0 อยู่ 18 เท่า
- เร็วขึ้น 1.23 เท่า เมื่อเทียบกับจุดเริ่มต้น
- คอขวดอยู่ที่เส้นทางลัดของ
-
การปรับแต่ง #4: Symbol
- เดิมทีอินเทอร์พรีเตอร์ใช้
std::stringเกือบทุกที่ - ตำแหน่งที่มีต้นทุนสูงจากการใช้สตริงคือ
Context::get,Context::set,Context::callFunction,Value::callMethod,Value::dot,Value::setDot,Value::callOperator<>, ชุดObject::callMethod - ในโครงสร้างแบบนี้ สิ่งที่เกิดขึ้นไม่ใช่แค่การค้นหาในแฮชตารางธรรมดา แต่เป็น การค้นหาในแฮชตารางที่ใช้คีย์เป็นสตริง ทำให้ระหว่างรันมี การแฮชและเปรียบเทียบสตริงซ้ำๆ
- การปรับแต่งนี้แทนที่การค้นหาแบบอิงสตริงด้วย พอยน์เตอร์ไปยังอ็อบเจ็กต์
Symbolที่ทำ hash-consed ไว้ - เพิ่ม คลาส
Symbolใหม่- implement ใน
symbol.h,symbol.cpp - แปลงไปมาระหว่าง Symbol กับสตริงได้
- ตอนแปลงสตริงเป็น Symbol จะทำ hash consing ผ่าน แฮชตารางส่วนกลาง
- ผลลัพธ์คือสามารถตัดสินว่าเป็นสัญลักษณ์เดียวกันหรือไม่ด้วย การเปรียบเทียบเอกลักษณ์ของพอยน์เตอร์
Symbol*เท่านั้น
- implement ใน
- ใช้ symbol ที่เตรียมไว้ล่วงหน้า แทน string literal
- ตัวอย่างเช่นใช้
Symbol::subscriptแทน"subscript"
- ตัวอย่างเช่นใช้
- มีการเปลี่ยนฟังก์ชันซิกเนเจอร์จำนวนมากจาก
const std::string&เป็นSymbol* - ผลด้านประสิทธิภาพคือ ดีขึ้น 18%
- ณ จุดนี้ประสิทธิภาพยัง ช้ากว่า CPython 3.10 อยู่ 24 เท่า
- ช้ากว่า Lua 5.4.7 อยู่ 54 เท่า
- ช้ากว่า QuickJS-ng 0.14.0 อยู่ 15 เท่า
- เร็วขึ้น 1.46 เท่า เมื่อเทียบกับจุดเริ่มต้น
- เดิมทีอินเทอร์พรีเตอร์ใช้
-
การปรับแต่ง #5: ทำ Value ให้ inline
- แกนสำคัญคือ เปิดให้ฟังก์ชันสำคัญต่างๆ ถูก inline ได้
- การเปลี่ยนแปลงเกือบทั้งหมดมีศูนย์กลางอยู่ที่การเพิ่มเฮดเดอร์ใหม่
valueinlines.h - เหตุผลที่แยกออกจาก
value.hเป็นอีกเฮดเดอร์หนึ่ง ก็เพราะมันใช้ เฮดเดอร์ที่จำเป็นต้อง includevalue.h - ผลด้านประสิทธิภาพคือ ดีขึ้น 2.8%
- ณ จุดนี้ประสิทธิภาพยัง ช้ากว่า CPython 3.10 อยู่ 24 เท่า
- ช้ากว่า Lua 5.4.7 อยู่ 53 เท่า
- ช้ากว่า QuickJS-ng 0.14.0 อยู่ 15 เท่า
- เร็วขึ้น 1.5 เท่า เมื่อเทียบกับจุดเริ่มต้น
การออกแบบโมเดลอ็อบเจ็กต์และโครงสร้างแคชใหม่
-
การเพิ่มประสิทธิภาพ #6: โมเดลอ็อบเจ็กต์, inline cache และ Watchpoint
- ปรับโครงสร้างการทำงานของ
Object,ClassObject,Contextครั้งใหญ่ เพื่อลดต้นทุนการจัดสรรอ็อบเจ็กต์และหลีกเลี่ยงการค้นหาในแฮชเทเบิลขณะเข้าถึง - การเปลี่ยนแปลงนี้เป็นการผสาน 3 ความสามารถเข้าด้วยกัน ได้แก่ โมเดลอ็อบเจ็กต์, inline cache และ watchpoint
- ปรับโครงสร้างการทำงานของ
-
โมเดลอ็อบเจ็กต์
- ก่อนหน้านี้จะจัดสรร อ็อบเจ็กต์
Contextสำหรับแต่ละ lexical scope- แต่ละ
Contextมี แฮชเทเบิล ที่เก็บตัวแปรของสโคปนั้น
- แต่ละ
- อ็อบเจ็กต์มีโครงสร้างซับซ้อนกว่านั้น
- แต่ละอ็อบเจ็กต์มี แฮชเทเบิลที่แมปคลาสซึ่งตนเป็นอินสแตนซ์อยู่ไปยัง
Context
- แต่ละอ็อบเจ็กต์มี แฮชเทเบิลที่แมปคลาสซึ่งตนเป็นอินสแตนซ์อยู่ไปยัง
- เหตุผลที่ต้องมีโครงสร้างแบบนี้มาจากการสืบทอดและ nested scope
- เมื่อ
BarสืบทอดจากFooนั้นBarและFooclose over คนละสโคป - และยังอาจมี ฟิลด์ private คนละตัวที่ใช้ชื่อเดียวกันได้
- เมื่อ
- โครงสร้างใหม่เพิ่มแนวคิดของ
Storage- ข้อมูลจะถูกเก็บตาม offsets
- โดย offset จะถูกกำหนดโดย
Contextใดตัวหนึ่ง
Contextยังคงมีอยู่ แต่ไม่ได้ถูกสร้างตอนสร้างอ็อบเจ็กต์หรือสโคปอีกต่อไป ทว่า ถูกสร้างล่วงหน้าใน resolve pass ของ AST- ตอนสร้างอ็อบเจ็กต์หรือสโคปจริง จะจัดสรรเฉพาะ Storage ตามขนาดที่
Contextคำนวณไว้
- ก่อนหน้านี้จะจัดสรร อ็อบเจ็กต์
-
Inline cache
- เป็นเทคนิคที่ตำแหน่งโค้ดอย่าง
expr.nameจะจดจำ ชนิดไดนามิกล่าสุดของexprและ offset ล่าสุดที่nameถูก resolve ไปหา - เป็นเทคนิคคลาสสิกที่มักอธิบายในบริบทของ JIT แต่ที่นี่ นำมาใช้กับอินเทอร์พรีเตอร์
- ข้อมูลที่จดจำไว้ถูกทำเป็นอิมพลีเมนต์โดย placement construct AST node แบบ specialized ลงบน AST node ปกติ
- เป็นเทคนิคที่ตำแหน่งโค้ดอย่าง
-
องค์ประกอบของ inline cache
CacheRecipe- ติดตามว่าการเข้าถึงหนึ่งครั้งทำอะไรไปบ้าง และแคชได้หรือไม่
- มีการแทรกการเรียก
CacheRecipeไว้ทั่วContext,ClassObject,Package- เพื่อเก็บข้อมูลระหว่างกระบวนการเข้าถึง
- ฟังก์ชันประเมิน AST อย่าง
Dot::evaluateจะส่งCacheRecipeที่ได้จากโอเปอเรชันแบบ polymorphic ที่มันทำ ร่วมกับthisเข้าไปยังconstructCache<> constructCache- คอมไพล์ AST node specialization ใหม่ ตาม
CacheRecipe - สร้าง AST node แบบ specialized ได้หลากหลายด้วยกลไกเท็มเพลต
- ถ้าเป็นการเข้าถึงตัวแปรโลคัล ก็จะเป็น การโหลดโดยตรง จาก
storageที่รับมา - ตรวจสอบด้วย class check ว่ายังเป็นคลาสเดียวกับที่เห็นล่าสุดหรือไม่
- หลังจากนั้นเรียกฟังก์ชันล่าสุดที่พบแบบ direct function call
- หากจำเป็น ก็ผสาน chain step กับ watchpoint
- คอมไพล์ AST node specialization ใหม่ ตาม
- AST node ที่เป็นเป้าหมายของการแคชแต่ละตัวมี cached variant ของตัวเอง
- โดยจะพยายามเรียกแบบเร็วผ่านอ็อบเจ็กต์
cacheก่อน - ชนิดของอ็อบเจ็กต์
cacheจะถูกกำหนดโดยconstructCache<>
- โดยจะพยายามเรียกแบบเร็วผ่านอ็อบเจ็กต์
-
watchpoint
- ยกตัวอย่างกรณีมีตัวแปร
xอยู่ใน lexical scope และมีคลาสFooอยู่ภายในนั้น โดยเมธอดของFooเข้าถึงx - ถ้าภายใน
Fooไม่มีฟังก์ชันหรือตัวแปรชื่อxก็ดูเหมือนว่าจะสามารถอ่านxจากสโคปด้านนอกได้โดยตรง - แต่ ซับคลาสอาจเพิ่ม getter
xได้ - ในกรณีนั้น ผลของการเข้าถึงไม่ควรเป็น
xด้านนอก แต่ต้องเป็น getter - เพื่อรับมือกับความเป็นไปได้ของการเปลี่ยนแปลงแบบนี้ inline cache จะตั้งค่า
Watchpointไว้ขณะรันไทม์ - ในตัวอย่างนี้ใช้ watchpoint สำหรับเฝ้าดูว่า ชื่อนี้ถูก override ไปแล้วหรือยัง
- ยกตัวอย่างกรณีมีตัวแปร
-
เหตุผลที่ทำทั้งสามอย่างพร้อมกัน
- หากมีแค่ โมเดลอ็อบเจ็กต์ใหม่ แต่ inline cache ทำงานได้ไม่ดี ก็ยากจะได้การปรับปรุงที่มีนัยสำคัญ
- ส่วน inline cache เอง ถ้าไม่มี watchpoint ก็ยากจะจัดการเงื่อนไขของแคชหลายแบบได้อย่างปลอดภัย ทำให้ประโยชน์จริงมีไม่มาก
- ดังนั้น โมเดลอ็อบเจ็กต์ใหม่กับ watchpoint จึงต้องทำงานร่วมกันให้ดี
-
ความคืบหน้าในการพัฒนาและส่วนที่ยาก
- จุดเริ่มต้นคือการเขียน
CacheRecipeเวอร์ชันอย่างง่าย และออกแบบ Storage, Offsets ที่ใกล้เคียงรูปแบบสุดท้ายตั้งแต่แรก - หนึ่งในงานที่ยากที่สุดคือ การเปลี่ยนวิธีอิมพลีเมนต์ intrinsic class
- ตัวอย่างของอาร์เรย์
- ก่อนหน้านี้
ArrayObject::tryCallMethodอิมพลีเมนต์ทุกเมธอดด้วยวิธี ดักการเรียกเสมือนของObject::tryCallMethod - แต่ในโมเดลอ็อบเจ็กต์ใหม่
Objectไม่มีทั้ง vtable และ virtual method - ดังนั้น
Object::tryCallMethodจึง delegate ไปที่object->classObject()->tryCallMethod(object, ...) - ผลคือหากต้องการให้มีเมธอดของ
Arrayก็จำเป็นต้องสร้าง คลาสสำหรับ Array ที่มีเมธอดเหล่านั้นอยู่ในตัว
- ก่อนหน้านี้
- สุดท้ายแล้ว intrinsic จำนวนมากจึงย้ายจากโครงสร้างที่กระจายอยู่ทั่วทั้งอิมพลีเมนต์ ไปสู่การรวมศูนย์ที่
makerootcontext.cpp - เหตุผลที่ประเมินว่าผลลัพธ์ออกมาดี คือ inline cache ยังถูกใช้กับฟังก์ชัน native/intrinsic ของอ็อบเจ็กต์ได้เหมือนเดิม
- ผลด้านประสิทธิภาพคือ เร็วขึ้น 4.55 เท่า
- ณ จุดนี้ประสิทธิภาพยัง ช้ากว่า CPython 3.10 อยู่ 5.2 เท่า
- ช้ากว่า Lua 5.4.7 อยู่ 11.7 เท่า
- ช้ากว่า QuickJS-ng 0.14.0 อยู่ 3.3 เท่า
- เร็วขึ้น 6.8 เท่าเมื่อเทียบกับจุดเริ่มต้น
- ผู้เขียนประเมินว่าช่องว่างของ Fil-C++ เมื่อเทียบกับอินเทอร์พรีเตอร์อื่น ลดลงจนโดยรวมเหลือประมาณระดับต้นทุนของ Fil-C เอง
- จุดเริ่มต้นคือการเขียน
การปรับแต่งการเรียกใช้และเส้นทางการเข้าถึง
-
การปรับแต่ง #7: ปรับปรุงโครงสร้างการส่งอาร์กิวเมนต์
- ก่อนการเปลี่ยนแปลง อินเทอร์พรีเตอร์ Zef ส่งอาร์กิวเมนต์ของฟังก์ชันเป็น
const std::optional<std::vector<Value>>& - เหตุผลที่ต้องใช้
optionalเป็นเพราะในบางกรณีขอบมุม ต้องแยกความต่างระหว่างสองอย่างนี้o.gettero.function()
- ใน Zef โดยทั่วไปทั้งสองอย่างเป็นการเรียกฟังก์ชัน แต่มีข้อยกเว้นคือโค้ดต่อไปนี้
o.NestedClasso.NestedClass()
- แบบแรกคืนค่า อ็อบเจ็กต์
NestedClassเอง - แบบที่สองคือ สร้างอินสแตนซ์
- ดังนั้นจึงต้องแยกความต่างระหว่าง การเรียกฟังก์ชันที่ไม่มีอาร์กิวเมนต์ กับ การเรียกแบบ getter ที่มีอาร์เรย์อาร์กิวเมนต์ว่างเปล่า
- แต่โครงสร้างเดิมไม่มีประสิทธิภาพ
- ฝั่งผู้เรียกต้อง จัดสรร
vector - ฝั่งผู้ถูกเรียกต้องจัดสรร arguments scope ที่เป็นการคัดลอกเวกเตอร์นั้นอีกครั้ง
- ฝั่งผู้เรียกต้อง จัดสรร
- การเปลี่ยนแปลงคือการนำ ชนิด
Argumentsเข้ามาใช้- รูปร่างของมัน เหมือนกับ arguments scope ที่ผู้ถูกเรียกสร้างอยู่เดิมทุกประการ
- ตอนนี้ผู้เรียกจัดสรรในรูปแบบนั้นได้โดยตรง
- ใน Yolo-C++ ก็ลดจำนวนการจัดสรรได้เช่นกันด้วยการ ตัด
mallocของ vector backing store ออก - ใน Fil-C++ นั้น
std::optionalเองก็มีการจัดสรรบนฮีป- แม้ไม่มี
std::optionalการส่งconst std::vector<>&ก็ยังมีการจัดสรรเช่นกัน - มีการระบุว่าสิ่งที่ควรจัดสรรบนสแตกกลับถูกจัดสรรบนฮีป
- ยังกล่าวถึงด้วยว่าฝั่งผู้เรียกไม่ได้กำหนดขนาดเวกเตอร์ล่วงหน้า จึงเกิด การจัดสรรใหม่หลายครั้ง
- แม้ไม่มี
- ส่วนใหญ่ของการเปลี่ยนแปลงคือการแทนที่ซิกเนเจอร์ของฟังก์ชันเป็น
Arguments* - ผลด้านประสิทธิภาพคือ ดีขึ้น 1.33 เท่า
- ณ จุดนี้ประสิทธิภาพ ช้ากว่า CPython 3.10 อยู่ 3.9 เท่า
- ช้ากว่า Lua 5.4.7 อยู่ 8.8 เท่า
- ช้ากว่า QuickJS-ng 0.14.0 อยู่ 2.5 เท่า
- เร็วขึ้น 9.05 เท่า เมื่อเทียบกับจุดเริ่มต้น
- ก่อนการเปลี่ยนแปลง อินเทอร์พรีเตอร์ Zef ส่งอาร์กิวเมนต์ของฟังก์ชันเป็น
-
การปรับแต่ง #8: การทำ getter ให้เฉพาะทาง
- Zef มีลักษณะคล้าย Ruby ตรงที่ ฟิลด์ของอินสแตนซ์เป็น private โดยปริยาย
- ตัวอย่าง
class Foo { my f fn (inF) f = inF }- เก็บค่าที่รับจากคอนสตรักเตอร์ไว้ใน ตัวแปรโลคัล
fที่มองเห็นได้เฉพาะในอินสแตนซ์
- เก็บค่าที่รับจากคอนสตรักเตอร์ไว้ใน ตัวแปรโลคัล
- แม้จะเป็นอินสแตนซ์ของชนิดเดียวกัน ก็ไม่สามารถเข้าถึง
fของอ็อบเจ็กต์อื่นได้- ตัวอย่าง
fn nope(o) o.f println(Foo(42).nope(Foo(666)))o.fภายในnopeไม่สามารถเข้าถึงfของoได้
- ตัวอย่าง
- เหตุผลคือฟิลด์ทำงานในลักษณะของ การปรากฏอยู่ใน scope chain ของสมาชิกคลาส
o.fจึงไม่ใช่การอ่านฟิลด์ แต่เป็น คำขอเรียกเมธอดชื่อf
- เพราะอย่างนั้น แพตเทิร์นต่อไปนี้จึงพบได้บ่อย
my ffn f f- กล่าวคือ เมธอดชื่อ
fที่คืนค่าตัวแปรโลคัลf
- มีไวยากรณ์ที่สั้นกว่านี้คือ
readable f- เป็นรูปย่อของ
my fและfn f f
- เป็นรูปย่อของ
- การเรียกเมธอดจำนวนมากในทางปฏิบัติคือ การเรียก getter
- การให้ getter ทุกตัวทำงานด้วยการประเมิน AST ทั้งหมดถือว่าสิ้นเปลือง
- การปรับแต่งนี้คือ ทำ getter ให้เฉพาะทาง
- จุดศูนย์กลางคือ
UserFunction - ใช้เมธอดใหม่
Node::inferGetterเพื่ออนุมานว่าบอดีของฟังก์ชันเป็น getter แบบง่ายหรือไม่
- จุดศูนย์กลางคือ
- กฎการอนุมาน
Block::inferGetterจะอนุมานว่าตัวเองเป็น getter ถ้าสิ่งที่บรรจุอยู่ทั้งหมดอนุมานเป็น getter ได้Get::inferGetterจะอนุมานว่าตัวเองเป็น getter และคืนค่า offset ที่ต้องโหลดContext::tryGetFieldOffsetsจะคืนOffsetsที่ไม่ว่างเปล่า ก็ต่อเมื่อฟิลด์นั้น มีอยู่แน่นอน ใน lexical scope ที่ getter จะถูกรันUserFunctionจะ resolve เป็น คลาสย่อยFunctionแบบพิเศษ ที่ทำเพียงอ่านค่าจาก offset ที่ทราบอยู่แล้วโดยตรง หากบอดีของฟังก์ชันอนุมานได้ว่าเป็น getter
- ผลด้านประสิทธิภาพคือ ดีขึ้น 5.6%
- ณ จุดนี้ประสิทธิภาพ ช้ากว่า CPython 3.10 อยู่ 3.7 เท่า
- ช้ากว่า Lua 5.4.7 อยู่ 8.3 เท่า
- ช้ากว่า QuickJS-ng 0.14.0 อยู่ 2.4 เท่า
- เร็วขึ้น 9.55 เท่า เมื่อเทียบกับจุดเริ่มต้น
-
การปรับแต่ง #9: การทำ setter ให้เฉพาะทาง
- ในการอนุมาน setter ต้องมีการจับคู่แพตเทิร์น
fn set_fieldName(newValue) fieldName = newValue - ในขั้นตอนอนุมานของ
UserFunctionจำเป็นต้องส่งต่อ ชื่อพารามิเตอร์ของ setter - ในขั้นตอนอนุมานของ
Setต้องตรวจสอบว่า ไม่ใช่การเขียนไปยัง ClassObject และต้องตรวจสอบด้วยว่าพารามิเตอร์ของ setter ถูกใช้เป็นต้นทางของ set หรือไม่ - ผลด้านประสิทธิภาพคือ ดีขึ้น 3.4%
- ณ จุดนี้ Zef ช้ากว่า CPython 3.10 อยู่ 3.6 เท่า
- ช้ากว่า Lua 5.4.7 อยู่ 8 เท่า
- ช้ากว่า QuickJS-ng 0.14.0 อยู่ 2.3 เท่า
- เร็วขึ้น 9.87 เท่า เมื่อเทียบกับจุดเริ่มต้น
- ในการอนุมาน setter ต้องมีการจับคู่แพตเทิร์น
-
การปรับแต่ง #10: ทำ
callMethodให้ inline- ทำให้ฟังก์ชันสำคัญ inline ได้ด้วยการเปลี่ยนเพียงบรรทัดเดียว
- ผลด้านประสิทธิภาพคือ ดีขึ้น 3.2%
- ณ จุดนี้ Zef ช้ากว่า CPython 3.10 อยู่ 3.5 เท่า
- ช้ากว่า Lua 5.4.7 อยู่ 7.8 เท่า
- ช้ากว่า QuickJS-ng 0.14.0 อยู่ 2.2 เท่า
- เร็วขึ้น 10.2 เท่า เมื่อเทียบกับจุดเริ่มต้น
-
การปรับแต่ง #11: แฮชเทเบิล
- เมื่อเกิด inline cache miss ในการเรียกเมธอด เดิมต้องไล่ลงไปตาม
ClassObject::tryCallMethodและClassObject::TryCallMethodDirectซึ่งทั้งสองเส้นทางมีขนาดใหญ่และซับซ้อน - ต้นทุนการค้นหาเดิมเป็น O(hierarchy depth) ตามความลึกของลำดับชั้น
- ในแต่ละคลาสของลำดับชั้น จะมีการค้นหาในแฮชเทเบิลเพื่อตรวจสอบว่าการเรียกนั้น ตีความเป็นฟังก์ชันสมาชิกหรือไม่
- ในแต่ละคลาสของลำดับชั้น จะมีการค้นหาในแฮชเทเบิลเพื่อตรวจสอบด้วยว่าการเรียกนั้น ตีความเป็นคลาสซ้อนหรือไม่
- การเปลี่ยนแปลงใหม่คือการเพิ่ม แฮชเทเบิลแบบโกลบอล ที่ใช้ receiver class และ symbol เป็นคีย์
- ด้วยการค้นหาเพียงครั้งเดียวก็ คืนค่า callee ได้โดยตรง
- ใน
classobject.hจะค้นในตารางโกลบอลนี้ก่อนลงไปยังtryCallMethodSlowทั้งหมด - ใน
classobject.cppจะบันทึกผลลัพธ์ของการค้นหาที่สำเร็จลงในตารางโกลบอล - ตัวแฮชเทเบิลโกลบอลเองเป็นอิมพลีเมนเทชันที่ค่อนข้างเรียบง่าย
- ผลด้านประสิทธิภาพคือ ดีขึ้น 15%
- ณ จุดนี้ Zef ช้ากว่า CPython 3.10 อยู่ 3 เท่า
- ช้ากว่า Lua 5.4.7 อยู่ 6.8 เท่า
- ช้ากว่า QuickJS-ng 0.14.0 อยู่ 1.9 เท่า
- เร็วขึ้น 11.8 เท่า เมื่อเทียบกับจุดเริ่มต้น
- เมื่อเกิด inline cache miss ในการเรียกเมธอด เดิมต้องไล่ลงไปตาม
-
การปรับแต่ง #12: หลีกเลี่ยง
std::optional- ใน Fil-C++ นั้น
std::optionalจำเป็นต้องจัดสรรบนฮีป เพราะ อาการผิดปกติของคอมไพเลอร์ที่เกี่ยวกับ union - โดยทั่วไป LLVM จะจัดการชนิดการเข้าถึงหน่วยความจำของ union แบบค่อนข้างผ่อนปรน แต่สิ่งนี้ไปชนกับ invisicaps
- มีกรณีที่พอยน์เตอร์ใน union สูญเสีย capability ในแบบที่ยากต่อการคาดเดาจากมุมมองของโปรแกรมเมอร์
- ผลก็คือใน Fil-C อาจเกิด panic จาก การ dereference อ็อบเจ็กต์ที่มี null capability ได้ แม้โปรแกรมเมอร์จะไม่ได้ทำพลาด
- เพื่อบรรเทาปัญหานี้ คอมไพเลอร์ Fil-C++ จึง แทรก intrinsics เพื่อบังคับให้ LLVM ทำงานแบบอนุรักษ์นิยมกับการจัดการตัวแปรโลคัลชนิด union
- หลังจากนั้น pass ชื่อ
FilPizlonatorจะทำ escape analysis ของตัวเอง เพื่อพยายามทำให้ตัวแปรโลคัลชนิด union สามารถถูกจัดสรรลงรีจิสเตอร์ได้- แต่การวิเคราะห์นี้ก็ยังไม่สมบูรณ์เท่ากับ การวิเคราะห์ SROA ของ LLVM ทั่วไป
- ผลลัพธ์คือ การส่งคลาสที่ มี union อยู่ภายใน อย่าง
std::optionalมักนำไปสู่การจัดสรรหน่วยความจำใน Fil-C++ - การเปลี่ยนแปลงครั้งนี้คือการ หลีกเลี่ยงเส้นทางโค้ดที่นำไปสู่
std::optionalใน hot path - ผลด้านประสิทธิภาพคือ ดีขึ้น 1.7%
- ณ จุดนี้ Zef ช้ากว่า CPython 3.10 อยู่ 3 เท่า
- ช้ากว่า Lua 5.4.7 อยู่ 6.65 เท่า
- ช้ากว่า QuickJS-ng 0.14.0 อยู่ 1.9 เท่า
- ใน Fil-C++ นั้น
-
เร็วขึ้น 12 เท่า เมื่อเทียบกับจุดเริ่มต้น
-
การปรับแต่งประสิทธิภาพ #13: อาร์กิวเมนต์แบบ specialized
- ฟังก์ชัน built-in ทั้งหมดของ Zef รับอาร์กิวเมนต์ 1 หรือ 2 ตัว และในการทำงานแบบเนทีฟ ไม่จำเป็นต้องจัดสรรออบเจ็กต์
Argumentsเพื่อเก็บสิ่งเหล่านี้ - setter ก็รับ อาร์กิวเมนต์หนึ่งตัว เสมอ และหากมีการอนุมาน setter แล้ว implementation ของ setter แบบ specialized ก็เพียงรับอาร์กิวเมนต์ค่าโดยตรงได้เลยโดยไม่ต้องมีออบเจ็กต์
Arguments - การเปลี่ยนแปลงครั้งนี้ได้เพิ่มชนิดอาร์กิวเมนต์แบบ specialized คือ
ZeroArguments,OneArgument,TwoArguments- หาก callee ไม่ต้องการ caller ก็จะ หลีกเลี่ยงการจัดสรรออบเจ็กต์
Argumentsได้
- หาก callee ไม่ต้องการ caller ก็จะ หลีกเลี่ยงการจัดสรรออบเจ็กต์
- จำเป็นต้องมี
ZeroArgumentsเพื่อ แยกความต่างจาก(Arguments*)nullptr- ก่อนหน้านี้ใช้
(Arguments*)nullptrเพื่อ สื่อถึงการเรียก getter และยังคงตรรกะนี้ไว้ - ตอนนี้
ZeroArgumentsหมายถึง การเรียกฟังก์ชันที่ไม่มีอาร์กิวเมนต์
- ก่อนหน้านี้ใช้
- การเปลี่ยนแปลงจำนวนมากประกอบด้วยการทำให้ฟังก์ชันที่รับอาร์กิวเมนต์ เป็นเทมเพลต
- มีการทำ explicit instantiation สำหรับแต่ละแบบคือ
ZeroArguments,OneArgument,TwoArguments,Arguments* - โค้ดเดิมจำนวนมากใช้
Value::getArgเป็นเฮลเปอร์สำหรับดึงอาร์กิวเมนต์ และได้เพิ่ม โอเวอร์โหลดสำหรับอาร์กิวเมนต์แบบ specialized เข้าไปที่นี่ - การแก้โค้ดเนทีฟที่ใช้อาร์กิวเมนต์นั้นเป็นการปรับที่ค่อนข้างตรงไปตรงมา
- มีการทำ explicit instantiation สำหรับแต่ละแบบคือ
- ผลด้านประสิทธิภาพคือ ดีขึ้น 3.8%
- ณ จุดนี้ Zef ช้ากว่า CPython 3.10 อยู่ 2.9 เท่า
- ช้ากว่า Lua 5.4.7 อยู่ 6.4 เท่า
- ช้ากว่า QuickJS-ng 0.14.0 อยู่ 1.8 เท่า
- เร็วขึ้น 12.4 เท่า เมื่อเทียบกับจุดเริ่มต้น
- ฟังก์ชัน built-in ทั้งหมดของ Zef รับอาร์กิวเมนต์ 1 หรือ 2 ตัว และในการทำงานแบบเนทีฟ ไม่จำเป็นต้องจัดสรรออบเจ็กต์
การเลี่ยงพยาธิสภาพของ Fil-C และการทำ specializaton แบบละเอียด
-
การปรับแต่ง #14: ปรับปรุง
Valueslow path- ได้ความเร็วเพิ่มขึ้นมากจากการ เลี่ยงพยาธิสภาพของ Fil-C อีกแบบหนึ่ง
- ก่อนเปลี่ยนแปลง slow path แบบ out-of-line ของ
Valueเป็นเมธอดสมาชิกของValueและต้องใช้อาร์กิวเมนต์แบบแฝงconst Value* - โครงสร้างนี้ทำให้ caller ต้อง จัดสรร
Valueบนสแตก - ใน Fil-C++ การ จัดสรรบนสแตกทั้งหมดคือการจัดสรรบนฮีป
- ดังนั้นโค้ดที่เรียก slow path จึงต้อง จัดสรร
Valueบนฮีป
- ดังนั้นโค้ดที่เรียก slow path จึงต้อง จัดสรร
- หลังเปลี่ยนแปลง เมธอดเหล่านี้ถูกเปลี่ยนเป็น
staticและส่งValueแบบค่า- ผลคือไม่ต้องมีการจัดสรรแยกต่างหาก
- ผลด้านประสิทธิภาพคือ ดีขึ้น 10%
- ณ จุดนี้ Zef ช้ากว่า CPython 3.10 อยู่ 2.6 เท่า
- ช้ากว่า Lua 5.4.7 อยู่ 5.8 เท่า
- ช้ากว่า QuickJS-ng 0.14.0 อยู่ 1.65 เท่า
- เร็วขึ้น 13.6 เท่า เมื่อเทียบกับจุดเริ่มต้น
-
การปรับแต่ง #15: ลบความซ้ำซ้อนใน
DotSetRMW- ทำการ ลบโค้ดซ้ำซ้อน บางส่วน
- คาดว่าการ ลดโค้ดเครื่อง ในฟังก์ชันเทมเพลตที่ถูก specialize โดย
constructCache<>อาจช่วยได้ - ผลจริงคือ ไม่มีผลต่อประสิทธิภาพ
-
การปรับแต่ง #16: specialization สำหรับ
sqrt- inline cache ช่วยส่งต่อการเรียกไปยังฟังก์ชันที่ต้องการได้ดี แต่ ทำงานกับอ็อบเจ็กต์เท่านั้น
- สำหรับค่าที่ไม่ใช่อ็อบเจ็กต์ fast path ของ
Binary<>,Unary<>,Value::callRMW<>อาศัยวิธีตรวจว่า receiver เป็นintหรือdouble - วิธีนี้ใช้ได้เฉพาะกับ ตัวดำเนินการ ที่ parser รู้จัก
- จึงใช้ไม่ได้กับรูปแบบอย่าง
value.sqrt
- จึงใช้ไม่ได้กับรูปแบบอย่าง
- การเปลี่ยนแปลงครั้งนี้ทำให้
Dotสามารถทำ specialization สำหรับvalue.sqrtได้ - ผลด้านประสิทธิภาพคือ ดีขึ้น 1.6%
- ณ จุดนี้ Zef ช้ากว่า CPython 3.10 อยู่ 2.6 เท่า
- ช้ากว่า Lua 5.4.7 อยู่ 5.75 เท่า
- ช้ากว่า QuickJS-ng 0.14.0 อยู่ 1.6 เท่า
- เร็วขึ้น 13.8 เท่า เมื่อเทียบกับจุดเริ่มต้น
-
การปรับแต่ง #17: specialization สำหรับ
toString- ใช้ specialization สำหรับ
toStringในแนวทางที่แทบจะเหมือนกับการปรับแต่งก่อนหน้า - การเปลี่ยนแปลงนี้รวมตรรกะ ลดจำนวนการจัดสรร ที่เกิดขึ้นเมื่อต้องแปลง
intเป็นสตริง - ผลด้านประสิทธิภาพคือ ดีขึ้น 2.7%
- ณ จุดนี้ Zef ช้ากว่า CPython 3.10 อยู่ 2.5 เท่า
- ช้ากว่า Lua 5.4.7 อยู่ 5.6 เท่า
- ช้ากว่า QuickJS-ng 0.14.0 อยู่ 1.6 เท่า
- เร็วขึ้น 14.2 เท่า เมื่อเทียบกับจุดเริ่มต้น
- ใช้ specialization สำหรับ
-
การปรับแต่ง #18: specialization สำหรับ array literal
- โค้ดอย่าง
my whatever = [1, 2, 3]ต้องมีการจัดสรรอาร์เรย์ใหม่ใน Zef เพราะอาร์เรย์ ถูก alias ได้และ mutable - ก่อนเปลี่ยนแปลง จะไล่ลงไปตาม AST ทุกครั้งระหว่างรันแล้ว ประเมิน
1,2,3แบบ recursive ซ้ำทุกครั้ง - การเปลี่ยนแปลงครั้งนี้ทำให้โหนด
ArrayLiteralทำ specialization ได้ในกรณีจัดสรรอาร์เรย์คงที่ - ผลด้านประสิทธิภาพคือ ดีขึ้น 8.1%
- ณ จุดนี้ Zef ช้ากว่า CPython 3.10 อยู่ 2.3 เท่า
- ช้ากว่า Lua 5.4.7 อยู่ 5.2 เท่า
- ช้ากว่า QuickJS-ng 0.14.0 อยู่ 1.5 เท่า
- เร็วขึ้น 15.35 เท่า เมื่อเทียบกับจุดเริ่มต้น
- โค้ดอย่าง
-
การปรับแต่ง #19: ปรับปรุง
Value::callOperator- นำการปรับแต่งแบบเดียวกับที่ก่อนหน้านี้ให้ผลด้านความเร็วจากการไม่ส่ง
Valueแบบอ้างอิง มาใช้กับcallOperatorslow path ด้วย - ผลด้านประสิทธิภาพคือ ดีขึ้น 6.5%
- ณ จุดนี้ Zef ช้ากว่า CPython 3.10 อยู่ 2.2 เท่า
- ช้ากว่า Lua 5.4.7 อยู่ 4.9 เท่า
- ช้ากว่า QuickJS-ng 0.14.0 อยู่ 1.4 เท่า
- เร็วขึ้น 16.3 เท่า เมื่อเทียบกับจุดเริ่มต้น
- นำการปรับแต่งแบบเดียวกับที่ก่อนหน้านี้ให้ผลด้านความเร็วจากการไม่ส่ง
-
การปรับแต่ง #20: ตัวเลือก C++ ที่ดีกว่า
- ใน Fil-C++ ได้ปิด RTTI และ libc++ hardening ที่ไม่จำเป็น
- ไม่มีการเปลี่ยนแปลงโค้ด C++ เอง มีเพียง การเปลี่ยนค่าการตั้งค่าในระบบ build
- ผลด้านประสิทธิภาพคือ ดีขึ้น 1.8%
- ณ จุดนี้ Zef ช้ากว่า CPython 3.10 อยู่ 2.1 เท่า
- ช้ากว่า Lua 5.4.7 อยู่ 4.8 เท่า
- ช้ากว่า QuickJS-ng 0.14.0 อยู่ 1.35 เท่า
- เร็วขึ้น 16.6 เท่า เมื่อเทียบกับจุดเริ่มต้น
-
การปรับแต่ง #21: ปิดการทำงานของ assert
- ในการปรับแต่งสุดท้าย มีการ ปิด assertion เป็นค่าเริ่มต้น
- โค้ดเดิมใช้ แมโคร
ZASSERTเฉพาะของ Fil-C- เป็นโครงสร้างที่ทำ assert อยู่เสมอ
- หลังเปลี่ยนแปลง ใช้ แมโคร
ASSERTภายในแทน- จะทำ assert เฉพาะเมื่อมีการตั้งค่า
ASSERTS_ENABLED
- จะทำ assert เฉพาะเมื่อมีการตั้งค่า
- การเปลี่ยนแปลงนี้ยังรวมถึงการแก้ไขอื่น ๆ เพื่อให้โค้ด build ได้บน Yolo-C++
- แต่ต่างจากที่คาดไว้ ไม่มีความเร็วเพิ่มขึ้น
ผลลัพธ์และข้อจำกัดของ Yolo-C++
- เมื่อนำโค้ดไปคอมไพล์ด้วย Yolo-C++ ได้ ความเร็วเพิ่มขึ้น 4 เท่า
- อย่างไรก็ตาม วิธีนี้ ไม่ sound และยัง suboptimal
- ที่ไม่ sound เพราะการเรียก GC ของ Fil-C++ เดิมจะถูกเปลี่ยนเป็น การเรียก
calloc - ผลคือหน่วยความจำไม่ถูกคืน และในเวิร์กโหลดที่รันนานพอ อินเทอร์พรีเตอร์จะ ใช้หน่วยความจำจนหมด
- ใน ScriptBench1 ไม่มีปัญหาหน่วยความจำหมด เพราะเวลาทดสอบสั้น
- ที่ไม่ sound เพราะการเรียก GC ของ Fil-C++ เดิมจะถูกเปลี่ยนเป็น การเรียก
- ที่ suboptimal เพราะ ตัวจัดสรร GC จริงนั้นเร็วกว่า
callocของ glibc 2.35 - ดังนั้นจึงมีการกล่าวว่า หากเพิ่ม GC จริง เข้าไปในพอร์ต Yolo-C++ ก็อาจได้ความเร็วเพิ่มมากกว่า 4 เท่า
- การทดลองนี้ใช้ GCC 11.4.0
- ณ จุดนี้ Zef
-
เร็วกว่า CPython 3.10 อยู่ 1.9 เท่า
-
ช้ากว่า Lua 5.4.7 อยู่ 1.2 เท่า
-
เร็วกว่า QuickJS-ng 0.14.0 อยู่ 3 เท่า
- เร็วขึ้น 67 เท่า เมื่อเทียบกับจุดเริ่มต้น
-
ข้อมูลดิบของเบนช์มาร์ก
- หน่วยเวลารันของเบนช์มาร์กคือ วินาที
- ในตารางมี
nbody,splay,richards,deltablue,geomeanของอินเทอร์พรีเตอร์แต่ละตัว -
Python 3.10
nbody0.0364splay0.8326richards0.0822deltablue0.1135geomean0.1296
-
Lua 5.4.7
nbody0.0142splay0.4393richards0.0217deltablue0.0832geomean0.0577
-
QuickJS-ng 0.14.0
nbody0.0214splay0.7090richards0.7193deltablue0.1585geomean0.2036
-
Zef Baseline
nbody2.9573splay13.0286richards1.9251deltablue5.9997geomean4.5927
-
Zef Change #1: Direct Operators
nbody2.1891splay12.0233richards1.6935deltablue5.2331geomean3.9076
-
Zef Change #2: Direct RMWs
nbody2.0130splay11.9987richards1.6367deltablue5.0994geomean3.7677
-
Zef Change #3: Avoid IntObject
nbody1.9922splay11.8824richards1.6220deltablue5.0646geomean3.7339
-
Zef Change #4: Symbols
nbody1.5782splay9.9577richards1.4116deltablue4.4593geomean3.1533
-
Zef Change #5: Value Inline
nbody1.4982splay9.7723richards1.3890deltablue4.3536geomean3.0671
-
Zef Change #6: Object Model and Inline Caches
nbody0.3884splay3.3609richards0.2321deltablue0.6805geomean0.6736
-
Zef Change #7: Arguments
nbody0.3160splay2.6890richards0.1653deltablue0.4738geomean0.5077
-
Zef Change #8: Getters
nbody0.2988splay2.6919richards0.1564deltablue0.4260geomean0.4809
-
Zef Change #9: Setters
nbody0.2850splay2.6690richards0.1514deltablue0.4072geomean0.4651
-
Zef Change #10: callMethod inline
nbody0.2533splay2.6711richards0.1513deltablue0.4032geomean0.4506
-
Zef Change #11: Hashtable
nbody0.1796splay2.6528richards0.1379deltablue0.3551geomean0.3906
-
Zef Change #12: Avoid std::optional
nbody0.1689splay2.6563richards0.1379deltablue0.3518geomean0.3839
-
Zef Change #13: Specialized Arguments
nbody0.1610splay2.5823richards0.1350deltablue0.3372geomean0.3707
-
Zef Change #14: Improved Value Slow Paths
nbody0.1348splay2.5062richards0.1241deltablue0.3076geomean0.3367
-
Zef Change #15: Deduplicated DotSetRMW::evaluate
nbody0.1342splay2.5047richards0.1256deltablue0.3079geomean0.3375
-
Zef Change #16: Fast sqrt
nbody0.1274splay2.5045richards0.1251deltablue0.3060geomean0.3322
-
Zef Change #17: Fast toString
nbody0.1282splay2.2664richards0.1275deltablue0.2964geomean0.3235
-
Zef Change #18: Array Literal Specialization
nbody0.1295splay1.6661richards0.1250deltablue0.2979geomean0.2992
-
Zef Change #19: Value callOperator Optimization
nbody0.1208splay1.6698richards0.1143deltablue0.2713geomean0.2810
-
Zef Change #20: Better C++ Configuration
nbody0.1186splay1.6521richards0.1127deltablue0.2635geomean0.2760
-
Zef Change #21: No Asserts
nbody0.1194splay1.6504richards0.1127deltablue0.2619geomean0.2759
-
Zef in Yolo-C++
nbody0.0233splay0.3992richards0.0309deltablue0.0784geomean0.0686
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
ในแง่คล้ายกัน หน้า นี้ ที่พูดถึงประสิทธิภาพของอินเทอร์พรีเตอร์ Wren ก็น่าสนใจมาก
ถ้าบทความของ Zef เน้นเทคนิคการติดตั้งใช้งาน ฝั่ง Wren ก็ทำให้รู้สึกว่า การออกแบบภาษา เองมีส่วนต่อประสิทธิภาพอย่างไรด้วย
โดยเฉพาะ Wren ที่ยอมไม่ใช้ dynamic object shapes ทำให้ใช้ copy-down inheritance ได้ และทำให้การค้นหาเมธอดง่ายขึ้นมาก ดูเป็นจุดที่ดีมาก
ส่วนตัวมองว่าเป็น trade-off ที่ค่อนข้างดี เพราะในทางปฏิบัติจะมีบ่อยแค่ไหนกันที่ต้องมาเพิ่มเมธอดหลังจากสร้างคลาสไปแล้ว
มี VM จำนวนมากที่ถูกปรับแต่งมาดีมากสำหรับภาษาดynamic แต่ที่ LuaJIT แข็งแกร่งก็เพราะ Lua เป็นภาษาที่เล็กมากและเข้ากับการทำ optimization ได้ดีมาก
มันก็มีฟีเจอร์บางอย่างที่ optimize ยากอยู่บ้าง แต่มีไม่มากพอจนคุ้มกับการลงแรงแก้
แต่ Python ให้ความรู้สึกต่างออกไปมาก ถ้าจะพูดเกินจริงหน่อยก็คือมันเหมือนถูกออกแบบมาให้โอกาสของ JIT ที่เร็ว ต่ำที่สุด และความ dynamic หลายชั้นก็ซ้อนกันจนทำให้ optimize ยากมากจริงๆ
ที่แม้ทำงานกันมานานแล้ว JIT ของ CPython 3.15 บน x86_64 ก็ยังเร็วกว่าอินเทอร์พรีเตอร์ปกติแค่ราว 5% ก็ดูจะสะท้อนเรื่องนี้ได้ดี
แน่นอนว่าพอนึกถึง Ruby ก็จะนึกได้พร้อมกันว่ามันไม่ใช่ภาษาที่ขึ้นชื่อเรื่องเอาความเร็วเป็นอันดับแรก
ในทางกลับกัน แนวคิดที่ว่าประเภทหนึ่งมีชุดฟังก์ชันที่ใช้ได้แบบปิดตายก็ดูชวนสงสัยอยู่บ้าง
มีหลายภาษาที่ให้คุณนิยามฟังก์ชันตามใจ แล้วเอาไปใช้กับตัวแปรที่อาร์กิวเมนต์ตัวแรกตรงชนิดได้เหมือนเมธอดแบบ dot notation
ตัวอย่างเช่น macro ของ Nim, implicit classes และ type classes ของ Scala, extension functions ของ Kotlin, หรือ traits ของ Rust
ภาษาดynamicที่ซับซ้อนมักทำลายความเป็นไปได้นั้นอย่างจริงจังด้วยหลายวิธี จึงทำให้ optimize ได้ยากขึ้น
พอมองย้อนกลับไปแล้วก็ดูเป็นเรื่องที่ค่อนข้างชัดเจนดี
ตอนเปลี่ยนจาก #5 ไป #6 ที่ inline caches กับ hidden-class object model สร้างการเพิ่มประสิทธิภาพส่วนใหญ่ขึ้นมา มันให้ความรู้สึกคล้ายมากกับวิธีที่ V8 หรือ JSC เร็วขึ้นในอดีต
จุดที่อินเทอร์พรีเตอร์แบบตรงไปตรงมาพังจริงๆ ก็คือ dynamic dispatch ของการเข้าถึงพร็อพเพอร์ตี ส่วนที่เหลือดูเหมือนใกล้เคียงกับ rounding error
ผมชอบที่เขาแยกให้ดูได้ว่าแต่ละขั้นช่วยมากน้อยแค่ไหน เพราะปกติบทความเรื่องประสิทธิภาพมักโยนแค่ตัวเลขสุดท้ายแล้วจบ
ใน bytecode interpreter คุณแค่แพตช์ offset ที่คงที่ใน bytecode stream ก็เลยมีตำแหน่งธรรมชาติสำหรับเขียน IC กลับเข้าไป
แต่ที่นี่ตำแหน่งแคชคือตัว AST node ทำให้จุดที่ @pizlonator ใช้
constructCache<>เพื่อสร้าง specialized AST node ทับ generic node แบบ in-place ดูน่าประทับใจมากสุดท้ายแล้วมันดูเหมือน self-modifying code ในระดับ AST
แต่แนวทางนี้ต้องอาศัย mutable AST nodes จึงขัดกับสมมติฐานเรื่อง AST แบบ immutable ที่คอมไพเลอร์จำนวนมากคาดหวัง เช่นการแชร์ subtree หรือการคอมไพล์แบบขนาน
ถ้าเป็นอินเทอร์พรีเตอร์เธรดเดียวก็ดูสะอาดดี แต่ถ้าระหว่างที่ JIT กำลังคอมไพล์ AST เดียวกันอยู่ใน background thread แล้วอินเทอร์พรีเตอร์มาเปลี่ยน node ไปด้วย ก็น่าจะมีปัญหาได้
ผมคิดว่ามันอาจไม่ได้เป็นตัวแทนของโค้ดงานจริงส่วนใหญ่ได้ดีนัก
ที่รู้สึกแบบนั้นก็เพราะมีช่วงหนึ่งบอกว่า optimization ของ sqrt ทำให้ดีขึ้น 1.6%
ถ้าจะดีขึ้นได้ระดับนั้น ก็แปลว่าเดิมทีเวลา benchmark ต้องใช้ไปกับส่วนนั้นเกิน 1.6% อยู่แล้ว ซึ่งค่อนข้างน่าประหลาดใจ
พอไปดู git repo ก็เห็นว่าใน nbody simulation มันเกิดแบบนั้นจริงๆ
ผมเพิ่งปล่อยเวอร์ชันแรกของ AST-walking interpreter ของตัวเองไปไม่นาน เลยอ่านแล้วสนใจเป็นพิเศษ
เป้าหมายของผมคืออยากเข้าใจในระดับพื้นฐานว่าการสร้างภาษาอินเทอร์พรีเตอร์ต้องใช้อะไรบ้าง
ผมไม่อยากใส่ความซับซ้อนด้าน optimization และโฟกัสแค่ทำให้ตัวเองเข้าใจโค้ด Rust ของตัวเองได้จริงๆ
แต่ก็แปลกใจที่แค่ใช้ Rust ซึ่งเป็นภาษาที่ผมชอบ ประสิทธิภาพก็ออกมาดีพอสมควรแล้ว
แถม Rust ยังช่วยดูแลเรื่อง ownership และ lifetimes ให้ เลยเหมือนได้โบนัสตรงที่ไม่ต้องมี garbage collector แยกต่างหาก
แน่นอนว่าตอนนี้ผมยังพึ่ง clone แบบค่อนข้างอนุรักษ์นิยมอยู่มากเพื่อเลี่ยงนรกของ lifetime ในส่วนอย่าง closure แต่ถึงอย่างนั้นความเร็วและ memory profile ก็ยังรู้สึกว่าดีพอมาก
ถ้าสนใจ tree-walking interpreter ที่เรียบง่าย เข้าใจง่าย และเขียนด้วย Rust ก็ดูอินเทอร์พรีเตอร์ของผม gluonscript ได้
บทความนี้ดีมากจริงๆ
โดยเฉพาะส่วน Arguments arc หรือเส้นทางจาก #7 ไป #13 ที่ตรงกับประสบการณ์ของผมมาก
เมื่อก่อนผมเคยทำ async step evaluator ด้วย Rust แล้วในตอนนั้นก็เชื่อว่าการ borrow น่าจะช่วยได้ เลยลงลึกกับ
Cow<'_, Input>มากพอสมควรใน microbench มันดูดี แต่ใน workload จริง ความซับซ้อนของ discriminant และ lifetime ของ Cow กลับแพร่ไปตาม combinator ทั้งหมดหลัง
awaitแรก จนการ inline พังลงเยอะและเหตุผลที่ใช้ Cow ก็หายไปเองสุดท้ายผมเปลี่ยนที่ขอบเขตของ evaluator ไปใช้
NoInput / OneInput / MultiInput(Vec)ซึ่งแม้จะดูหยาบๆ แต่สุดท้ายก็มาลงที่จุดเดียวกับการแยก ZeroArguments / OneArgument / TwoArguments ที่นี่แทบพอดีสิ่งหนึ่งที่ผมยังสงสัยอยู่ตลอดคือ เคยลองซ้อน type specialization เพิ่มบน arity specialization ในเส้นทาง native หรือยัง
เช่น ถ้าไปทาง binary อาจตัดการเช็ก
isIntออกได้เลยก็ได้ผมเดาว่าอาจจะติดข้อจำกัดเรื่องขนาดโค้ด หรือไม่ก็ฝั่ง object มี IC จัดการ hot path ไปมากพอแล้วจน native fast path ไม่ค่อยมีผล
เลยอยากรู้ว่าเป็นกรณีไหน
งานนี้น่าสนใจมากและทำออกมาได้ดีจริงๆ
ผมเองก็เคยทำอะไรคล้ายๆ กัน แต่เป็นฝั่ง Scheme ซึ่งออกไปทางภาษาสายฟังก์ชันมากกว่า
ที่นี่การ optimize object ให้ผลมากที่สุด แต่ในกรณีของผม จุดชี้ขาดที่สุดกลับเป็นการ optimize closures
ที่น่าสนใจก็คือวิธี optimize เองกลับคล้ายกันมาก
ถ้าจะทำให้ Scheme เร็วพอ คำตอบแทบทั้งหมดอยู่ใน Three implementation models for scheme
เพียงแต่ว่าฝั่งนี้มีขั้นตอนการคอมไพล์อยู่พอสมควร จึงไม่ใช่โมเดลที่อินเทอร์พรีต AST เดิมตรงๆ แบบนี้
น่าสนใจดีและขอบคุณที่แชร์
ผมเองก็รู้สึกว่าอยากหาเวลามาขุดลึกหัวข้อนี้เหมือนกันสักวัน
แล้วก็ขำและประทับใจตรงที่บน Github repo นี้เป็น 99.7% HTML กับ 0.3% C++
มันเหมือนเป็นหลักฐานว่าอินเทอร์พรีเตอร์ตัวนี้เล็กมากจริงๆ
ฝั่งเว็บมันเลยดูใหญ่เกินจำเป็นจากวิธี generate โค้ดสำหรับเบราว์เซอร์
แต่ถึงอย่างนั้นตัวอินเทอร์พรีเตอร์เองก็ เล็กมากจริงๆ
ผมสงสัยว่าระหว่างทำงานนี้ได้เรียนรู้อะไรที่ช่วยทำให้ fil c เองดีขึ้นบ้างไหม
และยังได้เรียนรู้ด้วยว่าต้นทุนของการจัดการเมธอดของ value object ด้วย outline call นั้นสูงพอสมควร
เห็นว่ามี Lua อยู่แล้ว แต่ก็แอบคิดว่าน่าจะมี LuaJIT ด้วย
ไม่สิ ถ้าคิดถึงระดับงานวิศวกรรมที่ใส่ไป ก็ควรจะเป็นแบบนั้นด้วยซ้ำ
มี runtime อีกหลายตัวที่ใส่ได้ แต่ผมไม่ได้ใส่มาทั้งหมด
แล้วการที่ PUC Lua เร็วกว่า QuickJS หรือ Python อยู่พอสมควรก็ดู น่าประทับใจ มากเหมือนกัน
ผมสงสัยว่าประสบการณ์ใช้งาน Fil-C จริงๆ เป็นอย่างไร และมันมี ประโยชน์ใช้สอยจริง ในงานภาคปฏิบัติหรือไม่
แต่ถึงอย่างนั้นในโปรเจกต์นี้มันช่วยได้จริงค่อนข้างมาก
มันช่วยจับปัญหา memory safety หลายอย่างได้แบบ deterministic ทำให้การออกแบบ object model ง่ายกว่ามากเมื่อเทียบกับกรณีที่ไม่มีมัน
อีกอย่าง C++ ที่มี GC แบบแม่นยำก็ดูเป็นโมเดลการเขียนโปรแกรมที่ดีมากจริงๆ
ผมรู้สึกว่าผลิตภาพสูงกว่า C++ ปกติประมาณ 1.5 เท่า และเมื่อเทียบกับภาษา GC อื่นๆ ก็ยังรู้สึกว่าพัฒนาได้เร็วกว่าอยู่ราว 1.2 เท่า
ผมคิดว่าสาเหตุคือ ecosystem ของ API ฝั่ง C++ นั้นอุดมสมบูรณ์มาก และ lambdas, templates, class system ก็โตเต็มที่มากแล้ว
แน่นอนว่าผมก็ยอมรับว่าตัวเองมีอคติอยู่หลายด้าน
เพราะผมเป็นคนสร้าง Fil-C++ เอง และก็ใช้ C++ มาราว 35 ปี แล้ว
ผมสงสัยว่าคอมไพเลอร์ YOLO-C/C++ ที่พูดถึงในบทความคืออะไร
ค้นหาก็ไม่ค่อยเจอ และ chatgpt เองก็ดูไม่รู้จัก