6 คะแนน โดย GN⁺ 8 일 전 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • แม้แต่อินเทอร์พรีเตอร์แบบ เดิน 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 ถูกแทนด้วยวิธี offset 0x1000000000000
      • อธิบายว่าเป็นเทคนิคที่เรียนมาจาก JavaScriptCore
      • ในเอกสารมักเรียกว่า NuN tagging
    • จำนวนเต็มและพอยน์เตอร์ใช้การแทนแบบเนทีฟ
      • อาศัยสมมติฐานว่าค่าพอยน์เตอร์จะ ไม่ต่ำกว่า 0x100000000
      • ผู้เขียนระบุเองว่าเป็นทางเลือกที่เสี่ยง
      • กล่าวถึงทางเลือกว่าจริงๆ แล้วสามารถใส่แท็กบิตบนของจำนวนเต็มเป็น 0xffff000000000000 ได้
    • การแทนค่านี้ทำให้ทำ fast path สำหรับการคำนวณตัวเลขแบบอิงการทดสอบบิตได้
    • ข้อดีที่สำคัญยิ่งกว่าคือ หลีกเลี่ยงการจองหน่วยความจำบน heap สำหรับตัวเลข
    • เมื่อต้องสร้างอินเทอร์พรีเตอร์ใหม่ การเลือกการแทนค่าพื้นฐานให้ดีตั้งแต่ต้น เป็นเรื่องสำคัญมาก เพราะเปลี่ยนทีหลังได้ยากมาก
    • ผู้เขียนเสนอให้ใช้ tagged value แบบ 32 บิตหรือ 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 ในหลายจุด
    • ใช้สตริงมากเกินไป
      • AST node แบบ Get เก็บ std::string ที่อธิบายชื่อตัวแปร
      • และใช้สตริงนั้นทุกครั้งที่เข้าถึงตัวแปร
    • ใช้ hash table มากเกินไป
      • ระหว่างรัน Get จะค้นหาใน std::unordered_map ด้วยคีย์แบบสตริง
    • ค้นหา scope ด้วยสาย recursive call
      • อนุญาตให้มีการซ้อนกันของโครงสร้างเกือบทุกแบบและมี closure
      • ในการซ้อนกันอย่างฟังก์ชัน F ภายในมีคลาส A และในคลาส B มีฟังก์ชัน G เมธอดของ A จะมองเห็นได้ทั้ง ฟิลด์ของ A, ตัวแปรโลคัลของ F, ฟิลด์ของ B และตัวแปรโลคัลของ G
      • การทำงานเดิมจัดการสิ่งนี้ด้วย ฟังก์ชัน recursive ของ C++ ที่คอยไต่ถาม scope object คนละตัว
  • คุณลักษณะของการใช้งานเดิม

    • แม้จะมีทางเลือกที่ผิดหลายอย่าง แต่ก็ยังสามารถสร้าง อินเทอร์พรีเตอร์ภาษาที่ค่อนข้างซับซ้อน ได้ด้วยโค้ดไม่มาก
    • โมดูลที่ใหญ่ที่สุดคือ 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::callMethod
      • Value::callMethod ทำ การเปรียบเทียบสตริงหลายครั้ง
    • หลังการเปลี่ยนแปลง พาร์เซอร์จะสร้างโหนด Binary<>, Unary<>
      • ใช้เทมเพลตและแลมบ์ดาเพื่อให้ Node::evaluate override ที่แตกต่างกัน สำหรับตัวดำเนินการแต่ละตัว
      • แต่ละโหนดจะเรียก เส้นทางลัด 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]
    • แต่ละการเรียก virtual ใช้แมโคร SPECIALIZE_NEW_RMW
      • SetRMW คือ id += value
      • DotSetRMW คือ expr.id += value
      • SubscriptRMW คือ expr[index] += value
    • การ specialize ตัวดำเนินการในข้อเปลี่ยนแปลง #1 ใช้การดิสแพตช์ด้วยแลมบ์ดา
    • ส่วน RMW ใช้ enum
      • เลือกแบบนี้เพราะต้องรองรับทั้ง 3 เส้นทางคือ get, dot, subscript และต้องส่ง enum นี้ไปหลายตำแหน่ง
      • สุดท้าย ฟังก์ชันเทมเพลต Value::callRMW<> จะทำหน้าที่ดิสแพตช์การเรียกตัวดำเนินการ RMW จริง
    • ผลด้านประสิทธิภาพคือ ดีขึ้น 3.7%
      • ณ จุดนี้ประสิทธิภาพยัง ช้ากว่า CPython 3.10 อยู่ 29 เท่า
      • ช้ากว่า Lua 5.4.7 อยู่ 65 เท่า
      • ช้ากว่า QuickJS-ng 0.14.0 อยู่ 18.5 เท่า
      • เร็วขึ้น 1.22 เท่า เมื่อเทียบกับจุดเริ่มต้น
  • การปรับแต่ง #3: หลีกเลี่ยงการตรวจสอบ IntObject

    • คอขวดอยู่ที่เส้นทางลัดของ Value ใช้ isInt() และภายใน isIntSlow() มีการเรียก virtual Object::isInt()
    • เดิมทีรูปแบบการแทนค่ามีอยู่ 4 กรณี
      • tagged int32
      • tagged double
      • IntObject สำหรับ int64 ที่ไม่สามารถแทนด้วย int32 ได้
      • อ็อบเจ็กต์อื่นทั้งหมด
    • แม้ในกรณี IntObject การดิสแพตช์เมธอดของจำนวนเต็มก็ยังให้ Value รับผิดชอบ
      • เพื่อให้ implementation ของการคำนวณเลขคณิตทั้งหมดอยู่รวมกันที่เดียว คือใน Value
    • หลังปรับแต่ง เส้นทางลัดของ Value จะ พิจารณาเฉพาะ int32 และ double
      • ย้ายตรรกะจัดการ IntObject ไปไว้ใน IntObject เอง
      • จึง หลีกเลี่ยงการเรียก isInt() ที่เคยเกิดขึ้นทุกครั้งที่มีการดิสแพตช์เมธอด
    • ผลด้านประสิทธิภาพคือ ดีขึ้น 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* เท่านั้น
    • ใช้ 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 เป็นอีกเฮดเดอร์หนึ่ง ก็เพราะมันใช้ เฮดเดอร์ที่จำเป็นต้อง include value.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 และ Foo close 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 ที่เป็นเป้าหมายของการแคชแต่ละตัวมี 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.getter
      • o.function()
    • ใน Zef โดยทั่วไปทั้งสองอย่างเป็นการเรียกฟังก์ชัน แต่มีข้อยกเว้นคือโค้ดต่อไปนี้
      • o.NestedClass
      • o.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 เท่า เมื่อเทียบกับจุดเริ่มต้น
  • การปรับแต่ง #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 f
      • fn 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 เท่า เมื่อเทียบกับจุดเริ่มต้น
  • การปรับแต่ง #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 เท่า เมื่อเทียบกับจุดเริ่มต้น
  • การปรับแต่ง #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 เท่า
  • เร็วขึ้น 12 เท่า เมื่อเทียบกับจุดเริ่มต้น

  • การปรับแต่งประสิทธิภาพ #13: อาร์กิวเมนต์แบบ specialized

    • ฟังก์ชัน built-in ทั้งหมดของ Zef รับอาร์กิวเมนต์ 1 หรือ 2 ตัว และในการทำงานแบบเนทีฟ ไม่จำเป็นต้องจัดสรรออบเจ็กต์ Arguments เพื่อเก็บสิ่งเหล่านี้
    • setter ก็รับ อาร์กิวเมนต์หนึ่งตัว เสมอ และหากมีการอนุมาน setter แล้ว implementation ของ setter แบบ specialized ก็เพียงรับอาร์กิวเมนต์ค่าโดยตรงได้เลยโดยไม่ต้องมีออบเจ็กต์ Arguments
    • การเปลี่ยนแปลงครั้งนี้ได้เพิ่มชนิดอาร์กิวเมนต์แบบ specialized คือ ZeroArguments, OneArgument, TwoArguments
      • หาก callee ไม่ต้องการ caller ก็จะ หลีกเลี่ยงการจัดสรรออบเจ็กต์ Arguments ได้
    • จำเป็นต้องมี ZeroArguments เพื่อ แยกความต่างจาก (Arguments*)nullptr
      • ก่อนหน้านี้ใช้ (Arguments*)nullptr เพื่อ สื่อถึงการเรียก getter และยังคงตรรกะนี้ไว้
      • ตอนนี้ ZeroArguments หมายถึง การเรียกฟังก์ชันที่ไม่มีอาร์กิวเมนต์
    • การเปลี่ยนแปลงจำนวนมากประกอบด้วยการทำให้ฟังก์ชันที่รับอาร์กิวเมนต์ เป็นเทมเพลต
      • มีการทำ explicit instantiation สำหรับแต่ละแบบคือ ZeroArguments, OneArgument, TwoArguments, Arguments*
      • โค้ดเดิมจำนวนมากใช้ Value::getArg เป็นเฮลเปอร์สำหรับดึงอาร์กิวเมนต์ และได้เพิ่ม โอเวอร์โหลดสำหรับอาร์กิวเมนต์แบบ specialized เข้าไปที่นี่
      • การแก้โค้ดเนทีฟที่ใช้อาร์กิวเมนต์นั้นเป็นการปรับที่ค่อนข้างตรงไปตรงมา
    • ผลด้านประสิทธิภาพคือ ดีขึ้น 3.8%
      • ณ จุดนี้ Zef ช้ากว่า CPython 3.10 อยู่ 2.9 เท่า
      • ช้ากว่า Lua 5.4.7 อยู่ 6.4 เท่า
      • ช้ากว่า QuickJS-ng 0.14.0 อยู่ 1.8 เท่า
      • เร็วขึ้น 12.4 เท่า เมื่อเทียบกับจุดเริ่มต้น

