ตอนดูค่าใน php.ini ของ server เรามักเจอกลุ่ม opcache.* ยาวเป็นสิบบรรทัด บางเครื่องมี opcache.jit=tracing ที่ใครสักคนเปิดไว้เพราะอ่านเจอว่า PHP 8 มี JIT แล้วเร็วขึ้น โพสต์นี้อธิบายว่า OPcache กับ JIT ทำอะไร ต่างกันตรงไหน ค่าไหนที่ควรตั้งให้แอป Laravel และเรื่องที่อาจเจอเมื่อใช้งานจริง
PHP แปลไฟล์ใหม่ทุก request ถ้าไม่มี OPcache
ทุกครั้งที่มี request เข้ามา PHP ต้องอ่านไฟล์ .php ทุกไฟล์ที่แอปใช้ แปลงเป็น opcode ซึ่งเป็นคำสั่งระดับล่างที่ Zend Engine เข้าใจ แล้วจึงรัน opcode นั้น พอ request จบ opcode ก็ถูกทิ้ง request ถัดไปเริ่มแปลใหม่ทั้งหมด
แอป Laravel หนึ่งตัวมีไฟล์ PHP เยอะกว่าที่คิด เว็บนี้รวม vendor, app, config และ routes มีราว 16,800 ไฟล์ แม้ request หนึ่งจะใช้แค่บางส่วน การแปลไฟล์หลายร้อยไฟล์ซ้ำทุก request ก็เป็นงานที่เปลืองเปล่า ๆ
OPcache แก้ตรงนี้ เก็บ opcode ที่แปลแล้วไว้ใน shared memory ของ PHP-FPM worker ทุกตัวใช้ชุดเดียวกัน request ถัดไปข้ามขั้นแปลไปรันได้เลย สำหรับเว็บ PHP นี่คือสิ่งที่ช่วยเรื่องความเร็วได้มากที่สุดโดยไม่ต้องแก้โค้ดสักบรรทัด ติดตั้งมากับ PHP แทบทุกชุดอยู่แล้ว และเปิดไว้โดยค่าเริ่มต้นสำหรับ PHP-FPM
ค่าของ OPcache ที่ควรดูสำหรับแอป Laravel
ค่าเริ่มต้นจากเอกสารของ PHP ใช้ได้กับเว็บเล็ก ๆ แต่แอป Laravel มักต้องขยายสองสามค่า
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
memory_consumptionขนาด shared memory เป็น MB ค่าเริ่มต้น 128 ถ้าเต็ม OPcache จะหยุดเก็บไฟล์ใหม่ หรือล้างทั้งหมดแล้วเริ่มใหม่ ซึ่งทำให้ช้าเป็นพัก ๆinterned_strings_bufferพื้นที่เก็บ string ที่ซ้ำกันอย่างชื่อ class และ method ค่าเริ่มต้น 8 MB แอปที่มีแพ็กเกจเยอะใช้เกินได้max_accelerated_filesจำนวนไฟล์สูงสุดที่เก็บได้ ค่าเริ่มต้น 10,000 ซึ่งน้อยกว่าจำนวนไฟล์ของแอป Laravel ขนาดกลางแล้ว ตั้งเป็น 20,000 ขึ้นไปvalidate_timestampsค่าเริ่มต้นคือ 1 OPcache จะเช็กทุก 2 วินาที (revalidate_freq) ว่าไฟล์เปลี่ยนไหม บน production ที่โค้ดเปลี่ยนเฉพาะตอน deploy ปิดไว้ได้ แต่ต้อง reload PHP-FPM ทุกครั้งที่ deploy ไม่อย่างนั้นเว็บจะรันโค้ดเก่าต่อไปเรื่อย ๆ
ส่วนเครื่อง dev ปล่อย validate_timestamps=1 ไว้ แก้ไฟล์แล้วเห็นผลทันที
JIT แปล opcode ต่อเป็น machine code
แม้มี OPcache แล้ว opcode ก็ยังต้องให้ Zend Engine อ่านทีละคำสั่งแล้วทำตาม เหมือนล่ามที่แปลไปพูดไป JIT (Just-In-Time compiler) ซึ่งมาใน PHP 8.0 ไปอีกขั้น คือแปลโค้ดช่วงที่ทำงานบ่อยเป็น machine code ของ CPU โดยตรง ครั้งถัดไปรันได้เลยโดยไม่ผ่านล่าม
JIT เป็นส่วนหนึ่งของ OPcache และมีสองแบบหลักให้เลือกใน opcache.jit
tracing(หรือon) ตามดูว่าโค้ดเส้นทางไหนทำงานบ่อย เช่นลูปที่วนถึงjit_hot_loopรอบ หรือฟังก์ชันที่ถูกเรียกถึงjit_hot_funcครั้ง แล้ว compile เส้นทางนั้นเป็น trace เอกสารของ PHP แนะนำแบบนี้ถ้าจะเปิดfunctioncompile ทีละฟังก์ชันทั้งก้อน ง่ายกว่าแต่ได้ผลน้อยกว่า
machine code ที่ได้เก็บไว้ในพื้นที่ของ JIT ขนาดตาม opcache.jit_buffer_size อยู่ใน shared memory เหมือน opcode ทุก worker ใช้ร่วมกันจนกว่าจะ reload PHP-FPM
PHP 8.4 ปิด JIT เป็นค่าเริ่มต้น
ค่าเริ่มต้นของ JIT เปลี่ยนใน PHP 8.4 ตามที่เขียนไว้ในเอกสาร
| ก่อน 8.4 | ตั้งแต่ 8.4 | |
|---|---|---|
opcache.jit |
tracing |
disable |
opcache.jit_buffer_size |
0 |
64M |
ก่อน 8.4 โหมดตั้งเป็น tracing แต่ buffer เป็น 0 JIT จึงไม่ทำงานอยู่ดี ตั้งแต่ 8.4 กลับด้าน มี buffer รออยู่ แต่โหมดปิด ทั้งสองแบบให้ผลเหมือนกันคือ JIT ไม่ทำงานถ้าเราไม่เปิดเอง ใน 8.5 ค่าเริ่มต้นของ jit_hot_loop เปลี่ยนจาก 64 เป็น 61 ตามหน้ารวมการเปลี่ยนแปลงของ 8.5 เพื่อไม่ให้ตรงกับจำนวนรอบของลูปทั่วไป
แปลว่าถ้าเครื่องไหนมี JIT ทำงานอยู่ มีคนตั้งเปิดไว้เอง ดูได้ด้วยการ grep opcache.jit ในโฟลเดอร์ config ของ PHP-FPM อย่าดูจาก php -i อย่างเดียว เพราะนั่นคือค่าของ PHP CLI ซึ่งอ่าน php.ini คนละไฟล์กับ FPM
JIT คุ้มกับงานที่ใช้ CPU คำนวณใน PHP
JIT ทำให้ส่วนที่ PHP คำนวณเองเร็วขึ้น เช่นงานคณิตศาสตร์ในลูปยาว ๆ ประมวลผลรูปหรือข้อมูลด้วยโค้ด PHP ล้วน หรือ parser ที่ไล่อ่านข้อความทีละตัว แต่ request ปกติของ Laravel ใช้เวลาไปกับเรื่องอื่น
- รอ query จากฐานข้อมูล
- รอ Redis, cache หรือ API ภายนอก
- อ่านเขียนไฟล์และ session
ช่วงที่ PHP รอ I/O JIT ไม่ได้ช่วยอะไร ส่วนที่เหลือก็เป็นโค้ดของ framework ที่กระจายไปหลายร้อยฟังก์ชัน ไม่ได้วนอยู่ในลูปเดียวนาน ๆ ให้ JIT เก็บผล ผลที่วัดได้จึงมักน้อยมาก ในขณะที่ JIT เพิ่มความซับซ้อนอีกชั้นที่ต้องดูแล
ถ้าอยากรู้ว่าแอปของเราได้ประโยชน์ไหม วัดเองดีกว่าเชื่อตัวเลขจากที่อื่น เปิด JIT บนเครื่อง staging แล้วยิง request ชุดเดียวกันเทียบเวลาตอบกับตอนปิด ถ้าต่างกันไม่ถึงระดับที่ผู้ใช้รู้สึก ก็ปิดไว้
สิ่งที่อาจเจอเมื่อใช้ OPcache และ JIT จริง
deploy แล้วเว็บยังรันโค้ดเก่า เกิดเมื่อตั้ง validate_timestamps=0 แล้วไม่ได้ reload PHP-FPM หรือ deploy แบบสลับ symlink แต่ nginx ส่ง path ที่ยังมี symlink อยู่ OPcache จึงเก็บ opcode ไว้ตาม path เดิม วิธีกันทั้งสองเรื่องอยู่ในลำดับขั้นตอน deploy และ deploy แบบไม่มี downtime ที่ใช้ $realpath_root
OPcache เต็ม ถ้า memory_consumption หรือ max_accelerated_files น้อยเกินไป OPcache จะล้างตัวเองแล้วแปลไฟล์ใหม่เป็นระยะ เว็บช้าลงเป็นช่วง ๆ โดยไม่มี error ดูได้จาก opcache_get_status() ถ้า cache_full เป็น true หรือจำนวน restart ขึ้นเรื่อย ๆ ให้เพิ่มค่า
JIT compile โค้ดผิด JIT เป็นส่วนที่ซับซ้อนที่สุดของ PHP และมีบั๊กได้รับการแก้แทบทุกรุ่นย่อย ระบบหลังบ้านของลูกค้ารายหนึ่งที่เราดูแลเคยเจอ request บันทึกฟอร์มค้างใช้ CPU เต็มจนครบ max_execution_time แล้วได้ 502 ระหว่างที่ค้างไม่มี syscall เลยและ memory ไม่ขึ้น อาการหายทันทีเมื่อ reload PHP-FPM แล้วกลับมาใหม่หลังใช้งานไปไม่กี่ชั่วโมง เพราะ trace ที่ compile ผิดค้างอยู่ใน shared memory และ JIT compile ใหม่เมื่อโค้ดช่วงนั้นถึงเกณฑ์อีกรอบ แก้ด้วย opcache.jit=off โดยเปิด OPcache ไว้เหมือนเดิม หลังจากนั้นไม่เจออีก
สรุปค่าที่เราใช้กับแอป Laravel คือเปิด OPcache ขยายขนาดให้พอกับจำนวนไฟล์ ปิด validate_timestamps บน production คู่กับ reload ทุกครั้งที่ deploy และปล่อย JIT ไว้ที่ค่าเริ่มต้นของ PHP 8.4 ถ้าสนใจว่า PHP 8.4 เปลี่ยนอะไรอีกบ้าง ลองอ่านฟีเจอร์ของ PHP 8.4 ที่ได้ใช้จริงต่อได้ครับ






