C ไม่ใช่ภาษาระดับต่ำ (2018)
(queue.acm.org)- ในยุค PDP-11 ภาษา C เคยสอดคล้องกับการทำ abstraction ของฮาร์ดแวร์ได้ดี แต่บน CPU สมัยใหม่ abstract machine ของ C ที่มองว่าโปรแกรมรันแบบลำดับและหน่วยความจำเป็นพื้นที่แบนราบ นั้นคลาดเคลื่อนจากฮาร์ดแวร์จริงอย่างมาก
- Spectre และ Meltdown เชื่อมโยงกับผลจากการที่โปรเซสเซอร์พึ่งพา branch prediction, speculative execution และ instruction-level parallelism อย่างหนัก เพื่อรันโมเดลแบบลำดับของ C ให้เร็ว
- การทำให้โค้ด C เร็วขึ้นไม่ใช่แค่การแปลงเป็นภาษาเครื่องอย่างง่าย แต่ต้องใช้การ optimize ที่ซับซ้อนระดับ LLVM/Clang และการ optimize บางอย่างอาจขัดกับ semantics ของ C
- กฎอย่าง pointer provenance, padding ใน struct, ค่าที่ยังไม่ได้ initialize และ signed integer overflow ทำให้คาดเดาผลลัพธ์การรันได้ยาก และอาจนำไปสู่ ช่องโหว่ด้านความปลอดภัย ได้ด้วย
- โมเดลที่เหมาะกับฮาร์ดแวร์สมัยใหม่มากกว่าคือการใช้เธรดจำนวนมาก หน่วยเวกเตอร์กว้าง และ memory model ที่เรียบง่าย แต่ ความเข้ากันได้กับโค้ด C เดิม ยังเป็นข้อจำกัดที่ใหญ่ที่สุด
เหตุผลที่ C เคยดูเหมือน “ระดับต่ำ”
- ถ้าเป็นภาษาระดับต่ำ abstraction ที่ฮาร์ดแวร์提供กับ abstract machine ของภาษาควรแมปเข้าหากันได้ง่าย
- บน PDP-11 สามารถมอง C เป็นภาษาระดับต่ำได้
- โปรแกรมรันแบบลำดับ
- หน่วยความจำถูกจัดการเหมือนพื้นที่แบนราบ
- ตัวดำเนินการ pre-increment และ post-increment เข้ากันได้ดีกับวิธี addressing ของ PDP-11
- Alan Perlis นิยามว่า “ภาษาใดเป็นภาษาระดับต่ำเมื่อโปรแกรมต้องใส่ใจกับสิ่งที่ไม่เกี่ยวข้อง” แต่เพียงนิยามนี้ยังอธิบาย “ความใกล้ชิดกับฮาร์ดแวร์” ที่ผู้คนคาดหวังจากภาษาระดับต่ำได้ไม่เพียงพอ
CPU สมัยใหม่ทำงานเหมือน emulator PDP-11 ที่เร็ว
- ต้นตอของ Spectre และ Meltdown ไม่ได้หยุดอยู่แค่การสร้างโปรเซสเซอร์ที่เร็ว แต่เกี่ยวพันกับการออกแบบโปรเซสเซอร์ที่พยายาม expose abstract machine แบบ PDP-11 ให้เร็ว
- โค้ด C ก่อน C11 หากไม่นับส่วนขยายเฉพาะ vendor ที่ไม่เป็นมาตรฐาน ก็แทบจะให้ เครื่องจักรแบบลำดับอย่างสมบูรณ์ และหลัง C11 ก็ยังคง abstract machine ที่ส่วนใหญ่เป็นลำดับไว้
- CPU สมัยใหม่ดึง instruction-level parallelism (ILP) ออกมาเพื่อให้ execution unit ยังคงทำงานต่อเนื่อง
- ตรวจสอบ operation ที่อยู่ใกล้กันและออก operation ที่เป็นอิสระต่อกันให้รันแบบขนาน
- แลกกับการอนุญาตให้โปรแกรมเมอร์เขียนโค้ดส่วนใหญ่แบบลำดับ ความซับซ้อนและการใช้พลังงานจึงเพิ่มขึ้น
- GPU สามารถให้ประสิทธิภาพสูงได้โดยไม่ต้องมีตรรกะแบบนี้ แต่ต้องการโปรแกรมแบบขนานที่ระบุอย่างชัดเจน
Spectre, Meltdown และต้นทุนของ speculative execution
- โปรเซสเซอร์ Intel สมัยใหม่สามารถมีคำสั่งอยู่ในสถานะ in-flight ได้สูงสุด 180 คำสั่ง ในครั้งเดียว
- ในโค้ด C โดยเฉลี่ยอาจมองได้ว่ามี branch ประมาณทุก ๆ 7 คำสั่ง
- เพื่อเติม pipeline ให้เต็มในเธรดเดียว ต้องเดา target ของ branch ถัดไป 25 จุด
- การเดาผิดทำให้เกิดผลลัพธ์ที่ทำงานไปแล้วแต่ต้องทิ้ง และยังเปลืองพลังงานด้วย
- Spectre และ Meltdown สามารถใช้ ผลข้างเคียงที่มองเห็นได้ ของงานที่ถูกทิ้งเหล่านี้เป็น side channel
- register rename engine ของคอร์ประสิทธิภาพสูงสมัยใหม่เป็นหนึ่งในส่วนที่กินพื้นที่ die และพลังงานมาก
- เมื่อคำสั่งกำลังรันอยู่ จะปิดหรือ power-gate ได้ยาก
- GPU ไม่มี unit แบบนี้ และ parallelism มาจากหลายเธรด
Memory model แบบแบนราบของ C ไม่สอดคล้องกับความจริงของ cache
- หน่วยความจำแบบแบนราบ ซึ่งเป็นแกนของ abstract machine ใน C ไม่สอดคล้องกับฮาร์ดแวร์จริงมานานกว่า 20 ปีแล้ว
- โปรเซสเซอร์สมัยใหม่โดยทั่วไปมี cache 3 ระดับระหว่าง register กับหน่วยความจำหลัก
- cache ตามชื่อของมัน ถูกซ่อนไว้จากโปรแกรมเมอร์และมองไม่เห็นใน C
- หากต้องการสร้างโค้ดที่เร็วบนโปรเซสเซอร์สมัยใหม่ ต้องใช้ cache อย่างมีประสิทธิภาพ
- โปรแกรมเมอร์ C ต้องรู้ไม่ใช่แค่ abstract machine แต่รวมถึงรายละเอียดการ implement ด้วย จึงจะทำประสิทธิภาพได้
- เช่น ค่าสองค่าที่ align ที่ 64 ไบต์อาจอยู่ใน cache line เดียวกันได้
ความซับซ้อนของ compiler ที่จำเป็นต่อการทำให้โค้ด C เร็ว
- ถ้าเป็นภาษาระดับต่ำ ก็ควรถูกแปลงเป็นโค้ดที่เร็วได้ง่ายโดยไม่ต้องใช้ compiler ซับซ้อน แต่ C ไม่เป็นเช่นนั้น
- Clang และส่วนที่เกี่ยวข้องของ LLVM มีขนาดประมาณ 2 ล้านบรรทัด
- หากนับเฉพาะ analysis และ transformation pass ที่จำเป็นต่อการรัน C ให้เร็ว ก็เกือบ 200,000 บรรทัด โดยไม่รวม comment และบรรทัดว่าง
- เมื่อต้องประมวลผลข้อมูลจำนวนมากใน C มักเขียน loop ที่ประมวลผลแต่ละ element ตามลำดับ
- เพื่อให้รันได้เหมาะที่สุดบน CPU สมัยใหม่ compiler ต้องตัดสินก่อนว่าแต่ละรอบของ loop เป็นอิสระต่อกันหรือไม่
- คีย์เวิร์ด
restrictสามารถให้การรับประกันได้ว่าการเขียนผ่าน pointer หนึ่งจะไม่รบกวนการอ่านผ่าน pointer อีกตัว
- Fortran ได้เปรียบ C ในแง่การให้ข้อมูลแบบนี้ และเป็นหนึ่งในเหตุผลหลักที่ C ไม่สามารถแทนที่ Fortran ในงาน high-performance computing ได้
ความขัดแย้งระหว่าง vectorization กับการรับประกัน layout หน่วยความจำของ C
- ถ้ารอบของ loop เป็นอิสระต่อกัน compiler จะพยายาม vectorize ผลลัพธ์
- โปรเซสเซอร์สมัยใหม่ทำ throughput จากโค้ดเวกเตอร์ได้มากกว่าโค้ด scalar 4–8 เท่า
- สำหรับภาษาระดับต่ำของโปรเซสเซอร์แบบนี้ การมี native vector type ความยาวใดก็ได้จึงเป็นเรื่องธรรมชาติ
- LLVM IR ให้โมเดลแบบนี้ เพราะการแบ่ง operation เวกเตอร์ใหญ่เป็น operation เล็กทำได้ง่ายกว่าย้อนกลับ
- การรับประกัน layout หน่วยความจำของ C ขัดกับการ optimize
- struct ที่มี prefix เหมือนกันสามารถใช้แทนกันได้
- offset ของ field ใน struct ถูก expose ในภาษา
- compiler จึงเปลี่ยนลำดับ field หรือแทรก padding เพื่อปรับปรุง vectorization ได้ยาก
- คุณสมบัติที่ควบคุม layout ของโครงสร้างข้อมูลได้ละเอียดอาจเป็นข้อดีของภาษาระดับต่ำ แต่ในเวลาเดียวกันก็ทำให้ทำ C ให้เร็วได้ยาก
ปัญหาของ padding, SROA และ loop unswitching
- C ต้องมี padding ท้าย struct เพื่อรับประกันว่าไม่มี padding ภายใน array
- เพราะ struct ต้องสามารถเปรียบเทียบแบบไม่สน type เช่น
memcmpได้ การคัดลอก struct จึงต้องคง padding ไว้ด้วย- ในการทดลองบางอย่าง สัดส่วนที่สังเกตได้ชัดของเวลารันทั้งหมดใน workload บางชนิดถูกใช้ไปกับการคัดลอก padding
- SROA เป็นการ optimize ที่พยายามแทนที่ struct และ array ความยาวคงที่ด้วยตัวแปรแยกกัน
- ทำให้จัดการการเข้าถึงแต่ละส่วนอย่างเป็นอิสระได้ และลบ operation ที่ผลลัพธ์ไม่ถูกสังเกตออกได้
- ในบางกรณีช่วยกำจัด padding แต่ไม่ใช่เสมอไป
- loop unswitching เป็นการ optimize ที่ย้ายเงื่อนไขใน loop ออกมานอกเงื่อนไข แล้วมี loop ไว้ทั้งสอง path
- ขัดกับแนวคิดที่ว่าเมื่อโค้ดภาษาระดับต่ำถูกรัน โปรแกรมเมอร์รู้ว่าโค้ดใดจะถูกรันเมื่อใด
- และยังสร้างปัญหากับแนวคิด unspecified value และ undefined behavior ของ C ด้วย
ค่าที่ยังไม่ได้ initialize และพฤติกรรมที่ไม่ถูกนิยาม
- ใน C เมื่ออ่านตัวแปรที่ยังไม่ได้ initialize จะได้ unspecified value และอาจมีค่าต่างกันทุกครั้งที่อ่าน
- กฎนี้อนุญาตพฤติกรรมอย่างการนำ page กลับมาใช้ใหม่แบบล่าช้า
- implementation ของ
mallocใน FreeBSD แจ้งให้ระบบปฏิบัติการทราบถึง page ที่ไม่ได้ใช้อยู่ในขณะนั้น - ระบบปฏิบัติการสามารถใช้การเขียนครั้งแรกลง page เป็น hint ว่า page นั้นจำเป็นต้องใช้อีกครั้ง
- implementation ของ
- หาก unspecified value ถูกใช้ใน flow control จะกลายเป็น undefined behavior
- เช่น กรณีใช้ค่าที่ยังไม่ได้ initialize ในเงื่อนไข
if
- เช่น กรณีใช้ค่าที่ยังไม่ได้ initialize ในเงื่อนไข
- ใน loop unswitching หาก loop รัน 0 ครั้ง โค้ดเดิมถือว่าเนื้อหา loop ทั้งหมดเป็น dead code
- หลัง unswitching อาจ branch ด้วยตัวแปรที่อาจยังไม่ได้ initialize
- เท่ากับว่า dead code ถูกเปลี่ยนเป็น undefined behavior
- สามารถทำให้โค้ด C เร็วได้ แต่การสร้าง compiler ที่ฉลาดพอต้องใช้เวลาหลายพัน person-years และบางครั้งต้องละเมิดกฎบางส่วนของภาษา
เหตุผลที่ C เข้าใจยากขึ้น
- หากเป็นภาษาระดับต่ำ โปรแกรมเมอร์ควรเข้าใจการแมประหว่าง abstract machine กับเครื่องจริงทางกายภาพได้ง่าย
- บน PDP-11 expression ของ C แมปเป็นคำสั่งหนึ่งหรือสองคำสั่งได้ง่าย และตัวแปร local กับ primitive type ก็สอดคล้องกับฮาร์ดแวร์อย่างเรียบง่าย
- หลังจากนั้น implementation ของ C ซับซ้อนขึ้นเรื่อย ๆ เพื่อคงภาพลวงตาว่าได้ทั้งโค้ดที่เร็วและการสอดคล้องกับฮาร์ดแวร์
- การสำรวจในปี 2015 กับโปรแกรมเมอร์ C, ผู้เขียน compiler และสมาชิกคณะกรรมการมาตรฐาน เผยให้เห็นปัญหาด้านความเข้าใจของ C
- เมื่อ initialize struct เป็น 0 แล้วตั้งค่า field บางส่วน 36% มั่นใจว่า padding bit ทั้งหมดเป็น 0 และ 29% ตอบว่าไม่รู้
- ผลจริงอาจแตกต่างกันไปตาม compiler และระดับการ optimize
Pointer provenance และช่องโหว่ด้านความปลอดภัย
- โมเดล BCPL เป็นโมเดลที่ค่อนข้างเรียบง่าย: ค่าเป็น word และแต่ละ word เป็นข้อมูลหรือ address ของข้อมูล
- โมเดล C ถูกออกแบบให้ implement ได้บน target หลากหลาย รวมถึงสถาปัตยกรรมแบบ segment และ virtual machine ที่มี garbage collection
- มาตรฐาน C จำกัด operation ที่ถูกต้องกับ pointer เพื่อหลีกเลี่ยงปัญหาในระบบเหล่านี้
- C Defect Report 260 ใส่แนวคิด pointer provenance ไว้ในนิยามของ pointer
- implementation สามารถติดตามที่มาของ bit pattern ได้
- แม้จะเหมือนกันในระดับ bit ก็สามารถแยก pointer ที่มีที่มาต่างกันได้
- คำว่า
provenanceไม่ปรากฏในสเปก C11 ทำให้ผู้เขียน compiler ต้องตัดสินความหมายเอง- GCC และ Clang แตกต่างกันในประเด็นว่า provenance ยังคงอยู่หรือไม่เมื่อแปลง pointer เป็น integer แล้วแปลงกลับเป็น pointer
- มีกรณีที่พบช่องโหว่ด้านความปลอดภัยจาก signed integer overflow และโค้ดที่ dereference pointer ก่อนตรวจ null
- การ dereference null pointer ใน C เป็น undefined behavior ดังนั้น compiler สามารถถือได้ว่า pointer ที่ถูก dereference ไปแล้วเป็น null ไม่ได้
- ตัวอย่างคือ CVE-2009-1897
จินตนาการถึงโปรเซสเซอร์ที่ไม่ใช่ C
- วิธีแก้ที่เสนอสำหรับ Spectre และ Meltdown มี performance penalty สูง และชดเชยความก้าวหน้าด้าน microarchitecture จำนวนมากในช่วง 10 ปีที่ผ่านมาไป
- ถึงเวลาคิดใหม่เรื่อง programming model ที่เหมาะกับโปรเซสเซอร์เร็ว ๆ แทนที่จะทำให้โค้ด C เร็ว
- ชิปแบบ multithread สูงอย่าง Sun/Oracle UltraSPARC Tx ไม่ต้องใช้ cache มากในการเติม execution unit
- หากมี parallelism ระดับสูงเพียงพอ ก็สามารถหยุดเธรดที่รอหน่วยความจำ และเติม execution unit ด้วยคำสั่งจากเธรดอื่นได้
- ปัญหาคือโปรแกรม C มีแนวโน้มจะมีเธรดที่กำลังทำงานจำนวนน้อย
- ARM SVE (Scalar Vector Extensions) เป็นตัวอย่างของ interface ที่ดีกว่าระหว่างโปรแกรมกับฮาร์ดแวร์
- หน่วยเวกเตอร์เดิม expose operation เวกเตอร์ขนาดคงที่ และคาดหวังให้ compiler ปรับ algorithm ให้เข้ากับขนาดนั้น
- SVE ให้โปรแกรมเมอร์บรรยายระดับของ parallelism ที่มีอยู่ แล้วให้ฮาร์ดแวร์แมปเข้ากับจำนวน execution unit
- ใน C autovectorizer ต้องอนุมาน parallelism จากโครงสร้าง loop จึงซับซ้อน แต่ใน operation แบบ functional style อย่าง
mapความยาวของ array เป้าหมายก็คือ parallelism ที่มีอยู่ ทำให้ code generation เรียบง่าย
Memory model ที่เรียบง่ายกว่าและการเขียนโปรแกรมแบบขนาน
- บน CPU สมัยใหม่ cache coherency protocol เป็นหนึ่งในส่วนที่ทำให้เร็วและถูกต้องได้ยาก
- ความซับซ้อนส่วนใหญ่มาจากการรองรับภาษาที่คาดหวังว่าข้อมูลถูกแชร์และแก้ไขได้
- ใน abstract machine สไตล์ Erlang วัตถุทั้งหมดเป็น thread-local หรือ immutable
- Erlang มีโมเดลที่เรียบง่ายกว่า โดยมีวัตถุที่แก้ไขได้เพียงหนึ่งชิ้นต่อหนึ่งเธรด
- cache coherency protocol ของระบบแบบนี้สามารถแบ่งเป็นสองกรณีคือ mutable หรือ shared
- immutable object สามารถทำให้ cache เรียบง่ายขึ้นและทำให้หลาย operation ถูกลง
- Project Maxwell ของ Sun Labs สังเกตว่าชุดของ object ที่อยู่ใน cache กับ object ที่จะถูก allocate ใน young generation แทบจะเป็นชุดเดียวกัน
- หาก object ตายก่อนถูกขับออกจาก cache ก็ไม่ต้องเขียนกลับไปยังหน่วยความจำหลัก จึงประหยัดพลังงานได้
- การใช้ immutable object ใน heap และ stack ที่แก้ไขได้ทำให้ garbage collector กลายเป็น state machine เรียบง่ายที่ implement ในฮาร์ดแวร์ได้ง่าย
- โปรเซสเซอร์ที่ออกแบบเพื่อความเร็วล้วน ๆ มีแนวโน้มจะรองรับเธรดจำนวนมาก หน่วยเวกเตอร์กว้าง และ memory model ที่เรียบง่ายกว่า
- การรันโค้ด C บนระบบเช่นนี้อาจเป็นปัญหา
- เพราะมี legacy C code จำนวนมากทั่วโลก จึงยากที่จะประสบความสำเร็จเชิงพาณิชย์
- ความเชื่อทั่วไปว่าการเขียนโปรแกรมแบบขนานยากนั้น แม่นยำกว่าหากหมายถึงการเขียนโปรแกรมแบบขนานในภาษาที่มี abstract machine แบบ C
- Alan Kay เคยสอนภาษา actor-model ให้เด็ก ๆ และพวกเขาเขียนโปรแกรมที่ทำงานได้โดยมีมากกว่า 200 เธรด
- โปรแกรมเมอร์ Erlang มักเขียนโปรแกรมที่มีคอมโพเนนต์ขนานหลายพันตัว
- ในสถานการณ์ที่ CPU multicore และ GPU many-core ถูกใช้อย่างแพร่หลาย C ไม่ได้แมปเข้ากับฮาร์ดแวร์สมัยใหม่ได้ดี
1 ความคิดเห็น
ความคิดเห็นบน Hacker News
เหตุผลที่ C เป็นภาษาระดับต่ำ อย่างน้อยก็เพราะ การจัดการหน่วยความจำด้วยตนเอง
โดยเฉพาะบนฮาร์ดแวร์สมัยใหม่ การจัดการหน่วยความจำเป็นศูนย์กลางของการเขียนโปรแกรม การที่ Rust ชูเรื่องความปลอดภัยของหน่วยความจำโดยไม่ใช้ garbage collector ก็เพราะท้ายที่สุดแล้ว เหตุผลหลักที่ Rust มีอยู่ก็ใกล้เคียงกับเรื่องการจัดการหน่วยความจำนั่นเอง เหตุผลที่ C เร็วก็คือหน่วยความจำ และเหตุผลที่ C ไม่ปลอดภัยก็ส่วนใหญ่คือหน่วยความจำ เหตุผลสำคัญข้อหนึ่งที่ทำให้ parallel computing ยากก็คือการเข้าถึงหน่วยความจำพร้อมกัน แม้ functional programming มักถูกห่อหุ้มด้วยแนวคิดทางคณิตศาสตร์ แต่ส่วนใหญ่ก็อยู่ที่การทำให้ object ดูเหมือนไม่เปลี่ยนแปลง ขณะที่ภายใน compiler จัดการหน่วยความจำที่เปลี่ยนแปลงได้
ใน C หากใช้ allocator การเรียกเหล่านั้นล้วนต้องระบุอย่างชัดเจน
new/deleteและ raw pointer ใน C++ ยุคเก่าก็เรียก allocator อย่างชัดเจนด้วย แต่หลายอย่างก็เกิดขึ้นอัตโนมัติใน destructor ส่วน smart pointer ใน C++ สมัยใหม่โดยสาระแล้วคล้ายกับภาษาที่มี garbage collection เพราะทั้งการ allocate และการ free เกิดขึ้นอัตโนมัติเราไม่สามารถสั่งโปรเซสเซอร์ได้ว่าจะวางข้อมูลใดไว้ใน cache level ไหน หรือจะส่งอะไรไปยัง virtual memory แม้จะเป็นระดับต่ำกว่า Python แต่ก็ยากจะเรียกว่าเป็นการจัดการหน่วยความจำระดับต่ำเหมือน C ในยุค PDP-11
สำหรับระบบระดับไมโครคอนโทรลเลอร์หรือระบบที่ไม่มี MMU เรื่องจะต่างออกไป แต่นั่นก็เป็นอีกประเด็นหนึ่ง
แม้แต่ผมที่เป็นนักพัฒนา Rust ก็ยังทำงานอยู่ในภาพลวงตาที่มอง pointer เป็นวัตถุทางกายภาพจริง ๆ อย่างที่อยู่หน่วยความจำ Rust และ C++ ในระดับหนึ่งวาง abstraction ด้านการจัดการอย่าง reference และ borrowing ไว้ข้างหน้า แต่แนวคิดหลักยังคงอยู่เหมือนเดิม
ในความเป็นจริง kernel ของระบบปฏิบัติการวางชั้นขนาดใหญ่ไว้ระหว่างหน่วยความจำกายภาพกับโปรแกรม และ “address” กับ “pointer” ก็ใกล้เคียงกับ handle ที่ OS และ MMU นำไปประมวลผลสารพัดอย่าง
“raw pointer” เองก็ไม่ได้ดิบจริง ๆ มันเป็น handle ไปยัง offset ภายใน page และ page จริงอาจกระจัดกระจายอยู่ตามที่ต่าง ๆ ได้ หากออกจาก libc และโมเดลของ C อย่างสิ้นเชิง ไปสู่โลกของ reference ล้วน ๆ ที่โต้ตอบโดยตรงกับ page ของ VM subsystem หรือคล้าย “object handle” บางทีอาจเข้าใกล้พฤติกรรมของ subsystem ระดับล่างจริง ๆ มากกว่าด้วยซ้ำ
mallocและfreeเป็นฟังก์ชันของ libraryฮาร์ดแวร์ไม่มีการ allocate ระดับ byte ในลักษณะนั้น จึงไม่ใช่แค่ abstraction เหนือฮาร์ดแวร์เท่านั้น แต่ยัง abstract วิธีที่ระบบปฏิบัติการจัดสรรหน่วยความจำด้วย
ใน C ไม่สามารถเข้าถึง stack โดยตรงได้เช่นกัน stack frame ถูก abstract ไว้ และสิ่งที่ใช้ได้ก็ประมาณ
longjmpเท่านั้นหากคำนึงถึง undefined behavior และ strict aliasing rule ด้วย สิทธิ์ในการจิ้มหน่วยความจำตามใจก็ไม่ได้มีมากนัก
ในฐานะโปรแกรมเมอร์ C และผู้เขียนคอมไพเลอร์ สำหรับคนที่เข้าใจ C และใช้มันอย่างมืออาชีพ C เป็น ภาษา low-level อย่างชัดเจน
ถ้ากำลังมองหาภาษา low-level อยู่ C และญาติ ๆ ของมันคือทางเลือกที่ดีที่สุด
ถ้าคุณเพิ่งเริ่มเรียน C และอยากรู้วิธีใช้ให้เหมือนผู้เชี่ยวชาญ ก็ควรมองข้ามบทความนี้ไปจะดีกว่า มันอาจทำให้สับสนและลดความสามารถในการใช้ C อย่างมีประสิทธิภาพได้
มันให้การเข้าถึงระดับต่ำต่อเครื่องจักรแบบหนึ่งที่เครื่องจริงต้องพยายาม emulate อยู่พอสมควร อุปกรณ์ที่แกว่ง ๆ และการปะซ่อมที่ค่อย ๆ ถูกเติมเข้ามาตลอดหลายปีเพื่อให้เข้าถึงเครื่องจริงนั้น เป็นองค์ประกอบที่ค่อนข้างแปลกแยกภายใน C
อย่างไรก็ตาม ผมเห็นด้วยว่าชื่อบทความใช้วาทศิลป์แรงไปหน่อย การเป็นภาษา low-level ที่ผิดแบบไม่ได้ทำให้มันกลายเป็นภาษา high-level ไปได้ WASM เองถ้าอ้างว่าตรงกับฮาร์ดแวร์สมัยใหม่โดยตรงก็ถือว่า “ผิดแบบ” เช่นกัน แต่ก็ไม่ได้แปลว่าเป็น high-level
ข้อเท็จจริงที่ว่า C เป็นการจับคู่ที่ไม่ดีนั้นไม่ใช่เรื่องน่าหงุดหงิดในตัวมันเอง มันเป็นภาษาจากยุค 1970 ก็อาจเป็นเช่นนั้นได้ และทุกวันนี้ก็ยังมีประโยชน์ชัดเจนในหลายกรณี สิ่งที่น่าหงุดหงิดกว่านั้นคือ C ยังคงมีอิทธิพลอย่างมากต่อการออกแบบภาษา และแต่งแต้มมุมมองที่นักออกแบบภาษามีต่อฮาร์ดแวร์อย่างแรงกล้า ผลก็คือ การออกแบบภาษาสมัยใหม่มักจบลงแค่การสับชิ้นส่วนของ C มาผสมใหม่ แทนที่จะสร้างภาษาที่เข้ากับฮาร์ดแวร์ได้ดี
ถ้าคิดว่าโค้ดที่เขียนจะมีความสัมพันธ์แบบหนึ่งต่อหนึ่งกับ assembly ก็จะเกิดปัญหา ถ้าอยากดูให้ลึกขึ้นว่าสิ่งเหล่านี้กลับมาสะดุดเราอย่างไร ดูได้ที่ https://youtu.be/w3_e9vZj7D8
ประเด็นหลักของผู้เขียนคงไม่ใช่ “C ไม่ใช่ภาษาที่ดีสำหรับ system programming” เพราะคงยากที่จะเขียนสิ่งอย่าง
volatile int *dma_register = SCATTER_GATHER_BASE;ใน Haskell ให้เทียบเท่ากันได้ประเด็นของผู้เขียนอยู่ที่ว่า แรงผลักดันที่จะทำให้ C และภาษาอื่น ๆ ที่ “จำลองเครื่อง von Neumann” ทำงานได้เร็ว ทำให้คอมไพเลอร์ซับซ้อนมาก และผู้เขียนก็สื่อเป็นนัยว่า “ถ้าเป็น low-level ก็ควรต้องใช้คอมไพเลอร์ที่เรียบง่าย” โปรเซสเซอร์ที่ถูกสร้างมาเพื่อรันโค้ดแบบนั้นให้เร็วก็ซับซ้อนมากเช่นกัน และความซับซ้อนนั้นมีต้นทุน
ในหลายแง่ นี่คือบทความที่เรียกร้องให้ เปลี่ยน programming model และยก GPU เป็นตัวอย่างของความเป็นไปได้เมื่อ “programming model ใหม่” กับ “ซิลิคอนที่รองรับมัน” ถูกสร้างขึ้นมาควบคู่กัน
ความหมายดั้งเดิมใกล้กับที่บทความใช้ ภาษา low-level ไม่พกพาและผูกกับฮาร์ดแวร์ที่มันรันอยู่ ส่วนภาษา high-level สามารถ targeting ได้หลายแพลตฟอร์ม ตามนิยามนี้ C เป็นภาษา high-level อย่างชัดเจน
ผมไม่ได้ไม่พอใจที่ผู้เขียนเล่นคำเท่าไรนัก แต่ไม่พอใจที่การยึดติดกับคำศัพท์เก่าทำให้ความเข้าใจมัวลงเสียมากกว่า การจัดประเภทเป็น “generation” มักอธิบายได้ดีกว่า
รุ่นที่ 1 คือ machine code, รุ่นที่ 2 คือ assembly, รุ่นที่ 3 คือภาษาทั่วไป, รุ่นที่ 4 คือภาษาเฉพาะโดเมนแอปพลิเคชัน
เส้นแบ่งระหว่างรุ่นที่ 3 กับรุ่นที่ 4 บางครั้งก็พร่าเลือน และในยุค 80–90 ก็เคยมีการพูดถึงรุ่นที่ 5 ซึ่งท้ายที่สุดไม่เคยตั้งหลักได้จริง ถึงอย่างนั้น SQL, HyperCard, Mathematica ก็น่าจะเป็นตัวอย่าง ภาษารุ่นที่ 4 ที่ค่อนข้างชัดเจน
เหตุผลที่แนวทางนี้ดีคือมันแบ่งภาษาตามความแตกต่างที่ค่อนข้างชัดเจนว่าใช้ภาษาเมื่อใด จากนั้น “high-level/low-level” ก็ใช้เป็นคำเชิงเปรียบเทียบได้ ภาษาที่ยิ่ง high-level ก็มัก abstract รายละเอียดของสิ่งที่คอมพิวเตอร์ทำจริงมากขึ้น วิธีนี้ยังคงรักษาข้อเท็จจริงที่ว่าภาษารุ่นสูงกว่ามัก high-level กว่าโดยทั่วไป และสิ่งที่เสียไปก็มีแค่การถกเถียงโง่ ๆ เรื่องเส้นแบ่งที่กำหนดขึ้นอย่างตามใจและพูดตรง ๆ คือไร้ประโยชน์
ถ้าใช้วิธีนี้ .NET IL, WebAssembly, Java bytecode ก็อาจมองได้ว่าเป็นภาษารุ่นที่ 2 ที่ high-level มาก ซึ่งก็น่าสนใจดี และ Forth เป็นภาษารุ่นที่ 3 ถ้า Chuck อยากเถียงก็เชิญเลย
มันไม่ใช่เรื่องว่าจะใช้ค้อนอย่างไร แต่ใกล้เคียงกับการตั้งคำถามว่าวิธีที่เราใช้ค้อนกับทุกอย่าง หรือก็คือ การออกแบบของ C กำลังจำกัดเราอยู่หรือไม่
ผมไม่เห็นด้วยกับข้ออ้างของผู้เขียนที่ว่าชุดคำสั่ง CPU ควรเปิดเผยการ implement ของ CPU ให้มากขึ้น
เคยมีการลองทำมาแล้วในอดีต และในระยะยาวก็ล้มเหลว ตัวอย่างคือโปรเซสเซอร์ RISC บางตัวที่ออกแบบช่วงปลายยุค 80 ถึงต้นยุค 90 เช่น branch delay slot ของ MIPS และ SuperH ถ้าอธิบายให้คนที่ไม่รู้แนวคิดนี้ฟัง ก็คือคำสั่งถัดจากคำสั่ง branch จะถูก execute ไม่ว่าจะกระโดดตาม branch หรือไม่ก็ตาม
ในระยะสั้น มันทำให้โปรเซสเซอร์เรียบง่ายและถูกลงได้ โดยโยนงานหลีกเลี่ยง pipeline stall หลัง branch ให้โปรแกรมเมอร์ แต่เมื่อเวลาผ่านไป การออกแบบโปรเซสเซอร์และ pipeline ก็ซับซ้อนขึ้น และคำสั่งเดียวไม่พอจะกลบ latency ของ branch อีกต่อไป สุดท้ายมันกลายเป็นมรดกที่โปรเซสเซอร์ในอนาคตต้องจัดการเพราะความเข้ากันได้ และทำให้ branch prediction กับตรรกะของ pipeline ซับซ้อนขึ้น
การเปิดเผยรายละเอียดที่ผิดย่อมแย่อยู่แล้ว เพียงแต่กำลังบอกว่าโมเดล C มีข้อจำกัดสำคัญในโลกของ CPU สมัยใหม่
ผมเคยฟังการนำเสนอที่นักพัฒนาใช้ subsystem แบบนั้นของโปรเซสเซอร์ตัวหนึ่ง เขาบอกว่าถ้าไม่ใช้ จะใช้ 95% ของ time window ไปกับการคัดลอกข้อมูลเพียงอย่างเดียว แต่ถ้าใช้ engine นั้นร้องขอข้อมูลล่วงหน้า จะใช้เพียง 10% ของ time window ในการรับข้อมูล และทำงานที่ต้องการเสร็จภายในประมาณ 50% ของ time window ทั้งหมด จึงเหลือเวลาอีกมากสำหรับฟีเจอร์และการปรับปรุงเพิ่มเติม
ถ้า x86 มีความสามารถแบบนั้น ตอนเรียนปริญญาเอกผมคงใช้มันเพื่อร้องขอข้อมูลเมทริกซ์ที่จะเข้าถึงล่วงหน้า รูปแบบที่ผมใช้ไม่ใช่เชิงเส้น แต่กำหนดชัดเจน ตอนนี้ถ้าจะเร่งโค้ดนั้นให้เร็วขึ้นอีก ก็ต้องจัดเรียงเมทริกซ์ใหม่ให้ prefetcher ชอบ และ refactor codebase ทั้งหมดจากบนลงล่าง
ถ้าต้องการก็ออกแบบให้แย่ได้อยู่แล้ว แต่ผมสงสัยว่ามีกรณีในประวัติศาสตร์มากพอจะสรุปทั่วไปได้แค่ไหน
ผมมองว่าจากระดับต่ำไปถึงระดับสูงไม่ใช่การแบ่งแบบสองขั้ว แต่เป็น สเปกตรัม
C น่าจะจัดอยู่ในหนึ่งในสามส่วนล่างของบรรดาภาษาทั้งหมด และเปิดให้สัมผัสกับ primitive ของเครื่องจำนวนมาก เช่น การจัดการหน่วยความจำและเธรด ถึงจะไม่ต่ำเท่าแอสเซมบลี แต่ก็ต่ำกว่า Java หรือ Go และแน่นอนว่าอยู่ห่างจากฝั่ง Python หรือ JavaScript มาก
อีกทั้ง C ค่อนข้างไม่เหมาะกับแพลตฟอร์มที่ใช้ segmented memory หรือที่อยู่แบบไม่แบน สิ่งเหล่านั้นเริ่มมีสัญญาณว่าจะกลับมาเป็นที่นิยมอีกครั้ง แต่การแพร่หลายอย่างกว้างขวางของ C เป็นอุปสรรคใหญ่จริง ๆ
ดังนั้นโมเดลในหัวผมจึงเป็นมาตลอดว่า “C คือ ระดับต่ำสุด ที่ยังลงไปได้ ก่อนจะถึงขั้นสั่งงานโปรเซสเซอร์โดยตรง”
“C ไม่ได้ทำงานเหมือนภาษา ‘ระดับสูง’ ทั่วไป เพราะมันให้ฟีเจอร์หลายอย่างที่มักถูกเชื่อมโยงกับภาษา ‘ระดับต่ำ’ อย่างภาษาแอสเซมบลีมากกว่า ซึ่งรวมถึงความสามารถในการเขียนและอ่านข้อมูลจากที่อยู่หน่วยความจำเฉพาะ การดำเนินการกับเนื้อหาของตำแหน่งหน่วยความจำ และคำสั่งสำหรับเพิ่มหรือลดค่าตัวแปรจำนวนเต็ม … ดังนั้น C จึงให้ความยืดหยุ่นและประสิทธิภาพแก่โปรแกรมเมอร์ในการทำงานระดับต่ำ ขณะเดียวกันก็ให้ข้อดีของงานระดับสูง เช่น โครงสร้างข้อมูลที่ก้าวหน้ากว่าและการควบคุมการไหลของโปรแกรม ซึ่งเป็นลักษณะทั่วไปของภาษาคอมพิวเตอร์ในปัจจุบัน ด้วยเหตุนี้ C จึงถูกอธิบายเป็นครั้งคราวว่าเป็น ‘ภาษาระดับต่ำระดับสูง’ หรือ ‘ภาษาระดับสูงระดับต่ำ’” - https://archive.org/details/computerprogramm0000ford/page/13...
ประโยคท้ายบทความที่ว่า “ในการพัฒนาซอฟต์แวร์มีความเชื่อผิด ๆ ที่พบบ่อยว่า programming แบบขนานเป็นเรื่องยาก” ชวนให้เข้าใจผิด
ผู้เขียนยกสถานการณ์เฉพาะที่ไม่ยากขึ้นมาก็จริง แต่ถ้าเป็นคำถามที่ใช้ได้ทั่วไปแล้ว programming แบบขนานนั้นยาก และไม่ใช่ความเชื่อผิด ๆ ที่พบบ่อย
programming แบบขนานยากไหม? ถ้าถามโดยไม่มีเงื่อนไขที่ละเอียดกว่านี้ คำตอบคือใช่ การนึกภาพว่าคำสั่งในโค้ดทำงานพร้อมกันนั้นยากกว่าการนึกภาพว่ามันทำงานทีละคำสั่งตามลำดับมาก
(map inc [0 1 2 3])ความยากในการนึกภาพว่าฟังก์ชันincทำงานกับแต่ละองค์ประกอบแบบตามลำดับ เทียบกับทำงานแบบขนานกัน มันต่างกันจริงหรือ?ผมมองว่าความยากของ programming แบบขนานไม่ได้เป็นเรื่องโดยกำเนิด แต่ใกล้เคียงกับสองเรื่องมากกว่า
หนึ่ง ภาษามักตั้งค่าเริ่มต้นเป็นการทำงานตามลำดับ ดังนั้นถ้าจะทำแบบ async ก็ต้องเพิ่ม primitive ให้โปรแกรมเมอร์ใช้
สอง ต้องรู้ว่าเมื่อใดจึงจะใช้ programming แบบขนานได้อย่างมีประสิทธิภาพ
ถ้ามีรายการหรือสตรีมขององค์ประกอบอิสระที่ต้องการเพียงการคำนวณที่เป็นอิสระ programming แบบขนานก็เป็นเรื่องเข้าใจได้โดยสัญชาตญาณ
จุดที่คนมักติดคือการยัด async เข้าไปในที่ที่ไม่จำเป็น นั่นคือจุดที่ประสิทธิภาพเท่ากับหรือแย่กว่าการทำงานตามลำดับ หรือใส่ async เข้าไปทั้งที่จริง ๆ แล้วการคำนวณพึ่งพากันจนทำให้พฤติกรรมพัง
เวลาพูดว่า “ถ้าไม่มีรายละเอียดเพิ่มเติมหรือความเฉพาะเจาะจง” จริง ๆ แล้วกำลังใช้ กรอบคิดแบบโลก C/ตระกูล C เป็นเฟรมเวิร์กพื้นฐาน
ประเด็นของผู้เขียนคือ sequential programming เป็นเพียง programming แบบหนึ่งที่เรียบง่าย ไม่ใช่แบบเดียว และไม่เข้ากับฮาร์ดแวร์สมัยใหม่ได้ง่าย
การที่ Erlang มีอยู่และผู้คนใช้มันได้สำเร็จ ไม่ได้หมายความว่าสิ่งที่ยากกว่านั้นไม่ยาก
การใช้อินฟราสตรักเจอร์สำหรับ concurrency programming เช่น process หรือ thread เพื่อทำอัลกอริทึมแบบขนานก็ยากเช่นกัน แต่ parallel programming หรือการทำให้องค์ประกอบประมวลผลจำนวนมากทำงานเดียวกันร่วมกันนั้น ถ้ามี abstraction ที่เหมาะสมจะง่ายกว่ามาก
อย่างไรก็ตาม มีข้อยกเว้นในบางกรณีใช้งาน เช่น การคูณเมทริกซ์
บทความนี้ถูกตรงที่คอมพิวเตอร์ไม่ใช่ PDP-11 ที่เร็วขึ้น แต่ผิดตรงที่บอกว่านั่นเกี่ยวข้องกับ C
ตัวอย่างเช่นมีประโยคว่า “อีกแกนหลักหนึ่งของโมเดลหน่วยความจำของเครื่องนามธรรม C คือหน่วยความจำแบบแบน ซึ่งไม่เป็นจริงมานานกว่า 20 ปีแล้ว”
เรื่องนี้ไม่เกี่ยวกับ C ฮาร์ดแวร์เป็นตัวบังคับ abstraction นี้ และนั่นเป็นเรื่องดี ไม่อย่างนั้นโปรแกรมคงหยุดทำงานเมื่อย้ายไปยังเครื่องที่มีแคชต่างกัน
ตัวอย่างเช่น โครงสร้างหน่วยความจำแบบลำดับชั้นที่แกล้งทำเหมือนเป็น RAM แบบแบน, CPU ที่ใหญ่กว่าที่ชุดคำสั่งบอกเป็นนัยมาก พร้อมการทำงานแบบ out-of-order และ speculative execution, และคอมไพเลอร์ optimizing ที่แยกโปรแกรมที่เขียนออกจากการรันจริงให้มากขึ้น
IBM ทำสิ่งเหล่านี้มาตั้งแต่ทศวรรษ 1970 นานก่อนที่ C จะรุ่งขึ้นมา การวิจารณ์โมเดลนี้และมองหาทางเลือกอื่นเป็นเรื่องสมเหตุสมผล แต่การโทษ C นั้นไม่ยุติธรรม
บทความนี้เป็นบทความเมื่อ 5 ปีก่อนแล้ว และสมมติฐานที่ว่าในเชิงโครงสร้าง คอมพิวเตอร์ไม่ได้คล้ายกับ PDP-11 มากนักนั้นกลับยิ่งถูกต้องขึ้น แต่ข้อสรุปที่ว่า “ลองจินตนาการถึงโปรเซสเซอร์ที่ไม่ใช่ C” ดูจะมีน้ำหนักน้อยลง
เรากำลังเห็นการแบ่งแยกอย่างชัดเจนระหว่างโค้ดแบบเชิงเส้นกับโค้ดที่ขนานกันอย่างสูง และในปี 2018 ก็เป็นเช่นนั้นอยู่แล้ว ตัวอย่างที่ชัดที่สุดคือการผงาดขึ้นของ Python ในงานแมชชีนเลิร์นนิงและการคำนวณทางวิทยาศาสตร์ เมื่อประสิทธิภาพไม่ใช่สิ่งสำคัญที่สุด การเขียนด้วยสไตล์เธรดเดียวและโมเดลหน่วยความจำแบบแบนก็ยังสะดวกมาก
เมื่อประสิทธิภาพเริ่มสำคัญ การย้ายไปใช้ภาษาที่เหมาะกับการเขียนโปรแกรมแบบขนานมากกว่าก็สมเหตุสมผล เช่น ภาษา computational graph ของสิ่งอย่าง Pytorch, ชุด primitive อื่น ๆ บน CUDA หรือภาษาที่ทดลองมากกว่าอย่าง Futhark โค้ดส่วนแกนหลักด้านประสิทธิภาพมักมีภาษาเฉพาะโดเมนมาโดยตลอด และดูเหมือนว่าภาษาเหล่านี้จะพบเห็นบ่อยขึ้น ไม่ใช่น้อยลง ฮาร์ดแวร์ก็ถูกสร้างให้สอดคล้องกับสิ่งนี้เช่นกัน ตัวอย่างเช่น การผสม CPU+GPU ที่พบได้ทั่วไปในเดสก์ท็อป PC, ส่วนขยายเวกเตอร์ของ x86 ที่มี primitive ซึ่งแทบจะกลายเป็น DSL ของตัวเอง และสิ่งอย่าง M1 ที่นำ GPU มาติดกับ CPU เพื่อให้ทั้งคู่เข้าถึงหน่วยความจำระบบเดียวกันได้อย่างรวดเร็ว
กล่าวอีกอย่าง สิ่งที่ล้าสมัยจริง ๆ อาจไม่ใช่ C แต่เป็นแนวคิดเรื่อง ภาษาสำหรับใช้งานทั่วไป ที่เหมาะกับงานทุกประเภทได้ดีเท่า ๆ กัน
ถ้าความซับซ้อนของ CPU สมัยใหม่ทำให้ C ไม่ใช่ภาษา “ระดับต่ำ” อีกต่อไป เหตุผลเดียวกันก็ใช้กับภาษาแอสเซมบลีได้เช่นกัน
เพราะสิ่งอย่างการประมวลผลแบบไม่เรียงลำดับและการเปลี่ยนชื่อรีจิสเตอร์ก็ใช้กับแอสเซมบลีด้วย
ความซับซ้อนที่เพิ่มขึ้นของคอมไพเลอร์ในช่วงหลายทศวรรษที่ผ่านมาก็ช่วยหนุนข้อโต้แย้งนี้ แอสเซมบลีที่คอมไพเลอร์ C สร้างขึ้น หรือแม้แต่ object code ก็อาจออกมาไม่ตรงกับที่คาด เพราะมีการดึงออกนอกลูป การกำจัดนิพจน์ย่อยร่วม ฯลฯ
ถึงอย่างนั้น แนวคิดที่เรียก C ว่าเป็นภาษา “ระดับต่ำ” ก็ยังเป็นป้ายกำกับที่มีประโยชน์อยู่ ถ้าไม่เช่นนั้นก็ควรเลิกใช้คำเรียกนี้ไปเลย
จริงอยู่ที่มันเป็น abstraction บนคอมพิวเตอร์จริง แต่ก็น้อยกว่าสิ่งที่ C สร้างทับบนโมเดลคอมพิวเตอร์เสมือนอยู่มาก แอสเซมบลีในปัจจุบันอยู่ในระดับใกล้เคียงกับ C ในยุคที่ C ถูกสร้างขึ้น ส่วน C ในปัจจุบันนั้นอยู่ระดับสูงเกินไป จนไม่ได้ให้ความสามารถที่ภาษาใหม่กว่าและดีกว่าให้ไม่ได้
อย่างไรก็ดี เห็นด้วยว่าปัจจุบันชื่อ “ระดับต่ำ” กับ “ระดับสูง” ไม่ค่อยมีประโยชน์นัก
บทความดูเหมือนจะเดินเหตุผลอยู่สองสายที่ประสานกันได้ยาก
สายแรกคือข้ออ้างว่า C ไม่ใช่ภาษาระดับต่ำ โดยยกตัวอย่างเรื่อง padding ของ struct และ signed integer overflow ที่เป็นพฤติกรรมที่ไม่ได้กำหนดไว้ ส่วนนี้เข้าใจได้ และดูสร้างสรรค์ในแง่ที่เสนอฟีเจอร์ของภาษาสำหรับภาษา “ระดับต่ำจริง ๆ” ในจินตนาการ
สายที่สองคือข้ออ้างว่าเพราะอิทธิพลครอบงำของ C ทำให้นักออกแบบ CPU ต้องฝืนสร้างอะไรบางอย่างที่รัน C ได้อย่างเป็นธรรมชาติ ตัวอย่างในส่วนนี้มีการเปลี่ยนชื่อรีจิสเตอร์ หน่วยความจำแบบแบน และแคช ข้อนี้ก็เข้าใจได้เช่นกัน แต่ไม่ค่อยแน่ใจว่ามันเชื่อมกับข้ออ้างแรกและชื่อบทความอย่างไร ถ้าตีความตามตัวอักษร ก็ดูเหมือนจะหมายความว่าบนฮาร์ดแวร์สมัยใหม่ การสร้างภาษาระดับต่ำเป็นไปไม่ได้เลย และแม้แต่ภาษาเครื่องก็เป็น “ระดับสูง” ถ้าเช่นนั้นข้อสรุปคือ ต้องสร้างฮาร์ดแวร์รุ่นใหม่ที่เปิดเผยความซับซ้อนต่อสถาปัตยกรรมชุดคำสั่งมากขึ้นก่อน แล้วจึงค่อยออกแบบภาษาระดับต่ำที่ใช้ประโยชน์จากสิ่งนั้นได้
ทั้งสองข้ออ้างมีคุณค่า แต่การนำมารวมไว้ในบทความเดียวแล้วตั้งชื่อว่า “C ไม่ใช่ภาษาระดับต่ำ” นั้นดูไม่ค่อยมั่นคงนัก ข้ออ้างแรกเข้ากับชื่อนี้ ส่วนข้ออ้างที่สองน่าจะเหมาะกับบทความต่อยอดชื่อ “ภาษาเครื่องก็ไม่ใช่ภาษาระดับต่ำ” มากกว่า
แต่ได้ยินว่ามันใช้เวลาคอมไพล์นาน และสุดท้ายคอมไพเลอร์ก็ไม่สามารถไปถึงระดับการปรับแต่งที่คาดหวังได้ การที่มันเข้ากันไม่ได้กับ x86 ก็ไม่ได้ช่วยให้มีการนำไปใช้มากขึ้น
นึกถึง VLIW ขึ้นมา ตามบทความ Itanium ใน Wikipedia ระบุไว้ดังนี้
“หนึ่ง word คำสั่งของ VLIW สามารถบรรจุคำสั่งอิสระหลายคำสั่งที่รันแบบขนานได้โดยไม่ต้องประเมินความเป็นอิสระของกันและกัน คอมไพเลอร์ต้องพยายามหาชุดคำสั่งที่ถูกต้องซึ่งสามารถรันพร้อมกันได้ และโดยพฤตินัยแล้วทำงาน scheduling คำสั่งแบบเดียวกับที่โปรเซสเซอร์ superscalar แบบเดิมทำด้วยฮาร์ดแวร์ใน runtime”
ถ้า CPU เปิดเผย single-flow parallelism ออกมาใน interface ก็อาจจัดการได้ตั้งแต่เวลา compile หรือกำหนดเองโดยตรงด้วย inline assembly
สงสัยว่าสาเหตุที่มันไม่แพร่หลายเป็นเพราะพลวัตทางธุรกิจของอุตสาหกรรม หรือมีเหตุผลทางเทคนิคจริง ๆ ว่ากลยุทธ์นี้ไม่ดี
อย่างแรก คอมไพเลอร์ทำ instruction scheduling แบบนั้นได้ไม่ดี และพอภายหลังทำได้ดีขึ้น Itanium ก็จมไปแล้ว อย่างที่สอง ชุดคำสั่งเดิมอย่าง x86 กลายเป็นว่าสามารถจัดการเรื่องนี้ด้วยฮาร์ดแวร์ตอน runtime ได้ค่อนข้างดี และในความเป็นจริงให้ผลลัพธ์ดีกว่า static scheduling เล็กน้อย เพราะ runtime มีข้อมูล profiling
เห็นว่า Linus เคย rant ดี ๆ ที่เกี่ยวกับหัวข้อนี้อยู่บ้างไว้ที่ [0] “ในขณะที่คนฝั่ง RISC พยายามปรับคอมไพเลอร์ให้สร้าง loop ที่ใช้ register ทั้ง 32 ตัวได้อย่างมีประสิทธิภาพ ฝั่งผู้ทำ x86 กลับทำให้ชิปทำงานได้เร็วภายใต้โหลดหลากหลายแบบแทน และใช้ฮาร์ดแวร์ register renaming ขนาดมหึมา เรากำลังเห็น memory renaming ด้วย”
[0] https://yarchive.net/comp/linux/x86.html
VLIW ทำงานได้ดีจริง ๆ ใน niche บางแบบ แม้จะเขียนโปรแกรมยากกว่าคำสั่งเดี่ยวที่รันตามลำดับ ไม่ว่าจะเขียนมือหรือให้คอมไพเลอร์ทำ แต่ทำให้ scheduling ในฮาร์ดแวร์เรียบง่ายลง มันทำงานได้ดีกว่าเมื่อคำสั่งที่ถูก bundle มี latency ใกล้เคียงกัน
ปริศนาหลักของการออกแบบในปัจจุบันอยู่ที่การเข้าถึงหน่วยความจำกิน cycle มากกว่าการคำนวณทางคณิตศาสตร์มาก การ bundle การคำนวณไม่กี่ cycle เข้ากับ memory load หลายร้อย cycle จึงไม่ค่อยมีความหมาย ดังนั้น VLIW จึงทำงานได้ดีเมื่อรู้ว่าการเข้าถึงหน่วยความจำเร็ว คือโดยคร่าว ๆ รู้ว่ามันจะอยู่ใน L1 cache หรือที่เทียบเท่า ผมคิดว่านั่นเป็นหนึ่งในเหตุผลที่มันเหมาะกับระบบสไตล์ DSP
exposed pipeline ก็เป็นคุณสมบัติที่น่าสนใจของระบบบางส่วนเช่นกัน หากคำสั่งหนึ่งใน bundle ของ VLIW เขียนลง register คำสั่งถัด ๆ ไปที่อ่าน register เดียวกันจะยังเห็นค่าเดิมไปอีก N cycle แล้วหลังจากนั้นจึงจะเห็นค่าที่เขียนใหม่ การเขียนมือชวนสับสนมากจริง ๆ แต่คอมไพเลอร์สามารถจัดการ scheduling แบบนั้นได้
จนกระทั่งเมื่อไม่นานมานี้ DSP และ HPC เป็นเพียงส่วนเล็กมากของตลาด ดังนั้นสถาปัตยกรรมที่ทำ dynamic scheduling ได้จึงได้รับการลงทุนมากกว่าและครองตลาดเหล่านั้นไปด้วย
แน่นอนว่าใน GPU สถานการณ์ต่างออกไป และจริง ๆ แล้ว GPU พึ่งพา static scheduling มากกว่า แต่เมื่อ GPU ขยายไปยังโหลดที่หลากหลายขึ้น ก็เริ่มมีองค์ประกอบแบบ dynamic มากขึ้นเรื่อย ๆ
https://news.ycombinator.com/context?id=37900987
Itanium เป็นความพยายามหลักที่จะนำสิ่งนี้ออกมาเป็น CPU ตอนนี้ AMD64 กับ ARM ครองตลาดอยู่ แต่ในอนาคตก็อาจได้เห็นมันอีกครั้ง