ชีวิตสั้นเกินกว่าจะใช้กับเทอร์มินัลที่ช้า
(mijndertstuij.nl)- ความเร็วของเทอร์มินัล ที่ใช้งานทั้งวันมีผลโดยตรงต่อประสิทธิภาพการทำงาน และเมื่อความหน่วงเล็กน้อยจากการเปิดแท็บใหม่ การพิมพ์ และการเติมคำอัตโนมัติสะสมกันวันละหลายร้อยครั้ง ก็จะกลายเป็นความไม่มีประสิทธิภาพ
- ปรับปรุงให้ interactive shell ที่โหลดครบถ้วนพร้อมระบบเติมคำอัตโนมัติ, syntax highlighting, autosuggestion, fzf และ direnv เริ่มต้นได้ในเวลาประมาณ 30 มิลลิวินาที และเปิดแท็บใหม่ได้แทบจะทันที
- เคล็ดลับสำคัญที่สุดคือ ไม่ใช้เฟรมเวิร์กและตัวจัดการปลั๊กอิน อย่าง oh-my-zsh หรือ prezto แต่เลือก git clone ปลั๊กอินเพียง 3 ตัวด้วยตัวเองแล้ว source จาก
.zshrc - ลดความหน่วงทั้งตอนเริ่มต้น ตอนแสดงพรอมป์ต และตอนรับอินพุตให้เหลือน้อยที่สุดด้วยการแคช
compinit, lazy-loading, asynchronous prompt และเทอร์มินัลที่เร่งด้วย GPU - การปรับแต่งส่วนใหญ่ไม่ใช่การเพิ่มอะไรเข้าไป แต่คือการ ตัดสิ่งที่ไม่จำเป็นออก และหัวใจสำคัญคือการเพิ่มเฉพาะสิ่งที่ใช้จริงอย่างตั้งใจ
ทำไมต้องมีเทอร์มินัลที่เร็ว
- งานแทบทุกอย่างเกิดขึ้นในเทอร์มินัล และมีการใช้ Git, kubectl, tmux, การเชื่อมต่อ ssh ไปยังเซิร์ฟเวอร์ตลอดทั้งวัน
- เครื่องมือที่ใช้บ่อยขนาดนี้จึงต้องเร็ว และเราจะรู้สึกถึงความหน่วงจากการเปิดแท็บใหม่ การพิมพ์ และการกด Tab เพื่อเติมคำวันละหลายร้อยครั้ง
- ความหน่วงเล็ก ๆ ที่สะสมเช่นนี้ก็เหมือนกับ ตายเพราะโดนกรีดพันแผล (death by a thousand cuts)
ผลการวัดความเร็วการเริ่มต้นของเชลล์
- หลังการปรับแต่ง เชลล์เริ่มต้นได้ในเวลาประมาณ 30 มิลลิวินาที โดยใช้คำสั่งวัด
for i in {1..5}; do /usr/bin/time zsh -i -c exit; done - interactive shell ที่ครบทั้งระบบเติมคำอัตโนมัติ, syntax highlighting, autosuggestion, fzf และ direnv โหลดเสร็จในเวลา สั้นกว่าหนึ่งเฟรมของ 30fps
- ไม่ได้มีโปรเจ็กต์ปรับจูนครั้งใหญ่ แต่เป็นผลจาก นิสัย ที่สั่งสมมาหลายปีในการทำให้เชลล์เรียบง่ายและคงความเร็วไว้
- การตั้งค่าทั้งหมดเปิดเผยไว้ใน คลัง dotfiles
ไม่มีเฟรมเวิร์ก
- ประโยชน์ที่มากที่สุดมาจากสิ่งที่ไม่มีอยู่ นั่นคือไม่ใช้ oh-my-zsh, prezto หรือ plugin manager
- แม้จะใช้จริงเพียงราว 5% จากปลั๊กอินและธีมนับร้อยของ oh-my-zsh แต่ทุกครั้งที่เปิดเชลล์ก็ยังต้องจ่าย ต้นทุนด้านเวลาและทรัพยากรคอมพิวต์ ให้กับอีก 95% ที่เหลือ
- plugin manager ยังเพิ่มโอเวอร์เฮดเข้าไปอีก
- ใช้ปลั๊กอินอยู่เพียง 3 ตัวเท่านั้น และให้สคริปต์ติดตั้ง git clone ครั้งเดียว แล้วค่อย source จาก
.zshrcfzf-tab,zsh-autosuggestions,zsh-syntax-highlighting- ไม่มี plugin manager ที่ต้องมาวิเคราะห์ dependency ตอนเริ่มต้น และการ source ไฟล์ที่อยู่บนดิสก์แล้วแทบไม่มีต้นทุนเลย
การแคชระบบเติมคำอัตโนมัติ
compinitเป็นหนึ่งในขั้นตอนที่กินเวลามากที่สุดใน.zshrcทั่วไป เพราะโดยปกติจะทำ security audit กับไฟล์เติมคำอัตโนมัติทั้งหมดทุกครั้งที่เปิดเชลล์- วิธีแก้คือรันเต็มรูปแบบเฉพาะเมื่อแคช (
.zcompdump) เก่ากว่า 24 ชั่วโมง ส่วนกรณีอื่นให้ข้ามการตรวจด้วย-C- glob qualifier
#qNmh-24หมายถึง "มีอยู่และถูกแก้ไขภายใน 24 ชั่วโมงล่าสุด" - จึงรัน
compinitแบบเต็มวันละครั้ง และเวลาอื่นใช้การอ่านจากแคชแทน
- glob qualifier
การโหลดแบบหน่วงเวลา (Lazy-loading)
- nvm เป็นหนึ่งในสาเหตุที่ขึ้นชื่อที่สุดของการทำให้เชลล์เริ่มช้า และหาก source ทันทีตอนเริ่ม ก็เพิ่มเวลาได้ง่าย ๆ ถึง 0.5 วินาที
- ไม่ใช่ทุกเชลล์ที่จะต้องใช้ nvm จึงต้องมีมันเมื่อพิมพ์
nvmเท่านั้น โดยห่อไว้ในฟังก์ชันที่แทนที่ตัวเองเมื่อถูกเรียกใช้ครั้งแรก- การเรียก
nvmครั้งแรกจะลบ stub ทิ้ง source nvm จริง (พร้อม--no-useเพื่อไม่ให้ตีความเวอร์ชัน node ด้วย) แล้วส่งต่ออาร์กิวเมนต์
- การเรียก
- ระบบเติมคำอัตโนมัติของ kubectl ก็ใช้แนวทางเดียวกัน เพราะมันต้องเรียกไบนารี
kubectlเพื่อสร้างสคริปต์เติมคำอัตโนมัติ จึงโหลดหลังจากใช้งานครั้งแรกจริงเท่านั้น - เครื่องมือทุกตัวที่แนะนำให้ใส่
eval "$(tool init zsh)"ลงใน.zshrcล้วนต้อง fork process ตอนเริ่มต้นแล้วประมวลผลเอาต์พุต จึงเป็น ตัวเลือกสำหรับทำ lazy-loading - direnv และ fzf นั้นเร็วและถูกใช้บ่อย จึงยังคงโหลดทันทีไว้ และต้องตัดสินอย่างเข้มงวดว่าอะไรคือสิ่งที่ใช้บ่อยจริง ๆ
พรอมป์ตแบบไม่บล็อก
- พรอมป์ตที่รัน
git statusแบบ synchronous จะเกิดความหน่วงในรีโพซิทอรีที่ใหญ่พอสมควร และความหน่วงนี้จะรับรู้ได้ทุกครั้งที่กด Enter จึงอาจแย่กว่าการเริ่มต้นช้าเสียอีก - ใช้ pure ซึ่งเรนเดอร์พรอมป์ตได้ทันที และค่อยเติมข้อมูล git แบบ asynchronous เมื่อพร้อม
- เคยลองเปลี่ยนไปใช้
vcs_infoที่มากับ zsh ชั่วคราว แต่พฤติกรรมแบบ asynchronous ของ pure ดีกว่า - จะเขียน async git status เองในพรอมป์ตก็ได้ แต่ pure ก็ห่อการทำงานลักษณะนี้มาให้เหมาะกับงานอยู่แล้ว
ตัว terminal emulator เอง
- การเริ่มต้นเชลล์เป็นเพียงครึ่งหนึ่งของเรื่อง เพราะตัว emulator เองก็เพิ่ม input latency ได้เช่นกัน
- ใช้ Ghostty ซึ่งเป็น native terminal ที่เร่งด้วย GPU และมีการตั้งค่าเพียง 7 บรรทัด
- เมื่อนำมาจับคู่กับ alias
tmux new -A -s main(t) หน้าต่างเทอร์มินัลใหม่ก็จะพากลับเข้าสู่เซสชันเดิมได้ทันที
วิธีวัดประสิทธิภาพของเชลล์ตัวเอง
- สามารถวัดได้เองจากในเทอร์มินัลว่ามีการใช้เวลาไปตรงไหน โดยความหน่วงที่ควรตรวจมี 3 อย่างคือ เวลาเริ่มต้น, ความหน่วงของพรอมป์ต, ความหน่วงของอินพุต
- การวัดพื้นฐานคือรัน
time zsh -i -c exitหลายครั้ง โดยครั้งแรกจะช้ากว่าเสมอเพราะเป็น cold cache- ต่ำกว่า 100ms ถือว่าโอเค, ต่ำกว่า 50ms ถือว่ายอดเยี่ยม, และมากกว่า 500ms แปลว่ายังมีจุดให้ปรับ
- หากต้องการสถิติที่แม่นยำขึ้น ให้ใช้ hyperfine:
hyperfine --warmup 3 'zsh -i -c exit' - ใช้ profiler ที่มีมาใน zsh
- ใส่
zmodload zsh/zprofที่บนสุดของ.zshrcและzprofที่ล่างสุด แล้วมันจะพิมพ์ตารางเรียงตามเวลาที่ใช้ - รายการบน ๆ มักเป็น
compinit, การ sourcenvm.sh, และeval "$(...)"ให้แก้จากรายการบนสุดแล้วรันวัดใหม่ซ้ำไป - เมื่อตรวจเสร็จแล้วให้ลบสองบรรทัดนี้ออก
- ใส่
- ถ้า zprof ยังไม่พอ ให้ไล่ดูการเริ่มต้นทั้งหมดด้วย timestamp:
zsh -ixc exit 2>&1 | ts -i '%.s' | sort -rn | head -20- หรือกำหนด
PS4='+%D{%s.%6.}: 'แล้วรันzsh -ixc exit 2> startup.logจากนั้นดูช่วงที่ตัวเลขกระโดดมากระหว่างบรรทัด
- หรือกำหนด
- บางครั้งการเริ่มต้นอาจเร็ว แต่การ redraw ของพรอมป์ตกลับช้า ให้
cdเข้ารีโพซิทอรี Git ที่ใหญ่ที่สุดแล้วกด Enter ถ้ามีช่วงหน่วงก่อนพรอมป์ตถัดไปจะขึ้น แปลว่าพรอมป์ตกำลังทำงานแบบ synchronous- ทางเลือกคือเปลี่ยนไปใช้พรอมป์ตแบบ asynchronous หรือตัดฟีเจอร์ Git ออก
สรุป
- การปรับแต่งส่วนใหญ่เป็นเรื่องของการตัดสิ่งที่ไม่จำเป็นออก และ หัวใจสำคัญคือทำอย่างตั้งใจแล้วเพิ่มเฉพาะสิ่งที่จะใช้จริงเท่านั้น
- เมื่อทำเช่นนี้ เซสชันหลายสิบครั้งที่เปิดในแต่ละวันจะเปิดได้แทบจะทันที และเทอร์มินัลจะให้ความรู้สึกไม่ใช่แอปที่ต้องรอ แต่เป็น ส่วนขยายของความคิด
- สำหรับเครื่องมือที่ใช้ตลอดทั้งวัน ความเร็วระดับนี้เป็นสิ่งที่ประนีประนอมไม่ได้
- การตั้งค่าทั้งหมดข้างต้นเปิดเผยไว้ใน คลัง dotfiles
1 ความคิดเห็น
ความเห็นจาก Lobste.rs
พูดให้เคร่งครัดแล้ว ส่วนใหญ่สิ่งที่หมายถึงไม่ใช่ terminal แต่เป็น shell
ควรใช้เครื่องมือที่มีค่าเริ่มต้นดี ๆ ไปเลย ดังนั้นก็ใช้ fish ได้
ชอบตรงที่มีของอย่าง tab completion แบบสมัยใหม่ที่เลือกด้วยปุ่มลูกศรได้มาให้เป็นค่าเริ่มต้น ส่วนบนเครื่องส่วนตัวยังใช้ ZSH อยู่ แต่แค่นั้นเพราะยังไม่มีเวลาไปจัดการค่า Nix และ home manager
ถ้าเป็น shell ที่มีค่าเริ่มต้นสมเหตุสมผลและมี completion ในตัวที่เร็ว โดยไม่ต้องทิ้งหรือเขียนเครื่องมือที่อิง bash ใหม่ก็คงดี
บางทีก็สงสัยว่าอะไรอย่าง non-blocking prompt หรือ terminal ที่ใช้ OpenGL มันคุ้มจริงไหม เทียบกับการใช้ xterm ที่มีแค่
PS1="\W: "แถมยังเร็วมากและมีข้อดีตรงที่เป็น “มาตรฐาน” ดังนั้นบั๊กที่เหลืออยู่ก็มักเล็กน้อย หรือโปรแกรมที่รันอยู่ข้างในก็มักถือว่านั่นคือพฤติกรรมปกติ
สุดท้ายเลยกลับมาใช้ xterm อีกครั้ง
การเริ่มต้น zsh เดิมทีก็เร็วมากอยู่แล้ว มันจะช้าก็ต่อเมื่อผู้ใช้ทำให้มันช้าเอง
แค่ไม่ยัดของที่ตัวเองไม่เข้าใจเข้าไปเยอะ ๆ ซึ่งรวมถึงไลบรารีที่ชอบเรียกตัวเองว่า “minimal” แต่กลับรันคำสั่งนับร้อยทุกครั้งที่สร้าง prompt
การตั้งค่า zsh ของผมเป็นไฟล์ไม่กี่ร้อยบรรทัดที่ค่อย ๆ พัฒนามาตั้งแต่ยุค 90 และผมเข้าใจทุกบรรทัดรวมถึงรู้ว่ามันอยู่ไปทำไม
ไม่เคยตั้งใจทำให้มันเร็วเป็นพิเศษ แต่ก็ยังเริ่มได้ใน 20ms และถ้ามีการเปลี่ยนแปลงโง่ ๆ ที่ทำให้มันช้าลง ผมก็จะสังเกตได้ทันทีและแก้ได้
ไม่ชอบที่ยังมีคนใช้ benchmark ที่พัง ๆ อย่าง
time zsh -i -c exitกันอยู่มันวัดสิ่งที่ผิดเป้าไปเต็ม ๆ และ plugin manager ของ zsh บางตัวก็ไป optimize ให้กับตัวเลขไร้ประโยชน์นี้ ทั้งที่ต้องแลกกับความหน่วงจริงตอนเริ่ม shell
ใน zsh-bench มีส่วนที่อธิบายว่าทำไม benchmark นี้ถึงไม่มีความหมาย: https://github.com/romkatv/zsh-bench#how-not-to-benchmark
ตัวชี้วัดอย่างความหน่วงก่อน prompt แรกหรือความหน่วงของการพิมพ์ที่ zsh-bench วัดนั้นมีประโยชน์กว่ามาก
ตอนแรกนึกว่าจะพูดถึงบั๊กของ terminal แบบเร่งด้วย GPU เสียอีก เลยดีใจที่ไม่ใช่
การแคช completion เป็นทิปที่ดี และผมใช้ zsh บนเครื่อง Mac ของบริษัทที่แค่คิดจะเปิดแท็บใหม่ก็เห็นลูกบอลหมุนแล้ว หวังว่าจะช่วยได้
สำหรับ completion ของ kubectl ก็สงสัยว่าส่วนที่ช้าคือการสร้าง completion หรือการโหลดมันเข้าไป และถ้าเป็นอย่างแรก การบันทึกลงไฟล์แล้วค่อยโหลดจะช่วยลดเวลาเริ่มต้นได้ไหม
ใน
jjเขาก็ทำแบบนั้น และพอเปลี่ยนไปใช้jjผมก็เลิกใช้ prompt ที่รันgit statusไปด้วยน่าเสียดายที่ผู้เขียนไม่ได้แสดงเวลาของตัวเองด้วย ไม่งั้นผมจะได้รู้ว่า 0.287 วินาทีของผมถือว่าปกติหรือช้า
พอวัดทีหลังก็ได้ว่า
.bashrcที่แทบว่างใช้ 0.007 วินาที, หลังใส่คีย์ไบน์ดิง skim เป็น 0.043 วินาที, หลังใส่ mise เป็น 0.115 วินาที, หลังใส่ completion ของ jj เป็น 0.186 วินาที และถ้าอ่าน/etc/bashrcด้วยจะเป็น 0.294 วินาที ก็เลยดูเหมือนยังมีที่ให้ปรับปรุงtime shell -c exitแบบเดียวกัน ของผมได้ราว 50msเวลาใช้เครื่อง Linux ของคนอื่น สิ่งที่น่ารำคาญที่สุดคือแอนิเมชันไร้ประโยชน์ที่มีอยู่เต็มไปหมด
บนคอมผมเอง กดคีย์ลัดแล้วหน้าต่าง terminal แทบจะเปิดทันที บางครั้งแค่เห็นการกะพริบสั้น ๆ ระหว่างหน้าต่างกับ prompt
เพราะงั้นการทดสอบแบบ end-to-end ทั้งหมด ตั้งแต่เปิดหน้าต่าง ทำอะไรบางอย่างใน shell แล้วปิด จึงสำคัญ และถ้าทำ
time mytermแล้วไปกด Ctrl+D ปิดหน้าต่าง ก็ไม่เคยเกิน 0.120 วินาทีเลยพอเอาแอนิเมชันและการ compositing ที่ไม่จำเป็นออก ก็ทำอะไรได้มากขึ้น และตอนเทียบความต่างของสเปรดชีตสองไฟล์ ผมก็แค่ขยายสองหน้าต่างให้เต็มจอแล้วสลับดูเร็ว ๆ ด้วยคีย์ลัดม้วนหน้าต่างขึ้น ก็เห็นความต่างได้ทันที
ถ้าทำแบบเดียวกันบน Windows พร้อมแอนิเมชันของ Excel มันชวนเสียสมาธิเกินไป
แม้ใช้การตั้งค่าว่าง
zsh -i -c exitก็เฉลี่ย 129.8ms แล้ว และการตั้งค่าทั้งหมดก็ใช้เวลาราว 250ms ใกล้เคียงกันการแคช compinit ช่วยลดได้เฉลี่ยราว 5ms แต่เพราะอาจทำให้ completion หายได้ ผมเลยมองว่าไม่ค่อยคุ้มแรง
ช่วงหลัง การเริ่มต้น zsh ช้าจนแทบเหมือนค้าง และแม้จะหาสาเหตุที่แน่ชัดไม่ได้ แต่ยืนยันได้ว่า compinit กินเส้นทางวิกฤตเกือบทั้งหมด
ผมเลยทำแคชด้วยวิธีที่แทบเหมือนกับที่บทความเสนอและแก้อาการช้าได้ และพอเห็น glob qualifier เท่ ๆ นั้นก็รู้สึกว่าวิธีของตัวเองควรปรับปรุงบ้าง
ไม่เคยรู้มาก่อนว่ามีความสามารถแบบนั้นอยู่ และพูดตรง ๆ ว่ามันดูน่าสงสัยนิดหน่อย แต่ก็ยังจะใช้
ก่อนหน้านี้ผมใช้วิธีค่อนข้างบ้าน ๆ อย่าง
date -Idตอนสร้าง path ปลายทางผมชอบเครื่องมือที่ตั้งค่าด้วยภาษาการเขียนโปรแกรมเต็มรูปแบบอย่าง zsh เพราะสามารถทำฟีเจอร์อย่างการแคชเองได้โดยไม่ต้องรอผู้เขียนเพิ่มให้
ใช้ zsh มาเกือบ 20 ปีแล้วแต่ไม่เคยใช้ framework หรือ plugin manager เลย และดูเหมือนของพวกนี้จะถูกใช้เพื่อการตกแต่งเป็นหลัก
ผมโชคดีที่ไม่สนใจความสวยงามของสภาพแวดล้อมคอมพิวเตอร์ prompt ที่ทำเองก็เรียบง่าย เล็ก ให้ข้อมูล แต่ไม่หวือหวาเลย และใช้ธีม terminal เริ่มต้นพื้นหลังสีดำ
shell หลายอินสแตนซ์อาจทำงานเดียวกันพร้อมกันได้ และผมเจอบ่อยเวลารันอินสแตนซ์แบบขนานสำหรับการฝึกบน tmux
อีกทั้งยังอาจแชร์ home directory ข้ามหลายโฮสต์ โดยเฉพาะระหว่างคอนเทนเนอร์ สุดท้ายผมเลยจัดการมันด้วยวิธีที่มีทั้ง lock file, การตรวจวันหมดอายุ และเงื่อนไขสำหรับ
zcompileน่าเสียดายที่การตั้งค่า fish ก็ดูเหมือนจะค่อย ๆ ไหลไปทางเดียวกัน และวันจันทร์ช่วงพักผมว่าจะลอง profile ดูว่าพวกเทคนิค lazy loading มีประโยชน์กับกรณีของผมจริงไหม
เวลาที่ช้าส่วนใหญ่น่าจะมาจากโมดูล git ของ Starship แต่ก็ยังมี alias และ helper function อีกพอสมควรที่น่าจะทำ lazy loading ได้
ใน Emacs ผมเตรียม staging shell ในเบื้องหลังไว้ล่วงหน้ามานานแล้ว
การเปิด terminal ก็คือเปิดหน้าต่างใหม่ไปยังบัฟเฟอร์นั้นแล้วเปลี่ยนชื่อ จากนั้นก็ fork thread เพื่อเตรียม shell รอบถัดไปไว้
เลยไม่มีความหน่วงตอนเริ่ม
จำได้ว่าเมื่อก่อนเคยพยายามฝืนทำทางออกนอก Emacs ด้วย reptyr เหมือนกัน แต่สุดท้ายก็ไม่ได้ใช้ต่อ และก็จำเหตุผลไม่ได้แล้ว
https://github.com/nelhage/reptyr
ผมก็ลองเช็กแบบคล้าย ๆ กัน แล้วพบว่า
zsh-abbrกินเวลาเริ่มต้นไปประมาณ 100ms แต่ระดับนั้นผมรับได้ถึงจะลดได้ทีละ 10ms จากหลายจุด แต่พอนึกถึงฟังก์ชันที่ต้องเสียไปก็ไม่คุ้มอยู่ดี
ผมอยู่กับเวลาเริ่มต้นราว 300ms ได้ มันเร็วพอแล้ว และก็ไม่ได้มีกรณีที่ต้องเปิด terminal รัว ๆ หรือพิมพ์ทันทีบ่อยขนาดนั้น
ถึงอย่างนั้นบทความก็ดี ทำให้รู้จัก
hyperfineและได้กลับไปเปิดดูไฟล์เริ่มต้นของ zsh หลายไฟล์ขอบคุณบทความนี้ที่ทำให้ผมได้แก้ zshrc ที่ผัดวันมานาน ตอนนี้ลดลงมาเหลือ 80ms แล้ว รู้สึกดีมาก
ชีวิตผมยาวพอจะทนกับ terminal ช้า ๆ ได้ และบางทีก็อยากให้ terminal ช้ากว่านี้อีกหน่อย
เช่น ถ้ามี ดีเลย์ 5 วินาที เป็นค่าเริ่มต้นก่อนรันจริงบน root console เพื่อให้มีเวลายกเลิกการพิมพ์ผิดด้วย Ctrl+C ก็คงช่วยประหยัดวันเวลาในวัยหนุ่มอันหุนหันของผมไปได้หลายวัน