2 คะแนน โดย GN⁺ 2024-08-05 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • คุณสมบัติ constant-time ที่สำคัญในโค้ดเข้ารหัสอาจถูกทำลายได้เพียงจากการปรับแต่งของคอมไพเลอร์ จึงมีการทดลองใส่แพตช์เตือนภายใน LLVM เพื่อค้นหารูปแบบที่เสี่ยง
  • “การ optimize” ของคอมไพเลอร์อาจทำให้บางส่วนของ benchmark เร็วขึ้นได้ แต่ในเส้นทางหลักจริง ๆ มักพึ่งพา intrinsics และ assembly และต้นทุนของบั๊กที่เกิดจากการ optimize ก็สะสมแยกต่างหาก
  • ในเดือนมิถุนายน 2024 Antoon Purnal ยืนยันว่าโค้ดอ้างอิงของ Kyber เมื่อใช้ Clang 15 ขึ้นไปกับตัวเลือก optimize บางอย่าง อาจถูกแปลงเป็น การ branch ตามค่าลับ และเปิดทางให้โจมตีผ่าน timing ได้
  • TIMECOP 2 ตรวจผลลัพธ์ที่คอมไพล์ซึ่งประกาศว่าเป็น constant-time ภายใน SUPERCOP แต่มีข้อจำกัดทั้งจากคำสั่งที่ Valgrind รองรับและ data flow ที่ปรากฏในการรันทดสอบจริง
  • แนวทางรับมือในงานจริงคือใช้ฟังก์ชันอย่าง crypto_{int,uint}{8,16,32,64}.h เพื่อไม่ให้คอมไพเลอร์มองผลลัพธ์ 1 บิตเป็น bool หรือย้ายไปใช้ assembly ที่ผ่านการตรวจสอบ ภาษาเน้นความปลอดภัย หรือคอมไพเลอร์เฉพาะทาง

ช่องว่างความรับผิดชอบที่เกิดจาก “การ optimize” ของคอมไพเลอร์

  • ในประวัติการเปลี่ยนแปลงของ LLVM และ GCC รุ่นใหม่ ๆ มีคำว่า “optimization”, การทดสอบ “optimization”, การแก้ไขการทดสอบ และการแก้บั๊ก “optimization” ปรากฏอยู่ต่อเนื่อง
  • แม้โค้ดที่เคยทำงานได้ดีก่อนคอมไพล์จะเปลี่ยนพฤติกรรมหลังคอมไพเลอร์เปลี่ยนแปลง ในหลายกรณีความรับผิดชอบกลับถูกโยนไปให้โปรแกรมเมอร์ที่ไปเหยียบ “undefined behavior”
  • “language standards” เหล่านี้ถูกสร้างโดยผู้เขียนคอมไพเลอร์ ส่งผลให้โครงสร้างความรับผิดชอบกลายเป็นว่าโค้ดของโปรแกรมเมอร์หลายล้านคนต้องแบกรับภาระมากกว่าการเปลี่ยนแปลงของกลุ่มผู้เขียนคอมไพเลอร์ขนาดเล็ก
  • ในตัวอย่างโค้ดเข้ารหัส บน benchmark ของ CPU หลายรุ่น implementation avx2 ของ kyber768 เร็วกว่าโค้ดพกพาที่คอมไพล์ด้วยคอมไพเลอร์แบบ “optimize” ประมาณ 4 เท่า

ข้อจำกัดของการวัดประสิทธิภาพการ optimize

  • ในปี 2000 Todd A. Proebsting เขียนไว้ใน Proebsting's Law ว่า “ความก้าวหน้าของคอมไพเลอร์ทำให้พลังประมวลผลเพิ่มเป็นสองเท่าทุก 18 ปี” และสรุปว่าบทบาทของ compiler optimization เป็นเรื่องรอบข้าง
  • ในปี 2022 Arseny Kapoulkine สรุปจาก benchmark ว่า LLVM 11 ใช้เวลา compile แบบ optimize นานกว่า LLVM 2.7 2 เท่า และโค้ดที่รันได้โดยทั่วไปเร็วขึ้น 10~20%
  • ทั้งสองข้อถกเถียงต่างพลาดการวัดประสิทธิภาพที่ผู้ใช้จริงสัมผัสได้
    • hotspot ที่ประสิทธิภาพกระจุกตัวอยู่มักเต็มไปด้วย intrinsics และ assembly
    • FFmpeg มี assembly 160,000 บรรทัด เมื่อนับจากไฟล์ .asm และ .S
    • เมื่อคอมพิวเตอร์และเครือข่ายประมวลผลข้อมูลมากขึ้น เวลา CPU จริงก็ยิ่งไปอยู่กับ hotspot เหล่านี้มากขึ้น
  • ต้นทุนด้านความปลอดภัยก็ขยายขึ้นแยกต่างหากในการถกเถียงเรื่อง optimization
    • Deloitte รายงานว่าในปี 2023 งบประมาณด้านความปลอดภัย IT อยู่ที่ 0.5% ของรายได้บริษัท
    • เมื่อนำไปเทียบกับตัวเลขที่ว่ารายได้รวมของบริษัททั่วโลกในปี 2022 มากกว่า 48 ล้านล้านดอลลาร์ ขนาดรวมอาจอยู่ในระดับหลายแสนล้านดอลลาร์
    • อย่างไรก็ตาม มีข้อสังเกตว่า 0.5% ของ Deloitte อาจเป็นค่าเฉลี่ยอย่างง่ายรายบริษัท และไม่ใช่ทุกบริษัทที่ตอบแบบสำรวจ