การเลี่ยงพยาธิสภาพของ Fil-C และการทำ specializaton แบบละเอียด

  • การปรับแต่ง #14: ปรับปรุง Value slow path

    • ได้ความเร็วเพิ่มขึ้นมากจากการ เลี่ยงพยาธิสภาพของ Fil-C อีกแบบหนึ่ง
    • ก่อนเปลี่ยนแปลง slow path แบบ out-of-line ของ Value เป็นเมธอดสมาชิกของ Value และต้องใช้อาร์กิวเมนต์แบบแฝง const Value*
    • โครงสร้างนี้ทำให้ caller ต้อง จัดสรร Value บนสแตก
    • ใน Fil-C++ การ จัดสรรบนสแตกทั้งหมดคือการจัดสรรบนฮีป
      • ดังนั้นโค้ดที่เรียก slow path จึงต้อง จัดสรร Value บนฮีป
    • หลังเปลี่ยนแปลง เมธอดเหล่านี้ถูกเปลี่ยนเป็น 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 เท่า เมื่อเทียบกับจุดเริ่มต้น
  • การปรับแต่ง #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 แบบอ้างอิง มาใช้กับ callOperator slow 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
    • การเปลี่ยนแปลงนี้ยังรวมถึงการแก้ไขอื่น ๆ เพื่อให้โค้ด build ได้บน Yolo-C++
    • แต่ต่างจากที่คาดไว้ ไม่มีความเร็วเพิ่มขึ้น

