โพสต์นี้เขียนโดย Colin Weld และ Connor Adams อ่านได้ที่นี่ หรือ ในบล็อกของเรา.
ที่ Modal เราสร้างแซนด์บ็อกซ์ (sandboxes) รวมถึงสิ่งอื่นๆ เอเจนต์ (Agents) ทำงานในแซนด์บ็อกซ์ และเอเจนต์กำลังกลืนกินซอฟต์แวร์ ปัจจุบัน Modal รันแซนด์บ็อกซ์หลายล้านรายการต่อวัน รองรับแซนด์บ็อกซ์พร้อมกันสูงถึงห้าหมื่นรายการต่อลูกค้า และรองรับกรณีการใช้งานที่หลากหลายในระดับใหญ่ ตั้งแต่ การเรียนรู้แบบเสริมกำลัง (reinforcement learning) ไปจนถึง เอเจนต์พื้นหลัง (background agents)
ผู้ใช้ของเราต้องการแซนด์บ็อกซ์มากขึ้นเรื่อยๆ และสร้างขึ้นในอัตราที่สูงขึ้นเรื่อยๆ การเรียนรู้แบบเสริมกำลังอาจต้องรันแซนด์บ็อกซ์หลายล้านรายการพร้อมกัน และสร้างแซนด์บ็อกซ์จำนวนหลายแสนรายการเป็นพักๆ เมื่อเริ่มต้นการเปิดตัว ในทำนองเดียวกัน เอเจนต์ต้องการขนาดที่ใหญ่โตและอัตราการสร้างพร้อมกันที่สูงเพื่อรองรับปริมาณการใช้งานที่พุ่งสูงขึ้น
แพลตฟอร์มแซนด์บ็อกซ์เดิมของเรานั้นดีมาก แต่มันไม่ได้ถูกออกแบบมาสำหรับขนาดเหล่านี้ และไม่มีโซลูชันอื่นใดที่มีอยู่แล้วก็ถูกออกแบบมาเช่นกัน เราคลั่งไคล้ในเรื่องของขนาดและประสิทธิภาพ และเราต้องการให้โครงสร้างพื้นฐานของเราช่วยเร่งการเติบโตของเอเจนต์ ไม่ใช่สร้างอุปสรรค ดังนั้นเราจึงกลับไปเริ่มต้นใหม่
ในช่วงหลายเดือนที่ผ่านมา เราได้สร้างแพลตฟอร์มแซนด์บ็อกซ์หลักของเราขึ้นมาใหม่ทั้งหมดทั้งในด้านขนาดและความน่าเชื่อถือ บนระบบใหม่ของเรา ผู้ใช้สามารถรันแซนด์บ็อกซ์หลายล้านรายการพร้อมกัน และสร้างแซนด์บ็อกซ์หลายหมื่นรายการต่อวินาที เราได้ขจัดคอขวดส่วนกลางทั้งหมดออกจากคอนโทรลเพลน (control plane) ของเรา ดังนั้นจึงไม่มีข้อจำกัดในการปรับขนาดในทางปฏิบัติ และเราปรับแต่งทุกส่วนของการจัดกำหนดการและการเริ่มต้นคอนเทนเนอร์ให้เหมาะสม ลดความซับซ้อนของเส้นทางการจัดกำหนดการให้เหลือเพียงชั้นของ load balancers ที่สร้างคอนเทนเนอร์โดยตรงบนกลุ่มเครื่องทำงาน (worker fleet) ของเรา
เพื่อเป็นการสาธิตความสามารถของแพลตฟอร์มของเรา เราได้รันแซนด์บ็อกซ์หนึ่งล้านรายการพร้อมกัน โดยสร้างทั้งหมด 1 ล้านรายการภายในเวลาไม่ถึงนาที