Timing leakage และกรณีของ Kyber

  • ปัญหาความปลอดภัยที่คอมไพเลอร์แบบ “optimize” สร้างขึ้นไม่ได้มีแค่บั๊กแบบดั้งเดิม แต่รวมถึง timing leakage ที่ข้อมูลลับรั่วผ่านเวลาในการทำงานด้วย
  • บทความ EuroS&P 2018 ของ Laurent Simon, David Chisnall และ Ross Anderson เตือนว่าการอัปเกรดคอมไพเลอร์อาจเปิด timing channel ในโค้ดที่เคยปลอดภัยมาก่อนโดยไม่แจ้งล่วงหน้า
  • ตัวอย่างที่เน้นในบทความปี 2018 คือโค้ดที่ใช้ bool เลือกระหว่างสองค่า และ bool ทำให้คอมไพเลอร์สร้าง conditional jump
    • ใน implementation ด้านการเข้ารหัส มีแนวปฏิบัติในการหลีกเลี่ยงสิ่งนี้ด้วยการเอา bool ออกจากโค้ดสำคัญ และสร้างฟังก์ชันเปรียบเทียบแบบ constant-time แยกต่างหาก
    • มีการอ้างว่า OpenSSL ประกาศ 37 ฟังก์ชัน เพื่อการนี้
  • กรณี curve25519-donna กับ MSVC 2015 ในปี 2015 ถูกสรุปในบทความว่าเป็นความเข้าใจผิด
    • จริง ๆ แล้วเมื่อคอมไพล์สำหรับ x86 แบบ 32 บิต การทำงาน int64 ถูกแปลงเป็นการเรียกไลบรารี int64 แบบ 32 บิตของ Microsoft ชื่อ llmul.asm
    • timing leakage เกิดจาก branch ที่ขึ้นกับข้อมูลใน llmul.asm และบทความมองว่าไลบรารีนี้ก็ควรถูกนับรวมอยู่ในแนวคิดซอร์สโค้ดที่สมเหตุสมผลด้วย
  • ในเดือนมิถุนายน 2024 Antoon Purnal ยืนยันว่าโค้ดอ้างอิงของ Kyber เมื่อใช้ Clang 15 ขึ้นไปกับตัวเลือก optimize บางอย่าง อาจเปิดให้โจมตีผ่าน timing ได้
    • รูปแบบปัญหาคือ (-((x>>j)&1))&y ซึ่งเป็นการคำนวณที่ให้ y หากบิตที่ j ของ x ถูกตั้งค่าไว้ และให้ 0 หากไม่ใช่
    • Clang ใช้คำสั่ง bit test เพื่อแปลงบิตนั้นเป็น bool แล้วสร้าง conditional branch บนพื้นฐานของ bool นั้น
    • ภายใน LLVM ฟังก์ชัน combineShiftAnd1ToBitTest ใน lib/CodeGen/SelectionDAG/DAGCombiner.cpp จัดการ “optimization” นี้
    • ฟังก์ชันนี้ถูก เพิ่ม โดย Sanjay Patel ในเดือนกันยายน 2019 และหลังจากนั้นมีหลายคนแก้ไข
  • GCC ก็มีกรณีล้ำเส้นคล้ายกัน
    • แพตช์ GCC ของ ARM ในเดือนพฤศจิกายน 2021 แปลง (-x)>>31 เป็น -(x>0)
    • ในเดือนเมษายน 2024 มีการเตือนเกี่ยวกับเรื่องนี้

TIMECOP และการตรวจ constant-time

  • TIMECOP 2 ฝังอยู่ในเฟรมเวิร์กทดสอบการเข้ารหัส SUPERCOP และตรวจอัตโนมัติหา conditional branch ที่สืบจากค่าลับ ในโค้ดที่คอมไพล์แล้วและประกาศว่าเป็น constant-time
  • เป้าหมายการตรวจไม่ได้มีแค่ conditional branch แต่รวมถึง array index ที่สืบจากค่าลับด้วย
    • บทความ KyberSlash ยังอธิบายแพตช์สำหรับตรวจการหารที่สืบจากค่าลับด้วย
  • TIMECOP 1 เป็นเครื่องมือที่ Moritz Neikes สร้างโดยแก้ SUPERCOP และทำให้แนวทาง ctgrind ของ Adam Langley เป็นอัตโนมัติ
  • TIMECOP 2 ขยายจากวิธีเดิมหลายอย่าง
    • ทำเครื่องหมาย output ของ RNG เป็น ค่าลับ โดยอัตโนมัติ
    • รองรับ “declassification”
    • รองรับการระบุ “public inputs”
    • รันบนหลายคอร์
  • TIMECOP มีข้อจำกัดที่ชัดเจน
    • จัดการได้เฉพาะคำสั่งที่ Valgrind รองรับ จึงหยุดเมื่อเจอคำสั่งอย่าง AMD XOP
    • ตรวจได้เฉพาะ data flow ที่มองเห็นในการรันทดสอบจริง
  • งานเกี่ยวกับเครื่องมือตรวจพฤติกรรม constant-time ยังดำเนินต่อไป และรายชื่อเครื่องมือที่เกี่ยวข้องอยู่ที่ ct-tools
  • การตรวจแบบเดียวกับ TIMECOP ถูกใส่เข้าไปในชุดทดสอบของ libmceliece แล้ว และอาจแพร่ไปยังไลบรารีอื่นได้