ผลลัพธ์และข้อจำกัดของ Yolo-C++

  • เมื่อนำโค้ดไปคอมไพล์ด้วย Yolo-C++ ได้ ความเร็วเพิ่มขึ้น 4 เท่า
  • อย่างไรก็ตาม วิธีนี้ ไม่ sound และยัง suboptimal
    • ที่ไม่ sound เพราะการเรียก GC ของ Fil-C++ เดิมจะถูกเปลี่ยนเป็น การเรียก calloc
    • ผลคือหน่วยความจำไม่ถูกคืน และในเวิร์กโหลดที่รันนานพอ อินเทอร์พรีเตอร์จะ ใช้หน่วยความจำจนหมด
    • ใน ScriptBench1 ไม่มีปัญหาหน่วยความจำหมด เพราะเวลาทดสอบสั้น
  • ที่ 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

    • nbody 0.0364
    • splay 0.8326
    • richards 0.0822
    • deltablue 0.1135
    • geomean 0.1296
  • Lua 5.4.7

    • nbody 0.0142
    • splay 0.4393
    • richards 0.0217
    • deltablue 0.0832
    • geomean 0.0577
  • QuickJS-ng 0.14.0

    • nbody 0.0214
    • splay 0.7090
    • richards 0.7193
    • deltablue 0.1585
    • geomean 0.2036
  • Zef Baseline

    • nbody 2.9573
    • splay 13.0286
    • richards 1.9251
    • deltablue 5.9997
    • geomean 4.5927
  • Zef Change #1: Direct Operators

    • nbody 2.1891
    • splay 12.0233
    • richards 1.6935
    • deltablue 5.2331
    • geomean 3.9076
  • Zef Change #2: Direct RMWs

    • nbody 2.0130
    • splay 11.9987
    • richards 1.6367
    • deltablue 5.0994
    • geomean 3.7677
  • Zef Change #3: Avoid IntObject

    • nbody 1.9922
    • splay 11.8824
    • richards 1.6220
    • deltablue 5.0646
    • geomean 3.7339
  • Zef Change #4: Symbols

    • nbody 1.5782
    • splay 9.9577
    • richards 1.4116
    • deltablue 4.4593
    • geomean 3.1533
  • Zef Change #5: Value Inline

    • nbody 1.4982
    • splay 9.7723
    • richards 1.3890
    • deltablue 4.3536
    • geomean 3.0671
  • Zef Change #6: Object Model and Inline Caches

    • nbody 0.3884
    • splay 3.3609
    • richards 0.2321
    • deltablue 0.6805
    • geomean 0.6736
  • Zef Change #7: Arguments

    • nbody 0.3160
    • splay 2.6890
    • richards 0.1653
    • deltablue 0.4738
    • geomean 0.5077
  • Zef Change #8: Getters

    • nbody 0.2988
    • splay 2.6919
    • richards 0.1564
    • deltablue 0.4260
    • geomean 0.4809
  • Zef Change #9: Setters

    • nbody 0.2850
    • splay 2.6690
    • richards 0.1514
    • deltablue 0.4072
    • geomean 0.4651
  • Zef Change #10: callMethod inline

    • nbody 0.2533
    • splay 2.6711
    • richards 0.1513
    • deltablue 0.4032
    • geomean 0.4506
  • Zef Change #11: Hashtable

    • nbody 0.1796
    • splay 2.6528
    • richards 0.1379
    • deltablue 0.3551
    • geomean 0.3906
  • Zef Change #12: Avoid std::optional

    • nbody 0.1689
    • splay 2.6563
    • richards 0.1379
    • deltablue 0.3518
    • geomean 0.3839
  • Zef Change #13: Specialized Arguments

    • nbody 0.1610
    • splay 2.5823
    • richards 0.1350
    • deltablue 0.3372
    • geomean 0.3707
  • Zef Change #14: Improved Value Slow Paths

    • nbody 0.1348
    • splay 2.5062
    • richards 0.1241
    • deltablue 0.3076
    • geomean 0.3367
  • Zef Change #15: Deduplicated DotSetRMW::evaluate

    • nbody 0.1342
    • splay 2.5047
    • richards 0.1256
    • deltablue 0.3079
    • geomean 0.3375
  • Zef Change #16: Fast sqrt

    • nbody 0.1274
    • splay 2.5045
    • richards 0.1251
    • deltablue 0.3060
    • geomean 0.3322
  • Zef Change #17: Fast toString

    • nbody 0.1282
    • splay 2.2664
    • richards 0.1275
    • deltablue 0.2964
    • geomean 0.3235
  • Zef Change #18: Array Literal Specialization

    • nbody 0.1295
    • splay 1.6661
    • richards 0.1250
    • deltablue 0.2979
    • geomean 0.2992
  • Zef Change #19: Value callOperator Optimization

    • nbody 0.1208
    • splay 1.6698
    • richards 0.1143
    • deltablue 0.2713
    • geomean 0.2810
  • Zef Change #20: Better C++ Configuration

    • nbody 0.1186
    • splay 1.6521
    • richards 0.1127
    • deltablue 0.2635
    • geomean 0.2760
  • Zef Change #21: No Asserts

    • nbody 0.1194
    • splay 1.6504
    • richards 0.1127
    • deltablue 0.2619
    • geomean 0.2759
  • Zef in Yolo-C++

    • nbody 0.0233
    • splay 0.3992
    • richards 0.0309
    • deltablue 0.0784
    • geomean 0.0686

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

 
GN⁺ 8 일 전
ความคิดเห็นจาก Hacker News
  • ในแง่คล้ายกัน หน้า นี้ ที่พูดถึงประสิทธิภาพของอินเทอร์พรีเตอร์ Wren ก็น่าสนใจมาก
    ถ้าบทความของ Zef เน้นเทคนิคการติดตั้งใช้งาน ฝั่ง Wren ก็ทำให้รู้สึกว่า การออกแบบภาษา เองมีส่วนต่อประสิทธิภาพอย่างไรด้วย
    โดยเฉพาะ Wren ที่ยอมไม่ใช้ dynamic object shapes ทำให้ใช้ copy-down inheritance ได้ และทำให้การค้นหาเมธอดง่ายขึ้นมาก ดูเป็นจุดที่ดีมาก
    ส่วนตัวมองว่าเป็น trade-off ที่ค่อนข้างดี เพราะในทางปฏิบัติจะมีบ่อยแค่ไหนกันที่ต้องมาเพิ่มเมธอดหลังจากสร้างคลาสไปแล้ว

    • ผมมองว่าความเร็วของอินเทอร์พรีเตอร์หรือ JIT ถูกกำหนดอย่างมากโดย การออกแบบภาษา
      มี VM จำนวนมากที่ถูกปรับแต่งมาดีมากสำหรับภาษาดynamic แต่ที่ LuaJIT แข็งแกร่งก็เพราะ Lua เป็นภาษาที่เล็กมากและเข้ากับการทำ optimization ได้ดีมาก
      มันก็มีฟีเจอร์บางอย่างที่ optimize ยากอยู่บ้าง แต่มีไม่มากพอจนคุ้มกับการลงแรงแก้
      แต่ Python ให้ความรู้สึกต่างออกไปมาก ถ้าจะพูดเกินจริงหน่อยก็คือมันเหมือนถูกออกแบบมาให้โอกาสของ JIT ที่เร็ว ต่ำที่สุด และความ dynamic หลายชั้นก็ซ้อนกันจนทำให้ optimize ยากมากจริงๆ
      ที่แม้ทำงานกันมานานแล้ว JIT ของ CPython 3.15 บน x86_64 ก็ยังเร็วกว่าอินเทอร์พรีเตอร์ปกติแค่ราว 5% ก็ดูจะสะท้อนเรื่องนี้ได้ดี
    • วิธีแบบนี้ดูคล้ายกับสิ่งที่ภาษาซึ่งยอมรับ monkey patching เป็นเรื่องปกติทำกันอยู่เสมอ โดยเฉพาะ Ruby
      แน่นอนว่าพอนึกถึง Ruby ก็จะนึกได้พร้อมกันว่ามันไม่ใช่ภาษาที่ขึ้นชื่อเรื่องเอาความเร็วเป็นอันดับแรก
      ในทางกลับกัน แนวคิดที่ว่าประเภทหนึ่งมีชุดฟังก์ชันที่ใช้ได้แบบปิดตายก็ดูชวนสงสัยอยู่บ้าง
      มีหลายภาษาที่ให้คุณนิยามฟังก์ชันตามใจ แล้วเอาไปใช้กับตัวแปรที่อาร์กิวเมนต์ตัวแรกตรงชนิดได้เหมือนเมธอดแบบ dot notation
      ตัวอย่างเช่น macro ของ Nim, implicit classes และ type classes ของ Scala, extension functions ของ Kotlin, หรือ traits ของ Rust
    • จากประสบการณ์ของผม โดยทั่วไปถ้าคุณสามารถใส่ static type ให้ expression ใด expression หนึ่งได้ คุณก็มักจะคอมไพล์มันออกมาให้มีประสิทธิภาพได้ค่อนข้างดี
      ภาษาดynamicที่ซับซ้อนมักทำลายความเป็นไปได้นั้นอย่างจริงจังด้วยหลายวิธี จึงทำให้ optimize ได้ยากขึ้น
      พอมองย้อนกลับไปแล้วก็ดูเป็นเรื่องที่ค่อนข้างชัดเจนดี
  • ตอนเปลี่ยนจาก #5 ไป #6 ที่ inline caches กับ hidden-class object model สร้างการเพิ่มประสิทธิภาพส่วนใหญ่ขึ้นมา มันให้ความรู้สึกคล้ายมากกับวิธีที่ V8 หรือ JSC เร็วขึ้นในอดีต
    จุดที่อินเทอร์พรีเตอร์แบบตรงไปตรงมาพังจริงๆ ก็คือ dynamic dispatch ของการเข้าถึงพร็อพเพอร์ตี ส่วนที่เหลือดูเหมือนใกล้เคียงกับ rounding error
    ผมชอบที่เขาแยกให้ดูได้ว่าแต่ละขั้นช่วยมากน้อยแค่ไหน เพราะปกติบทความเรื่องประสิทธิภาพมักโยนแค่ตัวเลขสุดท้ายแล้วจบ

    • รายละเอียดการติดตั้งใช้งานที่น่าสนใจเป็นพิเศษใน #6 คือ ในอินเทอร์พรีเตอร์ที่เดิน AST โดยตรงนั้นจะทำ inline caching อย่างไร
      ใน 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 ไปด้วย ก็น่าจะมีปัญหาได้
    • โดยรวมผมเห็นด้วยกับทิศทางนี้ แต่ก็มองว่ามีหมายเหตุเล็กๆ ว่านี่เป็นผลจาก benchmark เฉพาะตัว เพียงตัวเดียวเท่านั้น
      ผมคิดว่ามันอาจไม่ได้เป็นตัวแทนของโค้ดงานจริงส่วนใหญ่ได้ดีนัก
      ที่รู้สึกแบบนั้นก็เพราะมีช่วงหนึ่งบอกว่า 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++
    มันเหมือนเป็นหลักฐานว่าอินเทอร์พรีเตอร์ตัวนี้เล็กมากจริงๆ

    • มันเป็นแบบนั้นเพราะมีการ commit เว็บไซต์ที่ generate แบบ static ไว้ด้วย
      ฝั่งเว็บมันเลยดูใหญ่เกินจำเป็นจากวิธี generate โค้ดสำหรับเบราว์เซอร์
      แต่ถึงอย่างนั้นตัวอินเทอร์พรีเตอร์เองก็ เล็กมากจริงๆ
  • ผมสงสัยว่าระหว่างทำงานนี้ได้เรียนรู้อะไรที่ช่วยทำให้ fil c เองดีขึ้นบ้างไหม

    • แน่นอนว่าผมรู้สึกว่าวิธีจัดการ unions น่าจะต้องมีคำตอบที่ดีกว่านี้
      และยังได้เรียนรู้ด้วยว่าต้นทุนของการจัดการเมธอดของ value object ด้วย outline call นั้นสูงพอสมควร
  • เห็นว่ามี Lua อยู่แล้ว แต่ก็แอบคิดว่าน่าจะมี LuaJIT ด้วย

    • ผมเดาว่า LuaJIT น่าจะชนะ Zef แบบขาดลอย
      ไม่สิ ถ้าคิดถึงระดับงานวิศวกรรมที่ใส่ไป ก็ควรจะเป็นแบบนั้นด้วยซ้ำ
      มี runtime อีกหลายตัวที่ใส่ได้ แต่ผมไม่ได้ใส่มาทั้งหมด
      แล้วการที่ PUC Lua เร็วกว่า QuickJS หรือ Python อยู่พอสมควรก็ดู น่าประทับใจ มากเหมือนกัน
  • ผมสงสัยว่าประสบการณ์ใช้งาน Fil-C จริงๆ เป็นอย่างไร และมันมี ประโยชน์ใช้สอยจริง ในงานภาคปฏิบัติหรือไม่

    • ผมคือ Fil เอง ดังนั้นขอออกตัวก่อนว่ามีอคติแน่นอน
      แต่ถึงอย่างนั้นในโปรเจกต์นี้มันช่วยได้จริงค่อนข้างมาก
      มันช่วยจับปัญหา 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 เองก็ดูไม่รู้จัก

    • ผู้เขียน Fil-C และผู้สร้างภาษานี้ใช้คำว่า Yolo-C/C++ เพื่อหมายถึง C/C++ ปกติทั่วไปที่ไม่มี Fil-C