หลักฐานว่าเราสามารถรันแซนด์บ็อกซ์ได้จำนวนมาก
ทำไมโซลูชันส่วนใหญ่ถึงไม่สามารถปรับขนาดได้
การรันแซนด์บ็อกซ์ 1 ล้านรายการจะผลักดันขีดจำกัดของแพลตฟอร์มคอนเทนเนอร์ใดๆ ก็ตาม ไม่เพียงเพราะจำนวนคอนเทนเนอร์ที่มากมายมหาศาล แต่ยังเพราะการรันแซนด์บ็อกซ์จำนวนมากนี้ต้องใช้โหนดคอมพิวต์ (compute nodes) หลายหมื่นโหนดด้วย จะมีการดำเนินการหลายอย่างที่เป็น O(คอนเทนเนอร์) หรือ O(โหนด) หรือทั้งสองอย่าง ซึ่งจะทำให้แพลตฟอร์มคอนเทนเนอร์แบบดั้งเดิมถึงขีดจำกัดในการปรับขนาด
สำหรับ Kubernetes เป็นตัวอย่าง:
- อัลกอริทึมการจัดกำหนดการคือ O(n x p) สำหรับ n โหนดและ p พ็อด (pods) ในกรณีที่แย่ที่สุด และการจัดกำหนดการจะถูกทำให้เป็นอนุกรม (serialized) โดยค่าเริ่มต้น
- แต่ละพ็อดทำให้เกิดการเขียนหลายครั้งไปยัง etcd (ที่เก็บข้อมูลแบบถาวรส่วนกลางของ Kubernetes) ตลอดอายุการใช้งาน ซึ่งสามารถสร้างปัญหาที่ร้ายแรงภายใต้อัตราการสร้างพ็อดที่สูงหรือการเปลี่ยนแปลงพ็อดที่สูง และ etcd ไม่สามารถแบ่งส่วน (shard) ได้โดยธรรมชาติภายในคีย์สเปซ (keyspace)
- แต่ละโหนดต้องเขียนไปยัง etcd อย่างน้อยหนึ่งครั้งต่อช่วงเวลาการส่งสัญญาณ heartbeat เพื่อแสดงว่ายังทำงานอยู่ ดังนั้นภาระการเขียน etcd พื้นฐานคือ O(โหนด) โดยไม่ขึ้นกับการสร้างพ็อดเลย

การประมาณการไหลการจัดกำหนดการของ Kubernetes พ็อดใหม่จะถูกเขียนไปยัง etcd (ที่เก็บข้อมูลแบบถาวรที่มีความสอดคล้องสูง) โดย API server ตัวจัดกำหนดการ Kubernetes จะคอยดูพ็อดใหม่ที่ยังไม่ถูกกำหนด และกำหนดให้กับโหนดผ่านการเรียกไปยัง API server ซึ่งจะเขียนไปยัง etcd อีกครั้ง หลังจากที่การเขียนนี้เสร็จสมบูรณ์ โหนดจึงจะสามารถเริ่มพ็อดได้
Kubernetes สามารถปรับขนาดได้ แต่ต้องใช้ความพยายามอย่างมาก ในการรันโหนดจำนวนมาก โดยทั่วไป etcd จะต้องถูก เขียนใหม่ หรือ เปลี่ยนใหม่ การรองรับปริมาณงานการจัดกำหนดการที่สูงจำเป็นต้องสร้าง ระบบ scatter-gather ที่ซับซ้อน เพื่อทำการจัดกำหนดการแบบขนาน ในขณะที่ยังคงรักษาแหล่งความจริงเพียงแหล่งเดียวสำหรับสถานะพ็อด การแบ่งส่วนและการทำขนานไม่ใช่เรื่องง่ายโดยค่าเริ่มต้น เนื่องจาก Kubernetes อาศัยความสอดคล้องที่แข็งแกร่ง (strong consistency) เป็นกระดูกสันหลังของการออกแบบ
สถาปัตยกรรมแซนด์บ็อกซ์ดั้งเดิมของ Modal ก็มีปัญหาที่คล้ายกัน เช่นเดียวกับ Kubernetes เราใช้ความสอดคล้องที่แข็งแกร่งตลอดทั้งแบ็กเอนด์ของเรา ดังนั้นการสร้างและจัดกำหนดการแซนด์บ็อกซ์จึงต้องมีการประสานงานทั่วโลก และการเขียน O(แซนด์บ็อกซ์) ไปยัง Postgres ซึ่งเราไม่สามารถแบ่งส่วนได้อย่างง่ายดาย