วิธีเขียนใหม่ให้เป็น constant-time

  • หลังจากพบชิ้นโค้ดแบบ variable-time แล้ว จำเป็นต้องมีวิธีเขียนใหม่ให้เป็น constant-time โดยไม่มีบั๊ก
  • งานนำเสนอเดือนกรกฎาคม 2024 แนะนำฟังก์ชัน constant-time บางส่วนที่ libmceliece และ SUPERCOP มีให้
    • ชื่อไฟล์คือ crypto_{int,uint}{8,16,32,64}.h
    • ไฟล์เหล่านี้สามารถคัดลอกไปใช้ในโปรเจกต์อื่นได้
  • ฟังก์ชันตัวอย่าง crypto_uint32_bitmod_mask(x,j) ให้ผลเทียบเท่า -((x>>(j&31))&1) แต่ทำให้คอมไพเลอร์มองไม่เห็น ผลลัพธ์ 1 บิต
  • ตัวอย่างที่ซับซ้อนกว่านั้นคือ crypto_uint32_max(x,y)
  • บทความปี 2018 กล่าวถึง tweak ที่เพิ่มฟังก์ชัน constant-time __builtin_ct_choose(bool cond, x, y) ให้ Clang/LLVM
    • บทความนั้นเสนอผิดว่าฟังก์ชันเดียวนี้ก็เพียงพอแล้ว
    • แม้ฟังก์ชันนี้อาจเข้าไปอยู่ในคอมไพเลอร์สักวันหนึ่ง แต่คงใช้เวลานานกว่าที่โปรเจกต์ต่าง ๆ จะพึ่งพาได้
    • มีการประเมินว่าวิธี implementation นี้ดูเปราะบางกว่า crypto_{int,uint}{8,16,32,64}.h

วิธีหลีกเลี่ยงปัญหาล่วงหน้า

  • หากการทดสอบก่อนเผยแพร่ไลบรารีที่คอมไพล์แล้วจับ timing leakage ที่คอมไพเลอร์นำเข้ามาได้ ระหว่างที่เขียนโค้ดใหม่ก็ยังสามารถใช้คอมไพเลอร์เวอร์ชันเดิมสำหรับการเผยแพร่ได้
    • วิธีนี้เป็นมาตรการชั่วคราวที่ยังคงทำให้ผู้ใช้ปลอดภัย
  • วิธีแก้อย่างหนึ่งคือเผยแพร่ไลบรารีเป็น assembly
    • งานนำเสนอ RWC 2024 Adoption of high-assurance and highly performant cryptographic algorithms at AWS นำเสนอซอฟต์แวร์ X25519 ที่เร็วและพิสูจน์แล้วว่าคำนวณ X25519 ได้ถูกต้องสำหรับทุก input
    • implementation เขียนเป็น assembly สองเวอร์ชันสำหรับ CPU Intel/AMD แบบ 64 บิต และสองเวอร์ชันสำหรับ CPU ARM แบบ 64 บิต
    • ข้อความถูกต้องเป็นทฤษฎีบทเกี่ยวกับ machine code ที่ผู้ใช้รันจริง และ proof ถูกตรวจสอบด้วย theorem prover HOL Light
  • อย่างไรก็ตาม ในซอฟต์แวร์เข้ารหัสที่ยังไปไม่ถึงระดับนี้ ปัญหาความยากในการ audit assembly ยังคงอยู่
  • สำหรับโค้ดที่เขียนด้วย C, C++ ฯลฯ ยังมีการสำรวจวิธีใส่ “วัคซีน” ป้องกัน timing leakage อย่างรวดเร็วด้วย

การทดลองแพตช์ clang-vs-clang

  • จุดร่วมของ x&1 และ x>>31 คือผลลัพธ์ที่เป็นไปได้มีแค่สองค่า
    • x&1 คือ 0 หรือ 1
    • x>>31 ของ uint32 คือ 0 หรือ 1
    • x>>31 ของ int32 คือ 0 หรือ -1
  • รูปแบบเหล่านี้ทำให้ผู้เขียน “optimization” ของคอมไพเลอร์นำผลลัพธ์ 1 บิตไปใส่เป็น bool ได้ง่าย
  • มีคำแนะนำให้คอมไพล์ด้วย -fwrapv เสมอ เพื่อให้ GCC และ Clang สมมติว่าใช้ two's-complement arithmetic
  • แม้การสแกนซอร์สเพื่อหา &1, 1&, >>31 ฯลฯ แบบง่าย ๆ จะพบตัวอย่างจำนวนมาก แต่บทความสแกนด้วยวิธีอื่นโดยใส่แพตช์เข้าไปใน “optimizer” ของ LLVM โดยตรง
  • แพตช์ เริ่มจาก LLVM commit 68df06a0b2998765cb0a41353fcf0919bbf57ddb เพื่อหา &1 และ >>31 แล้วออกคำเตือนต่อไปนี้
    • please take this away before clang does something bad
  • ตัวอย่างคำสั่งคอมไพล์คือ clang -Rpass-analysis=clang-vs-clang -O -c x.c
  • ฟังก์ชันทดสอบมีดังนี้
