เมื่อผลลัพธ์บอกว่าอย่าทำ แต่เราก็ยังเลือกจะทำ
ในการเขียนโปรแกรม บางครั้งตัวเลขที่วัดได้ก็ไม่ได้บอกความจริงทั้งหมดเสมอไป เหมือนกับตอนที่เราทำเครื่องมือค้นหาข้อมูลในไฟล์บันทึกการทำงาน (Log Query Tool) เราพยายามทำให้มันทำงานเร็วขึ้นด้วยการแบ่งงานให้หลายคนช่วยทำพร้อมกัน แต่ผลลัพธ์กลับกลายเป็นว่าการมีคนช่วยหลายคนกลับทำให้งานช้าลงกว่าเดิมเสียอย่างนั้น
เราทดสอบด้วยการใช้ Goroutines (หน่วยทำงานย่อยในภาษา Go ที่ช่วยให้รันคำสั่งพร้อมกันได้) จำนวน 8 ตัว เพื่อจัดการข้อมูลขนาด 76.3MB ผลปรากฏว่ามันใช้เวลาไป 7.558 วินาที ในขณะที่ถ้าให้คนเดียวทำ มันใช้เวลาเพียง 6.535 วินาทีเท่านั้น ซึ่งการแบ่งงานให้ 8 คนทำช้ากว่าเดิมถึง 16%
แม้ผลการทดสอบจะบอกว่าการเพิ่มจำนวนคนทำงานไม่ได้ช่วยให้เร็วขึ้น แต่เราก็ยังตัดสินใจใส่ความสามารถนี้เข้าไปในโปรแกรมของเราอยู่ดี เพราะในโลกของการพัฒนาซอฟต์แวร์ บางครั้งการตัดสินใจไม่ได้ขึ้นอยู่กับความเร็วในวันนี้เพียงอย่างเดียว แต่ขึ้นอยู่กับการวางโครงสร้างเพื่ออนาคตด้วย
ทำไมการแบ่งงานทำพร้อมกันถึงช้ากว่าเดิม
หลายคนอาจสงสัยว่าทำไมการเพิ่มจำนวนคน (Workers) ถึงไม่ช่วยให้งานเสร็จไวขึ้น ให้ลองนึกภาพการทำอาหาร ถ้าคุณมีเชฟคนเดียวเขาจะทำทุกอย่างตามลำดับขั้นตอน แต่ถ้าคุณมีเชฟ 8 คน คุณต้องเสียเวลาในการ Coordination (การประสานงานหรือส่งต่องานระหว่างกัน) ซึ่งถ้างานนั้นมีขั้นตอนการเตรียมวัตถุดิบที่ซับซ้อนและต้องรอคิว การเพิ่มคนจะกลายเป็นภาระแทน
ในกรณีของเรา ปัญหาคอขวดอยู่ที่ขั้นตอนการ Decode (การแปลงข้อมูลดิบให้เป็นรูปแบบที่โปรแกรมเข้าใจ) ซึ่งเป็นงานหลักที่กินเวลามากที่สุด การที่เราเพิ่มจำนวนคนทำงานเข้าไปช่วยแค่ขั้นตอนการคำนวณสรุปผล แต่ขั้นตอนการอ่านข้อมูลดิบยังคงทำได้ทีละคนเหมือนเดิม ทำให้เวลาที่เสียไปกับการส่งข้อมูลให้กันระหว่างคนทำงานนั้นไม่คุ้มค่าเลย
นี่คือบทเรียนสำคัญสำหรับโปรแกรมเมอร์มือใหม่ว่า Parallelization (การทำหลายอย่างพร้อมกัน) ไม่ใช่ยาวิเศษที่จะแก้ปัญหาช้าได้ทุกเรื่อง หากเราไม่ได้ปรับปรุงขั้นตอนที่ช้าที่สุด (Bottleneck) การเพิ่มทรัพยากรเข้าไปก็เหมือนการเอาคนไปยืนออหน้าประตูที่เปิดได้ทีละนิด ยิ่งคนเยอะก็ยิ่งวุ่นวายและช้าลงกว่าเดิม
ความถูกต้องต้องมาก่อนความเร็ว
เหตุผลที่เราตัดสินใจใส่ฟีเจอร์นี้ลงไปทั้งที่มันช้ากว่า คือเรื่องของ ความถูกต้อง (Correctness) การเขียนโปรแกรมที่ดีไม่ได้ดูแค่ว่ามันเร็วแค่ไหน แต่ต้องดูว่ามันให้ผลลัพธ์ที่เชื่อถือได้หรือไม่ เราได้ทดสอบแล้วว่าไม่ว่าจะใช้คนทำงานกี่คน ผลลัพธ์ที่ได้ออกมานั้นเหมือนกันทุกประการ ซึ่งเป็นเรื่องที่สำคัญที่สุดสำหรับเครื่องมือจัดการข้อมูล
ลองดูตัวอย่างการเปรียบเทียบการทำงานแบบขั้นตอนเดียวกับหลายขั้นตอนในโค้ด Go ง่ายๆ ดังนี้ครับ
// โค้ดส่วนที่จัดการงานแบบง่าย
func processLog(data []string) {
for _, line := range data {
// ประมวลผลทีละบรรทัด
println(line)
}
}
// โค้ดส่วนที่แบ่งงานให้หลายคนทำ
func processLogParallel(data []string) {
// สร้างช่องทางส่งข้อมูลและแบ่งงาน
// แต่ละคนจะประมวลผลข้อมูลในส่วนของตัวเอง
}
ในโค้ดชุดแรกเป็นการทำงานแบบวนลูป (Loop) ทีละบรรทัด ซึ่งเรียบง่ายและตรวจสอบได้ง่าย ส่วนโค้ดชุดที่สองคือการใช้ Concurrency (การทำงานแบบขนาน) เพื่อแบ่งงาน ซึ่งต้องมีการจัดการ Channel (ช่องทางสื่อสารระหว่างหน่วยทำงาน) ให้ถูกต้อง ผลลัพธ์ที่ได้จากการรันทั้งสองวิธีต้องได้ข้อมูลชุดเดียวกันเป๊ะ ห้ามมีบรรทัดไหนหายไปหรือผิดเพี้ยนเด็ดขาด
การออกแบบที่ใส่ใจรายละเอียดข้อมูล
อีกเรื่องที่เราภูมิใจมากคือการจัดการกับข้อมูลที่หายไป ปกติแล้วเครื่องมือส่วนใหญ่จะมองว่าค่าที่ไม่มีอยู่จริง (Missing) กับค่าที่เป็นศูนย์ (Null) คือเรื่องเดียวกัน ซึ่งมักจะทำให้โปรแกรมพังเมื่อเจอข้อมูลที่หน้าตาแปลกไปจากเดิม เราจึงออกแบบให้ระบบแยกแยะสถานะเหล่านี้ออกจากกันอย่างชัดเจนตั้งแต่เริ่มสร้างโปรเจกต์
การแยกแยะสถานะ Missing, Null และ False ออกจากกันช่วยให้โปรแกรมไม่พังกลางคันเมื่อเจอข้อมูลที่ไม่สมบูรณ์ นี่คือแนวคิดเรื่อง Robustness (ความทนทานของโปรแกรม) ที่โปรแกรมเมอร์ควรมี เราไม่ได้แค่เขียนโค้ดให้ทำงานได้ แต่ต้องเขียนให้มันรับมือกับสิ่งที่คาดไม่ถึงได้ด้วย
หากเราไม่วางรากฐานนี้ไว้ตั้งแต่ต้น การจะมาแก้ไขภายหลังจะเป็นเรื่องยากมาก การเลือกออกแบบให้ถูกต้องตั้งแต่บรรทัดแรก แม้จะใช้เวลานานกว่าในการคิด แต่จะช่วยลดบั๊ก (ข้อผิดพลาด) ที่จะเกิดขึ้นในอนาคตได้อย่างมหาศาล เพราะโค้ดที่เขียนมาดีจะช่วยให้คุณไม่ต้องกลับมาแก้เรื่องเดิมซ้ำๆ ในภายหลัง
ทำไมเราถึงลบ CI ทิ้งแทนที่จะเก็บไว้
ในระหว่างการทำโปรเจกต์ เราได้ใส่ CI (Continuous Integration - ระบบตรวจสอบโค้ดอัตโนมัติ) เข้าไปเพื่อช่วยเช็คความเรียบร้อยของโค้ด แต่เมื่อมันทำงานผิดพลาดโดยที่เราหาสาเหตุไม่เจอและไม่สามารถแก้ไขได้ทันท่วงที เราจึงตัดสินใจลบมันทิ้งออกไปจากระบบทันที แทนที่จะปล่อยให้มันขึ้นไฟสีแดงค้างไว้โดยไม่มีคำอธิบาย
สำหรับมือใหม่ การมีระบบตรวจสอบอัตโนมัติเป็นเรื่องดี แต่ถ้ามันให้ผลลัพธ์ที่เชื่อถือไม่ได้ การลบออกดีกว่าการเก็บไว้ให้สับสน เพราะการเห็นไฟเตือนสีแดงโดยไม่รู้สาเหตุจะทำให้เราเสียสมาธิ และอาจทำให้เราละเลยไฟเตือนสีแดงจริงๆ ในอนาคตได้ ซึ่งเป็นนิสัยที่ไม่ดีในการทำงานเป็นทีม
จำไว้ว่าเครื่องมือมีไว้เพื่อช่วยให้เราทำงานง่ายขึ้น ไม่ได้มีไว้เพื่อสร้างภาระเพิ่ม หากเครื่องมือไหนทำตัวเป็นปัญหามากกว่าเป็นตัวช่วย การตัดใจทิ้งไปก่อนแล้วค่อยกลับมาทำใหม่เมื่อพร้อมคือทางเลือกที่ฉลาดกว่า การทำงานอย่างโปร่งใสและตรวจสอบได้สำคัญกว่าการมีเครื่องมือที่ดูหรูหราแต่ใช้งานจริงไม่ได้
สรุปบทเรียนจากการตัดสินใจ
เราตัดสินใจใส่ตัวเลือกการประมวลผลแบบขนานเข้าไป แม้ผลทดสอบจะบอกว่าช้ากว่าเดิม เพราะเรามองว่ามันคือ การวางโครงสร้างพื้นฐาน (Baseline) เพื่อรอการปรับปรุงในอนาคต เมื่อวันหนึ่งที่เราปรับปรุงขั้นตอนการอ่านข้อมูลให้เร็วขึ้นได้ ระบบจะพร้อมรองรับการทำงานหลายคนทันทีโดยไม่ต้องไปแก้โครงสร้างหลักอีก
สำหรับการนำไปใช้จริง: เมื่อคุณกำลังฝึกเขียนโปรแกรม อย่าเพิ่งหมกมุ่นกับการทำให้โค้ดเร็วที่สุดเพียงอย่างเดียว แต่ให้เน้นที่ความถูกต้องและการออกแบบที่ยืดหยุ่นก่อน เหมือนที่เราทำคือ
- เขียนโค้ดให้ทำงานได้ถูกต้องและจัดการเคสแปลกๆ ได้ครบถ้วนก่อน
- วัดผลด้วยตัวเลขจริงเสมอ เพื่อให้รู้ว่าสิ่งที่ทำไปนั้นได้ผลจริงหรือไม่
- ถ้าผลลัพธ์ยังไม่ดีพอ ให้ยอมรับความจริง แต่ถ้าโครงสร้างมันดีแล้ว ให้เก็บไว้เป็นรากฐานสำหรับพัฒนาต่อในเวอร์ชันถัดไป
การเป็นโปรแกรมเมอร์ที่ดีไม่ได้วัดกันที่ว่าคุณทำฟีเจอร์ได้เร็วแค่ไหน แต่วัดกันที่ว่าคุณเข้าใจเหตุผลเบื้องหลังการตัดสินใจของคุณมากน้อยเพียงใด และพร้อมที่จะเรียนรู้จากความผิดพลาดเพื่อก้าวต่อไปได้ดีกว่าเดิมหรือไม่ครับ
ที่มา: We Shipped an Optimization Our Own Benchmark Said Not To — DEV Community