สถาปัตยกรรมคอนโทรลเพลนแซนด์บ็อกซ์ดั้งเดิมของ Modal เมื่อสร้างแซนด์บ็อกซ์ พวกมันจะถูกวางในคิวและเขียนไปยัง Postgres การจัดกำหนดการเป็นการมองโลกในแง่ดี (optimistic) และรันแบบขนาน โดยต้องมีการประสานงานส่วนกลางเพื่อหลีกเลี่ยงความขัดแย้ง การกำหนดแซนด์บ็อกซ์ให้กับ worker (โหนดคอมพิวต์) ต้องมีการเขียนเพิ่มเติมไปยัง Postgres
เนื่องจากเราไม่ได้สร้างบน Kubernetes เราจึงสามารถปรับขนาดส่วนต่างๆ ของระบบนี้ได้ ตัวอย่างเช่น การจัดกำหนดการถูกทำให้เป็นแบบขนานโดยค่าเริ่มต้น ซึ่งช่วยให้เราบรรลุอัตราการสร้างแซนด์บ็อกซ์แบบพุ่งสูง (burst) ได้สูงมาก แต่เมื่อเราปรับขนาดไปยังโหนดและแซนด์บ็อกซ์จำนวนมากขึ้นเรื่อยๆ เราก็พบคอขวดใหม่ๆ อย่างต่อเนื่องซึ่งเกิดจากการดำเนินการที่เป็น O(แซนด์บ็อกซ์) หรือ O(โหนด) แต่ไม่สามารถปรับขนาดออกได้ง่าย
ตัวอย่างเช่น เรารันเวิร์กโฟลว์แบบถาวร (durable workflow) สำหรับแต่ละแซนด์บ็อกซ์ที่เสร็จสมบูรณ์ ดังนั้นอัตราการเปลี่ยนแปลงแซนด์บ็อกซ์ที่สูงจะสร้างงานในคิว event backlog จำนวนมหาศาล เราจะเจอ RPC ที่ถูกเรียกในอัตรา O(แซนด์บ็อกซ์) ซ้ำแล้วซ้ำเล่า ซึ่งทำให้เกิดปัญหาภาระงานที่ไม่คาดคิดทั่วทั้งระบบของเรา และจำนวนโหนดที่มากมายมหาศาลที่จำเป็นในการรันแซนด์บ็อกซ์จำนวนมากทำให้เกิดปัญหาปลายน้ำหลายประการในการจัดการโหนดและการปรับขนาดอัตโนมัติ ท้ายที่สุด แม้เราจะสามารถแก้ไขปัญหาได้ แต่การปล่อยให้อินสแตนซ์ Postgres ที่ไม่ได้แบ่งส่วนอยู่ในเส้นทางวิกฤต (critical path) ของการสร้างและจัดกำหนดการแซนด์บ็อกซ์ทั้งหมดก็พิสูจน์แล้วว่าเป็นความคิดที่ไม่ดี
ปลดล็อกการปรับขนาดที่ไร้ขีดจำกัด
เราตระหนักได้อย่างรวดเร็วว่าการบรรลุขนาดที่เราต้องการนั้นจำเป็นต้องคิดสถาปัตยกรรมของเราใหม่ตั้งแต่ต้น เราต้องการรันแซนด์บ็อกซ์หลายล้านรายการและสร้างแซนด์บ็อกซ์หลายหมื่นรายการต่อวินาที ซึ่งต้องมีคุณสมบัติการปรับขนาดที่ดีกว่าสิ่งที่มีอยู่มาก แทนที่จะพยายามวิวัฒนาการสิ่งที่เรามี เราเชื่อว่าเส้นทางที่เร็วและสะอาดที่สุดคือเริ่มต้นใหม่
เพื่อปรับให้เหมาะสมกับขนาด เราตัดสินใจว่าทุกอย่างที่รับภาระ O(แซนด์บ็อกซ์) หรือ O(โหนด) จะต้องสามารถปรับขนาดในแนวนอนได้โดยค่าเริ่มต้น เส้นทางการสร้างแซนด์บ็อกซ์ควรจะง่ายที่สุดเท่าที่จะเป็นไปได้ และทุกสิ่งทุกอย่างควรเป็นรอง วิธีแก้ปัญหาที่เราได้นั้นแตกต่างอย่างเห็นได้ชัดจากระบบที่มีอยู่ เราละทิ้งการประสานงานส่วนกลางทุกประเภทโดยสิ้นเชิง และแลกความสอดคล้องระดับโลก (global consistency) กับความสามารถในการปรับขนาดและประสิทธิภาพในทุกที่บนเส้นทางวิกฤตสำหรับการรันและสร้างแซนด์บ็อกซ์ นี่คือวิธีการทำงาน:
- แทนที่จะมีตัวจัดกำหนดการตัวเดียวแบบอนุกรม เราใช้กลุ่มเซิร์ฟเวอร์จัดกำหนดการ (scheduling servers) ที่จัดการคำขอสร้างแซนด์บ็อกซ์พร้อมกัน ในการจัดการคำขอสร้าง เซิร์ฟเวอร์จัดกำหนดการจะรันอัลกอริทึมการจัดกำหนดการที่รวดเร็วกับข้อมูลที่แคชไว้ในหน่วยความจำ ผลลัพธ์คือการจัดกำหนดการสามารถปรับขนาดในแนวนอน และดูเหมือนการปรับสมดุลโหลด (load balancing) มากกว่าการจัดกำหนดการคอนเทนเนอร์แบบดั้งเดิม
- แทนที่จะมีที่เก็บข้อมูลแบบถาวรส่วนกลางที่ทำหน้าที่เป็นแหล่งความจริงสำหรับสถานะแซนด์บ็อกซ์และ worker ซึ่งเป็นวิธีการทำงานของแพลตฟอร์มคอนเทนเนอร์ส่วนใหญ่ worker ทุกตัวในระบบใหม่ของเราคือแหล่งความจริงของตัวเอง Worker เผยแพร่สถานะของตนเป็นระยะไปยัง Redis stream เซิร์ฟเวอร์จัดกำหนดการจะบริโภคสถานะนี้แบบอะซิงโครนัสและใช้เพื่อตัดสินใจจัดกำหนดการ เมื่อเซิร์ฟเวอร์จัดกำหนดการตัดสินใจว่า worker ตัวใดที่จะสร้างแซนด์บ็อกซ์ มันจะติดต่อ worker โดยตรงผ่าน RPC เพื่อขอให้สร้างแซนด์บ็อกซ์ Worker จะยอมรับคำขอจัดกำหนดการหากมีทรัพยากรว่าง หรือมิฉะนั้นจะปฏิเสธ
- เราไม่มีที่เก็บข้อมูลในเส้นทางวิกฤตของการสร้างแซนด์บ็อกซ์เลย ซึ่งช่วยปรับปรุงความสามารถในการปรับขนาดและความน่าเชื่อถือ แม้ว่าเราจำเป็นต้องเขียนเมทาดาทาและผลลัพธ์ของแซนด์บ็อกซ์ไปยังที่เก็บข้อมูลแบบถาวร แต่เราทำเช่นนั้นเป็นส่วนใหญ่แบบอะซิงโครนัส
- นอกเหนือจากการสร้างแซนด์บ็อกซ์แล้ว เราไม่มี RPC ที่เป็น O(แซนด์บ็อกซ์) Worker จะรวมข้อความควบคุมสำหรับหลายแซนด์บ็อกซ์เข้าด้วยกันใน RPC เดียว ตามแนวคิดจาก data-oriented design