int sra31(int x)
    {
      x >>= 31;
      return x;
    }
  • การที่คำเตือนเดียวกันซ้ำหลายครั้งไม่น่าแปลกใจ
    • คอมไพเลอร์จะพยายามใช้ “optimization” ต่อไปจนกว่าจะไม่คืบหน้าแล้ว
  • output ของ clang-vs-clang แยกความต่างระหว่าง signed และ unsigned ใน shift
    • ความต่างนี้สำคัญต่อการเขียนใหม่ด้วยมือหรืออัตโนมัติโดยใช้ crypto_{int,uint}{8,16,32,64}.h
    • หนึ่งในวิธีทำ source transformation อัตโนมัติคือ clang-tidy
  • โค้ดที่ถูกตัดออกด้วย #ifdef หรือถูกลบก่อนถึงขั้น “optimization” นี้ จะไม่ทำให้เกิดคำเตือน clang-vs-clang

ผลการรัน SUPERCOP และกรณีที่พบ

  • รัน SUPERCOP 20240716 บน dual EPYC 7742 ด้วย ./data-do-biglittle
    • ปิดการ overclock
    • ปรับรายการคอมไพเลอร์ของ SUPERCOP ให้ใช้ clang-vs-clang โดยเพิ่ม -Rpass-analysis=clang-vs-clang ในบรรทัด clang ของ okcompilers/{c,cpp}
  • ผลลัพธ์พร้อมหลังจาก 3 ชั่วโมง
    • output ของ Clang รวม 675,752 บรรทัด
    • ขนาดต้นฉบับ 210,786,494 ไบต์
    • ผลลัพธ์ที่บีบอัดคือ 20240803-fromclang.txt.gz ขนาด 3,595,199 ไบต์
  • output มี noise มากจาก source branch ที่อิง public data ซึ่งสร้าง &1 ภายใน Clang
  • ตัวอย่างที่ชัดเจนว่าควรแก้ล่วงหน้ามีดังนี้
a0 += (a0>>15)&106;
  • ตัวอย่างที่ต้องใช้ความพยายามในการ parse C หากจะหาโดยสแกนซอร์สแบบง่ายมีดังนี้
    • macro ONE8 ถูกนิยามเป็น ((uint8_t)1)
*pk2^=(((* pk_cp)>>ir)&ONE8)<<jr;
  • ตัวอย่างที่หาได้ยากกว่านั้นมาจาก macro บน AVX2 intrinsic
    • signmask_x16(x) ถูกนิยามเป็น _mm256_srai_epi16((x),15)
    • นี่คือการ shift ขวา 15 บิตของชิ้น signed 16 บิตแต่ละชิ้นในเวกเตอร์ 256 บิต
mask = signmask_x16(sub_x16(x,const_x16((q+1)/2)));
  • กรณี AVX2 นี้ยังไม่ใช่ลำดับความสำคัญสูง
    • เพื่อให้ vector operation ถูกเปลี่ยนเป็น conditional branch ต้องคอมไพล์เป็น AVX-512 และคอมไพเลอร์ตัดสินใจแปลก ๆ ในการแปลง bool แบบ vectorized เป็น conditional branch ของ bool แบบ serial
    • TIMECOP ใช้ Valgrind และ Valgrind ไม่รองรับ AVX-512
    • ขณะนี้ยังไม่แนะนำให้คอมไพล์เป็น AVX-512