การออกแบบสุดท้ายของเรา ครั้งแรกที่เราวาดบนไวท์บอร์ด

เส้นทางการสร้างแซนด์บ็อกซ์ในสถาปัตยกรรมแซนด์บ็อกซ์ v2 ของ Modal คำขอสร้างแซนด์บ็อกซ์จะถูกจัดการโดยเซิร์ฟเวอร์จัดกำหนดการที่ปรับขนาดในแนวนอน ซึ่งจะเลือก worker ด้วยอัลกอริทึมการปรับสมดุลโหลดในหน่วยความจำที่รวดเร็ว และติดต่อ worker (โหนดคอมพิวต์) โดยตรงเพื่อสร้างแซนด์บ็อกซ์ ออบเจ็กต์แซนด์บ็อกซ์ถูกเก็บไว้ใน Redis แต่ไม่ได้อยู่ในเส้นทางวิกฤต
ผลลัพธ์คือ เส้นทางการสร้างแซนด์บ็อกซ์ต้องใช้เพียงสองฮ็อปเครือข่าย (network hops) และการดำเนินการ CPU ราคาถูกหนึ่งครั้ง ไม่มีคอขวดส่วนกลางหรือต้นทุนการประสานงาน ไม่มีจุดล้มเหลวเพียงจุดเดียว (single points of failure) และดังนั้นจึงไม่มีเพดานในทางปฏิบัติสำหรับขนาดแซนด์บ็อกซ์รวมหรือปริมาณงานการสร้างแซนด์บ็อกซ์ เราสามารถเพิ่มตัวจัดกำหนดการหรือ worker ได้ตามต้องการ คอขวดที่ใกล้ที่สุดคือ worker ทั้งหมดเผยแพร่สถานะไปยัง Redis stream เดียว แต่การทดสอบโหลดชี้ให้เห็นว่าสิ่งนี้ยังคงใช้งานได้จนถึง worker มากกว่า 100,000 ตัว และเราไม่ได้พึ่งพาลำดับในสตรีมอยู่แล้ว ดังนั้นจึงง่ายที่จะเพิ่มสตรีมมากขึ้นเท่านั้น โดยการออกแบบ เราหลีกเลี่ยงปัญหาที่ทำให้โซลูชันที่มีอยู่ไม่สามารถปรับขนาดได้
การสร้างโซลูชันนี้ไม่ใช่เรื่องง่าย! กระบวนการพัฒนาทั้งหมดใช้เวลาทำงานหลายเดือน ครอบคลุมระบบหลักส่วนใหญ่ในแบ็กเอนด์ของเรา เราใช้เวลาหลายชั่วโมงบนไวท์บอร์ด พวกเราสี่คนย้ายไปพักที่บ้านเช่าในไมอามีบีชเพื่อสร้างต้นแบบของระบบใหม่ที่เราต้องการ โดยไม่มีสิ่งรบกวน เราใช้เวลาแปดวันในการเขียนโค้ดจนร่างกายไม่ไหว เล่นหมากรุกเร็วเพื่อพักฟื้น กระโดดลงทะเล แล้วกลับไปที่โค้ดทันที ต่อสู้เพื่อให้ระบบใหม่ของเราสะอาดและใช้งานได้