int128 และทิศทางรับมือที่กว้างขึ้น

  • สิ่งที่ค้นพบซึ่งน่าสนใจที่สุดคือกรณีที่การ shift ขวา 64 บิตของ int128 ทำให้เกิดคำเตือน >>
  • implementation ของ int128 อาจใช้การ shift ขวา 63 บิตภายใน เพื่อหาสัญญาณของ word 64 บิตด้านบน
  • หาก Clang เพิ่มการรองรับแบบ GCC ที่เปลี่ยนการ shift ขวา 63 บิตให้เป็น bool แล้วเปลี่ยนเป็น conditional branch ต่อ โค้ด int128 จำนวนมากอาจกลายเป็น variable-time ทันที
    • ในกรณีนี้จะคล้ายกับสถานการณ์ที่ชื่อบทความปี 2015 เคยกล่าวอ้าง แต่ครั้งนี้เกิดขึ้นได้จริงแม้ในซอร์สจะไม่มี bool
  • วิธีป้องกันที่ง่ายที่สุดในระดับซอร์สคือหลีกเลี่ยง implementation int128 เดิมของคอมไพเลอร์ และใช้ฟังก์ชัน crypto_int128
    • crypto_int128 ต่างจาก int128 ของ GCC และ Clang ตรงที่สามารถทำงานได้แม้บนแพลตฟอร์ม 32 บิตขนาดเล็ก
  • แนวทางเพิ่มชนิดข้อมูลลับให้ GCC และ Clang ดูดี แต่จากโครงสร้างของคอมไพเลอร์ทั้งสองยังไม่เห็นวิธีทำให้แข็งแรงได้ชัดเจน
  • ความคาดหวังจึงไปอยู่กับคอมไพเลอร์ที่ออกแบบมาเพื่อความปลอดภัยตั้งแต่ต้นมากกว่า
    • คอมไพเลอร์เน้นความปลอดภัยที่ต้องใช้ภาษา input ใหม่ ได้แก่ FaCT และ Jasmin ที่กำลังพัฒนาอย่างแข็งขัน
    • แม้มีความกังวลเรื่องเวลาที่ต้องใช้เขียนโค้ดใหม่ แต่เมื่อดูวิธีที่คอมไพเลอร์ปัจจุบันจัดการโค้ดเดิม ก็จำเป็นต้องมีมาตรการในรูปแบบใดรูปแบบหนึ่ง

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

 
GN⁺ 2024-08-05
ความคิดเห็นบน Hacker News
  • การเรียกสิ่งที่เป็นบั๊กของคอมไพเลอร์เพราะโค้ดที่มี พฤติกรรมที่ไม่ได้กำหนดไว้ ไม่ทำงานตามที่ต้องการนั้นไม่ถูกต้อง
    คล้ายกับการรัน dd ด้วยอาร์กิวเมนต์ที่ผิดจนข้อมูลหาย แล้วบอกว่า dd มีบั๊ก

    • ดูเหมือนผู้เขียนจะสับสนระหว่าง พฤติกรรมที่กำหนดโดยการใช้งานจริง กับพฤติกรรมที่ไม่ได้กำหนดไว้ในจุดนี้ ตัวอย่างในบทความส่วนใหญ่เป็นโค้ดที่ถูกต้อง และปัญหาจริงคือการปรับแต่งของคอมไพเลอร์ที่เปลี่ยนการคำนวณแบบ bit operation ให้เป็น branch ทำให้โค้ดเข้ารหัสลับเสี่ยงต่อ timing attack
      จะมองว่าเป็นบั๊กของซอร์สโค้ดหรือคอมไพเลอร์ก็คงยากกว่า ควรมองว่ามาตรฐาน C ระบุรายละเอียดไว้น้อยเกินไปตามเกณฑ์ของผู้เขียน จนทำให้เกิดบั๊กด้านความปลอดภัยบนบาง target มากกว่า
      สุดท้ายแล้วผู้เขียนมาตรฐาน C ก็ไม่สามารถกำหนดไปถึงพฤติกรรมของฮาร์ดแวร์ได้ ทำได้เพียงกำหนด semantics ของภาษา ดังนั้นฝั่งการเข้ารหัสลับก็ต้องลำบากกับบั๊กที่เกิดจากฮาร์ดแวร์อย่างเลี่ยงไม่ได้
    • ปัญหาคือ พฤติกรรมที่ไม่ได้กำหนดไว้ของ C และ C++ มีมากจนน่าหัวเราะ และการหลีกเลี่ยงทั้งหมดนั้นยากสุด ๆ
      หนึ่งในข้อดีของ Rust คือจำกัดพฤติกรรมที่อาจไม่ได้กำหนดไว้ให้อยู่ภายในบล็อก unsafe ถึงอย่างนั้น แม้ Rust จะกำหนดพฤติกรรมหลายอย่างที่ใน C ถือว่าไม่ได้กำหนดไว้แล้ว แต่เมื่อเข้าไปในโค้ด unsafe ก็ยังเผลอเหยียบพฤติกรรมที่ไม่ได้กำหนดไว้อันละเอียดอ่อนได้ง่ายมาก
    • โมเดลของพฤติกรรมที่ไม่ได้กำหนดไว้ ที่มีประโยชน์ต่อผู้ใช้คอมไพเลอร์มีเพียงสองแบบ: ถ้าเป็นแนวคิดที่ไม่ดี ก็ปฏิเสธการคอมไพล์ไปเลย หรือไม่ก็ทำสิ่งที่สมเหตุสมผลและเสถียร
      โมเดลที่สามซึ่งล้มเหลวแบบเงียบ ๆ แล้วสร้างโค้ดที่คาดเดาไม่ได้ มีประโยชน์เฉพาะกับผู้เขียนคอมไพเลอร์เท่านั้น การหลบอยู่หลังสเปกไม่ได้ให้ประโยชน์แก่ผู้ใช้จริง
    • บทความ C and C++ Prioritize Performance over Correctness ของ Russ Cox อธิบายประเด็นนี้ได้ดี: https://research.swtch.com/ub
    • คำโต้แย้งนั้นแทบจะเป็นการโจมตีหุ่นฟาง ประเด็นหลักคือผู้เขียนคอมไพเลอร์ตัดสินเองว่าอะไรคือพฤติกรรมที่ไม่ได้กำหนดไว้ และนิยามมาตรฐานเพื่อให้ได้ ช่องว่างสำหรับการปรับแต่ง มากขึ้น
      การปรับแต่งนั้นทำให้โค้ดที่เคยทำงานได้ดีพัง ผู้เขียนคอมไพเลอร์อาจให้ความสำคัญกับความเข้ากันได้ย้อนหลังได้ แต่ก็ไม่ได้ทำ
      แถมการปรับแต่งแบบนี้ยังไม่ได้ช่วยปรับปรุงประสิทธิภาพของโค้ดจริงอย่างมีนัยสำคัญด้วย ดังนั้นจึงต้องโต้แย้งให้ได้ว่าการแลกเปลี่ยนที่ทำให้โค้ดพังนี้คุ้มค่า
  • ผมชอบ Bernstein แต่บางครั้งเขาก็จับทิศผิดแล้วกลายเป็นสุดโต่ง ซึ่งบทความนี้เป็นตัวอย่างที่ดี ตอนท้ายเขาเองก็ยอมรับอยู่ครึ่งหนึ่ง
    ส่วนใหญ่ของบทความเป็นประเด็นรองว่าอานิสงส์จากการปรับแต่งดีแค่ไหน และต่อให้มีข้อมูล ก็ยังเป็นการตัดสินที่ขึ้นกับ use case
    ความไม่พอใจหลักคือคอมไพเลอร์ C ไม่คำนึงถึง semantics ที่ไม่สามารถแสดงด้วยภาษาได้ ซึ่งก็ไม่ใช่เรื่องน่าประหลาดใจ
    ตอนท้ายเขาบอกว่า “ให้ใช้ภาษาที่สามารถแสดง semantics ที่ต้องการได้” ซึ่งทั้งบทความอาจแทนได้ด้วยประโยคเดียวนี้

    • จุดสำคัญคือฝั่งที่นิยาม semantics ของ C และ C++ โยนพฤติกรรมมากเกินไปลงไปในตะกร้า “พฤติกรรมที่ไม่ได้กำหนดไว้”
      ในจำนวนนี้หลายอย่างมีเหตุผลที่น่าสงสัย และทำให้การเขียนโปรแกรมที่ถูกต้องยากขึ้น
    • ส่วนที่บอกว่าอานิสงส์จากการปรับแต่งขึ้นกับ use case เป็นบริบทที่มีประโยชน์ และค่อนข้างเปิดหูเปิดตา
    • ตรงนี้ DJB ไม่ค่อยโน้มน้าวใจ มี มุมมองแบบศาสนาเชิงอภิชน ที่ไม่มีหลักฐานรองรับออกมาให้เห็นมาก
  • C และ C++ ไม่เหมาะกับการเขียนอัลกอริทึมที่รับประกัน constant-time
    ในมาตรฐานแทบไม่มีแนวคิดเรื่อง real-time และคอมไพเลอร์ก็ไม่ได้ให้การรับประกันเพิ่มเติมผ่าน extension ด้วย
    แต่การโยนความผิดให้ผู้พัฒนาคอมไพเลอร์ในเรื่องนี้เป็นทิศทางที่ผิด

    • หากต้องการสร้าง machine code ที่ทำงานเป็น constant-time เสมอโดยไม่ขึ้นกับ branch ก็ต้องใช้ภาษาที่สามารถแสดงสิ่งนั้นได้ C ไม่รองรับสิ่งนั้น
    • อยากรู้ว่าภาษาอะไรเหมาะกับการเขียนอัลกอริทึมที่มีการรับประกัน constant-time
  • บน CPU ของ Intel ไม่ว่าจะเป็น clang หรืออะไรก็ตาม ไม่สามารถสร้างโค้ดที่ถูกต้องใน user mode ได้ เพราะตั้งแต่แรกไม่มีโค้ดที่ถูกต้องอยู่แล้ว
    https://www.intel.com/content/www/us/en/developer/articles/t...
    ถ้าดู DOITM ในเอกสาร จะเห็นว่าการให้ไลบรารีเข้ารหัสลับใน user space ตั้งบิตที่จำเป็นนั้นเป็นไปไม่ได้เลย

    • โค้ด user mode ก็สามารถรันในโหมดที่ถูกต้องได้ เพียงแต่ไม่สามารถสลับ toggle เพื่อเปิดปิดโหมดนั้นได้เองเท่านั้น
      เมื่อเปิดแล้วก็ทำงานใน user space ได้ดี ดังนั้น เช่น ทำให้เป็นแฟล็กรายโปรเซสที่เปิดใช้งานผ่าน system call prctl แล้วให้ปรับ MSR ตอน scheduler สลับงาน ก็เป็นไปได้
    • เรียก system call เข้า kernel เพื่อตั้งแฟล็ก แล้วกลับมาที่ user mode ในสถานะนั้นไม่ได้หรือ?
  • แค่เห็นประโยคที่ว่า “เมื่อใดก็ตามที่ทำได้ ผู้เขียนคอมไพเลอร์ปฏิเสธที่จะรับผิดชอบต่อบั๊กที่ตนเองสร้างขึ้น” ก็แทบไม่ค่อยเห็นกรณีที่ความเป็นมืออาชีพของบทความบล็อกพังลงเร็วขนาดนี้
    ถ้าตามลิงก์ไปดู ก็เป็นเพียงเนื้อหา C พื้นฐานมาก ๆ ว่า undefined behavior ไม่ได้หมายความว่าจะสร้าง “ค่าใด ๆ ก็ได้”

    • ดูเหมือนกำลังเรียกสิ่งคนละอย่างว่า “บั๊ก” ฝั่งหนึ่งหมายถึงบั๊กในซอร์สโค้ด อีกฝั่งหมายถึงบั๊กในโปรแกรมที่ถูกสร้างออกมา
      แม้จะมี undefined behavior ซอร์สโค้ดก็เป็นบั๊กได้ แต่โปรแกรมที่สร้างออกมายังถูกต้องอยู่บ่อยครั้ง ต่อมาเมื่อผู้เขียนคอมไพเลอร์ใส่ optimization ใหม่ แล้วใช้ undefined behavior นั้นเป็นเหตุให้สร้างโปรแกรมที่มีบั๊กออกมา การโต้เถียงเรื่องความรับผิดชอบก็เริ่มขึ้น
      ส่วนที่ไม่อยากยอมรับคือความรับผิดชอบต่อผู้ใช้นั้นแบ่งกันอยู่ทุกฝ่าย ถ้าแอป CRUD ทำให้แบตเตอรี่ไหม้เพียงเพราะ dereference NULL คนปกติคงไม่โทษแค่ว่าผู้เขียนแอปลืมตรวจ NULL เท่านั้น
      คอมไพเลอร์ ระบบปฏิบัติการ และผู้ผลิตฮาร์ดแวร์ก็ต้องรับผิดชอบต่อผลิตภัณฑ์ที่ออกแบบอย่างไร้ความรับผิดชอบด้วย ไม่ใช่จบแค่คำว่า “undefined behavior” ตามมาตรฐาน ISO สมาชิกทุกส่วนในซัพพลายเชนร่วมกันมีหน้าที่คาดการณ์ว่าผลิตภัณฑ์อาจถูกใช้งานผิดวิธีอย่างไร และจัดการอย่างสมเหตุสมผล
    • มองว่าผู้เขียนบทความรู้ดีว่า undefined behavior คืออะไร เพียงแต่กำลังมองทั้งระบบอย่างวิพากษ์
      undefined behavior มีอยู่เพื่อมอบคุณค่า สามารถสร้างภาษาโดยไม่มีสิ่งนี้ก็ได้ แต่เหตุผลที่ยังจงใจมีอยู่คือเพื่อ portability และความยืดหยุ่นที่ให้แก่ผู้เขียนคอมไพเลอร์
      แก่นของบทความคือความยืดหยุ่นนั้นคุ้มค่าหรือไม่ เมื่อเทียบกับความยากในการเขียนโปรแกรมโดยไม่มี undefined behavior
      ผู้เขียนบทความมองว่าเงินที่เสียไปกับบั๊กดูจะมากกว่าเงินที่ประหยัดได้จาก bytecode ที่เร็วขึ้น และเพราะผู้เขียนคอมไพเลอร์มีอิทธิพลมากในการกำหนดสิ่งที่จะใส่ในมาตรฐานภาษา จึงมีแรงจูงใจน้อยที่จะปรับแก้เรื่องนี้
  • สำหรับข้อมูลอ้างอิง ใน clang มีแอตทริบิวต์ clang::optnone ที่ปิดการปรับแต่งประสิทธิภาพทั้งหมดเป็นรายฟังก์ชัน และใน GCC มีแอตทริบิวต์ gnu::optimize ที่ยอดเยี่ยม ซึ่งสามารถเพิ่มหรือลบการปรับแต่งประสิทธิภาพตามชื่อ หรือกำหนดระดับการปรับแต่งประสิทธิภาพได้โดยไม่ขึ้นกับแฟล็กของคอมไพเลอร์
    gnu::optimize(0) คล้ายกับแฟล็กของ clang นั้น ใน clang ยังมี clang::no_builtins ซึ่งปิดการปรับแต่งประสิทธิภาพของ memcpy และ memset โดยเฉพาะด้วย

    • “แอตทริบิวต์ optimize ควรใช้เพื่อ วัตถุประสงค์ในการดีบัก เท่านั้น และไม่เหมาะกับโค้ดโปรดักชัน”
      https://gcc.gnu.org/onlinedocs/gcc/Common-Function-Attribute...
  • ค่อนข้างเห็นด้วยกับเป้าหมายที่คนสายคริปโตต้องการ เช่น การประเมินผลแบบเวลาคงที่ และการซ่อนค่าลับ
    แต่คอมไพเลอร์เอนกประสงค์ไม่ได้คิดถึงเรื่องแบบนั้นเป็นส่วนใหญ่ จึงดูยากที่จะเป็นอะไรได้มากกว่าแฮ็กที่พอทำงานได้โดยทั่วไป
    ถ้าจะทำจริงจัง ก็น่าจะต้องมีคอมไพเลอร์เฉพาะของตัวเอง หรือไม่ก็ต้องไปทาง assembly ต่อไป

    • ผู้เขียนได้เขียนคอมไพเลอร์แบบนั้นไว้แล้ว: https://cr.yp.to/qhasm.html อย่างน้อยก็เป็นต้นแบบแบบนั้น
  • สักวันหนึ่งเราอาจมองยุคนี้ว่าเป็น “วันเก่า ๆ อันเลวร้าย” และหลุดพ้นจาก C ไปใช้ ภาษาที่มีพฤติกรรมไม่ถูกกำหนดน้อยกว่ามาก
    ใน C เราสามารถเขียนนิพจน์ที่คอมไพล์ผ่านได้ง่ายเกินไป ทั้งที่คอมไพเลอร์ไม่มีทางรู้ได้เลยว่าตั้งใจให้ทำอะไร
    ตัวอย่างเช่น ใน Python เราเขียนโค้ดอย่าง result = [something(value) for value in set_object] ได้ อ็อบเจ็กต์ set ไม่มีลำดับ ดังนั้นจึงชัดเจนว่าลำดับการประมวลผลรายการและลำดับผลลัพธ์ไม่สำคัญ และสิ่งนี้เปิดทางให้ปรับแต่งประสิทธิภาพได้มากในระดับภาษา โดยที่คอมไพเลอร์ไม่ต้องเดาเจตนาของผู้เขียน
    โค้ดลักษณะคล้ายกันในภาษาอื่นที่มีข้อมูลแบบไม่เปลี่ยนรูปไปได้อีกขั้น เพราะ something(value1) ไม่สามารถส่งผลต่อ something(value2) ได้ จึงสามารถรันขนานกันได้ ไม่ว่าจะเป็นเธรดหรือโปรเซส
    การปรับแต่งประสิทธิภาพของคอมไพเลอร์ C ส่วนใหญ่คือการดูแพตเทิร์นโค้ด แล้วหาวิธีทำให้สิ่งที่ผู้เขียนน่าจะตั้งใจทำเร็วขึ้น C มีความสามารถในการสื่อเจตนาน้อยกว่าภาษาสมัยใหม่ จึงมีอิสระให้เดาได้ แต่ถ้าจะให้ได้ประสิทธิภาพที่พอใช้ได้ ก็ต้องทำการอนุมานแบบนั้น
    ถึงอย่างนั้น มันอาจเป็นพรที่แฝงมาในคราบปัญหา เหมือนกรณีที่กล้องโทรทรรศน์ฮับเบิลต้องการ “แว่นตา” ก็ได้ เราสร้างเทคนิคชั้นยอดขึ้นมาเพื่อเอาชนะข้อจำกัด และหลังจากแก้ปัญหาแล้ว เทคนิคเหล่านั้นก็ให้ประสิทธิภาพสูงกว่าที่คาดไว้เดิมมาก หากนำการปรับแต่งประสิทธิภาพของคอมไพเลอร์ C ไปใช้กับภาษาที่ไม่ใช่ C มันอาจทำงานได้ราวกับพลังพิเศษ

    • ข้อเสียของตัวอย่าง Python คือ แม้ลำดับจะไม่ได้ถูกระบุไว้ในสเปก ผู้คนก็ยังอาจพึ่งพาคุณสมบัติบางอย่างได้ และถ้า optimizer เปลี่ยนลำดับ โค้ดก็อาจพัง
      โดยพื้นฐานแล้วคล้ายกับพฤติกรรมไม่ถูกกำหนด เพียงแต่ไม่ได้ปรากฏเป็นปัญหาความปลอดภัยทันที แต่อาจเป็นผลลัพธ์ที่ผิด แน่นอนว่าผลลัพธ์ที่ผิดอาจนำไปสู่ปัญหาความปลอดภัยในภายหลังได้
      ต่างจากพฤติกรรมไม่ถูกกำหนด การสร้าง “sanitizer” ที่ตรวจว่าโค้ดทำงานได้กับทุกลำดับที่เป็นไปได้ของ set นั้นแทบเป็นไปไม่ได้ในทางปฏิบัติ
      gcc และ clang มี hint ระดับต่ำจำนวนมากที่มักไม่มีในภาษาอื่น เช่น __builtin_expect/__builtin_unpredictable, __builtin_unreachable/__builtin_assume, #pragma clang loop vectorize(assume_safety)/#pragma GCC ivdep, รวมถึง pragma สำหรับปิดการ unroll ลูปหรือ vectorization หรือเลือกค่าบางค่า
      สิ่งที่ขาดหายไปมากที่สุดน่าจะเป็น optimization barrier ที่ระบุชัดเจนเพื่อกันไม่ให้คอมไพเลอร์อนุมานจากที่มาของค่าได้ __asm__ ทำได้ในระดับหนึ่ง แต่มีผลข้างเคียงที่ไม่ต้องการ และต้องใช้ชื่อชนิดรีจิสเตอร์เฉพาะแพลตฟอร์ม
      ศักยภาพของการปรับแต่งประสิทธิภาพเชิงเจตนาระดับสูงก็มีอยู่ชัดเจนเช่นกัน เช่น จองพื้นที่ array list ก่อน push n ครั้งในลูป, รวมการค้นหา hashmap แบบ contains→get→put ด้วยคีย์เดียวกัน, หรืออนุมานพฤติกรรมการจัดสรรแบบ global ในขอบเขต local เพื่อกำจัดอ็อบเจ็กต์และการจัดสรร
    • ในเชิงทฤษฎีก็ฟังขึ้น แต่ยังไม่มีอะไรพิสูจน์ได้ว่าจริง ๆ แล้วเร็วกว่า C
      C อยู่ใกล้กับฮาร์ดแวร์จริงมากพอที่โปรแกรมเมอร์จะบอกได้ตรง ๆ ว่าจะทำอะไร ดังนั้นคอมไพเลอร์จึงไม่จำเป็นต้องเดาเจตนาของโปรแกรมเมอร์
    • จริงอยู่ว่ายังมีพื้นที่สำหรับ การปรับแต่งประสิทธิภาพบนพื้นฐานของ semantics แต่จากที่สังเกต การปรับแต่งแบบนั้นส่วนใหญ่อยู่รอบ ๆ การจัดสรรหน่วยความจำ
      ภาษาที่นำ optimization ด้านหน่วยความจำแบบนั้นไปใช้ส่วนใหญ่เป็นตระกูล Java และเหตุผลที่มีแรงจูงใจให้ทำ optimization แบบนั้นก็เพราะเดิมทีมีการทำให้แย่ลงล่วงหน้าอย่างรุนแรงอยู่แล้ว แต่แม้ optimization นั้นก็ยังชดเชยความเสียหายไม่ได้
      ประเด็นคือ C ก็ไม่ได้ดีนัก แต่ฝั่งอื่นแย่กว่า
  • ถ้าไม่ชอบ semantics ของ C ก็ไม่ต้องไปโกรธวิศวกรคอมไพเลอร์ แค่ใช้ ภาษาโปรแกรมมิงอื่น ก็พอ

    • พูดตามตรง ผมไม่รู้ว่า djb จะทนอะไรได้นอกจาก qhasm ของเขาเองหรือเปล่า แม้แต่ Zig ก็เถอะ ดังนั้นความเห็นครั้งนี้จากเขาจึงไม่ได้ทำให้แปลกใจนัก
  • เป็นบทความที่สดใหม่ เพราะนำเสนอมุมมองที่ไม่ค่อยได้ยินบ่อย น่าอ่านคู่กัน: https://gavinhoward.com/2023/08/the-scourge-of-00ub/