วิศวกรที่ดีที่สุดของเรากำลังพักผ่อนที่ไมอามีบีช
เมื่อเราได้ชิ้นส่วนหลักทำงานได้ (และกลับมาที่นิวยอร์ก) เรายังต้องนำฟีเจอร์แซนด์บ็อกซ์ทุกตัวและการสังเกตการณ์แซนด์บ็อกซ์ทั้งหมดกลับมาใช้ใหม่บนระบบใหม่ของเรา โปรเจกต์นี้ยังจำเป็นต้องเปลี่ยนแปลงสแต็กการจัดการ worker หลักของเรา เช่นเดียวกับรันไทม์คอนเทนเนอร์ของเรา ตัวอย่างเช่น ปัญหาที่น่าสนใจอย่างหนึ่งที่เราเจอคือ ตัวจัดกำหนดการแซนด์บ็อกซ์ใหม่ของเราสามารถส่งคอนเทนเนอร์ไปยัง worker ได้ เร็วมาก จนทำให้คอนเทนเนอร์จำนวนมากที่เริ่มต้นพร้อมกันแย่งชิงล็อค rtnl ในเคอร์เนล Linux เมื่อตั้งค่ากฎเครือข่ายคอนเทนเนอร์ และใช้เวลาหลายสิบวินาทีในการเริ่มต้น ดังนั้นเราจึงต้องเปลี่ยนการตั้งค่าเครือข่ายคอนเทนเนอร์สำหรับแซนด์บ็อกซ์ เพื่อให้ worker ของเราไม่ระเบิดเมื่อถูกถาโถมด้วยการสร้างแซนด์บ็อกซ์
ประสิทธิภาพของเราเป็นอย่างไร
เราทดสอบระบบของเราโดยเปิดแซนด์บ็อกซ์ 1 ล้านรายการให้เร็วที่สุดเท่าที่จะทำได้ ในภาพรวม เราสามารถสร้างแซนด์บ็อกซ์หนึ่งล้านรายการได้ภายในเวลาไม่ถึงนาที โดยที่คอขวดหลักคือการทดสอบเอง เวลาในการโต้ตอบได้ (time-to-interactivity) ของแซนด์บ็อกซ์แต่ละรายการยังคงต่ำอย่างสม่ำเสมอ และเราไม่เห็นการเสื่อมประสิทธิภาพที่แท้จริงตามขนาด

การกระจายและ eCDF ของคำขอสร้างแซนด์บ็อกซ์ คำขอสร้างแซนด์บ็อกซ์จะส่งกลับเมื่อเซิร์ฟเวอร์จัดกำหนดการของเรากำหนดแซนด์บ็อกซ์ให้กับ worker สำเร็จ และมันได้เริ่มต้นทำงานแล้ว
เราเชื่อว่านี่เป็นสิ่งที่คาดหวังได้ เนื่องจากการออกแบบของเรา ไม่มีการประสานงานในเส้นทางการจัดกำหนดการ ดังนั้นการจัดกำหนดการควรจะรวดเร็วมากโดยไม่ขึ้นกับความพร้อมกันและขนาด ในความเห็นของเรา เราไม่มีข้อจำกัดที่ร้ายแรงต่อการจัดกำหนดการแซนด์บ็อกซ์พร้อมกันหรือขนาดนอกเหนือจากความจุที่มีอยู่ และการจัดการความจุเป็น สิ่งที่เราทำได้ดีอยู่แล้ว

Scatterplot ของเวลาเริ่มต้นแซนด์บ็อกซ์ 10,000 รายการจากการทดสอบ 1M แซนด์บ็อกซ์ของเรา สุ่มเลือกจาก 1M แซนด์บ็อกซ์
เวลาเริ่มต้นแซนด์บ็อกซ์บนระบบใหม่ของเรา (ความหน่วงจากเวลาที่ไคลเอ็นต์พยายามสร้างแซนด์บ็อกซ์ครั้งแรก ไปจนถึงเวลาที่แซนด์บ็อกซ์สามารถรันโค้ดผู้ใช้) น้อยกว่าครึ่งวินาทีที่ค่ามัธยฐาน และยังคงแข็งแกร่งในระดับขนาดใหญ่ นอกจากนี้ยัง เร็วกว่าระบบเก่าของเราอย่างมาก สาเหตุหลักมาจากการจัดกำหนดการที่เร็วกว่ามาก — ตอนนี้ใช้เวลาเพียงสิบมิลลิวินาทีเท่านั้น ส่วนท้ายยาว (long tail) ของความหน่วงนั้นยาวกว่าที่เราต้องการเล็กน้อย เราถือว่าส่วนใหญ่ของท้ายนี้เกิดจากการขัดแย้งของเคอร์เนลและเครือข่าย (รวมถึงการขัดแย้งล็อค rtnl ที่กล่าวถึงก่อนหน้านี้) เมื่อแซนด์บ็อกซ์จำนวนมากเริ่มต้นพร้อมกันบน worker เดียวกัน และเรากำลังทำงานเพื่อลดมัน นอกจากนี้ ส่วนท้ายในระดับขนาดใหญ่ (tail at scale) นั้นมีจริง เราคาดว่าสิ่งนี้จะดีขึ้นเมื่อเราปรับแต่งเส้นทางการเริ่มต้นคอนเทนเนอร์
โดยรวมแล้ว เราพอใจกับตัวเลขประสิทธิภาพเหล่านี้มาก เมื่อเอเจนต์เข้ายึดครองโลก แน่นอนว่าเราสามารถปรับขนาดไปพร้อมกับพวกมันได้
ลองดูด้วยตัวคุณเอง
เร็วๆ นี้ ระบบใหม่นี้จะรองรับการจัดกำหนดการแซนด์บ็อกซ์ทั้งหมดที่ Modal แต่มันก็พร้อมใช้งานในรุ่น Beta แล้ว คุณสามารถเลือกใช้ได้โดย การเปลี่ยนแปลงโค้ดของคุณเพียงเล็กน้อย หากคุณต้องการรันแซนด์บ็อกซ์จำนวนมาก ลองดูสิ แล้วคุยกับเรา!
กิตติกรรมประกาศ
หลายคนมีส่วนร่วมทั้งเลือด หยาดเหงื่อ และน้ำตากับโปรเจกต์นี้ POC ที่ไมอามีของเราสร้างโดย Colin Weld (ฉัน), Daniel Shaar, Walter Tang และ Gleb Posobin จากนั้นนำไปสู่การผลิตโดย Walter, Colin, Connor Adams, Akshay Balwally, Tom Wildenhain, Scott Hao และ Taylor Baldwin





