library(purrr)
library(mirai)
# เปิด Daemons 6 ตัว (แนะนำให้ตั้งตามจำนวน Core ที่เหมาะสมของเครื่อง)
daemons(6)R Tips 02: รันงาน Parallel ระดับ Microsecond ใน purrr ด้วย in_parallel() และ mirai
เวลาที่เราขอให้ AI ช่วยแปลงโค้ดลูปหรือ purrr::map() ให้ทำงานแบบขนาน (Parallel computing) บน CPU หลายแกน (Multi-core)
AI ในยุคนี้เกือบทั้งหมดยังคงติดนิสัยแนะนำแพ็กเกจยุคเดิมอย่าง parallel::mclapply() / parLapply() หรือไม่ก็ตระกูล future อย่าง furrr::future_map()
แม้เครื่องมือเหล่านั้นจะใช้งานได้ดีในอดีต แต่คนที่เคยนำไปรันงานจริงระดับหมื่นหรือแสนรอบ (เช่น Monte Carlo Simulation, Bootstrap หรือ Model Tuning) มักจะต้องปวดหัวกับ 2 คอขวดใหญ่:
- Overhead ในการส่งงานที่สูงระดับ Milliseconds: การสื่อสารข้ามโปรเซสผ่านระบบ Socket ดั้งเดิมนั้นช้า หากแต่ละ task เป็นงานสั้นๆ เวลาที่เสียไปกับการส่งงาน (Dispatch overhead) มักจะมากกว่าเวลารันจริงเสียอีก
- ปัญหา RAM บวมจนเครื่องค้าง (Out of Memory): เอนจินแบบเดิมมักเผลอ Serialize เอาตัวแปรขนาดใหญ่และขยะใน Global Environment ติดไปด้วย ส่งผลให้ RAM พุ่งขึ้นเป็นทวีคูณตามจำนวน Core ที่เปิดใช้งาน
ทว่าตั้งแต่ purrr 1.1.0 เป็นต้นมา ทีมพัฒนา Tidyverse และ r-lib ได้ยกเครื่องระบบ Parallel ใหม่ทั้งหมด โดยจับมือกับ mirai เอนจินกระจายงานรุ่นใหม่ที่เขียนด้วยภาษา C บนสถาปัตยกรรม NNG (Nanomsg Next Gen) พร้อมแนะนำตัวช่วยใหม่ในชื่อ purrr::in_parallel() ที่เปลี่ยนวิธีการเขียน Parallel ใน R ไปตลอดกาล!
1. เปรียบเทียบสถาปัตยกรรม: ทำไม mirai ถึงเร็วกว่า?
ความต่างที่สำคัญที่สุดระหว่างเอนจินยุคเก่ากับ mirai อยู่ที่กลไกการส่งต่อข้อมูล (Inter-Process Communication - IPC) และการจัดคิวงาน (Task Scheduling):
[ระบบเดิม: Sockets + Static Chunking]
┌────────────────────────────────────────────────────────┐
│ R Main Process (คัดลอก Global Env ส่งไปให้ทุก Workers) │
└────────┬──────────────────────┬───────────────────────┘
│ (TCP/Socketช้า ~ms) │ (Static Chunk: แบ่งชิ้นงานล่วงหน้า)
▼ ▼
┌──────────┐ ┌──────────┐
│ Worker 1 │ │ Worker 2 │ <-- ถ้า Worker 1 เจองานชิ้นหนัก
└──────────┘ └──────────┘ Worker 2 เสร็จก่อนก็นั่งรอเฉยๆ!
──────────────────────────────────────────────────────────
[ระบบใหม่: mirai + NNG + Dynamic Work Stealing]
┌────────────────────────────────────────────────────────┐
│ R Main Session (Crate Function: ส่งเฉพาะตัวแปรที่จำเป็น) │
└────────────────────────┬───────────────────────────────┘
│ (NNG C-IPC: เร็วระดับ Microsecond)
▼
┌───────────────────────────┐
│ mirai Task Broker Queue │
└─┬───────────────────────┬─┘
│ (Dynamic Scheduling) │
▼ ▼
┌──────────┐ ┌──────────┐
│ Daemon 1 │ │ Daemon 2 │ <-- คอร์ไหนเสร็จก่อน
└──────────┘ └──────────┘ ดึงงานชิ้นถัดไปทันที!
2. ความว้าว 4 ประการที่ทำให้ in_parallel() + mirai ชนะขาด
2.1 เร็วระดับ Microsecond (Near-zero Overhead)
mirai ส่งข้อความผ่านไลบรารี C ระดับล่าง (NNG) ทำให้มี Latency ต่ำเพียง 10–20 ไมโครวินาที (µs) ต่อ Task เร็วกว่าระบบ Socket ดั้งเดิมหลายสิบเท่า นั่นหมายความว่า คุณสามารถนำ Parallel ไปใช้กับงานชิ้นเล็กๆ ที่ต้องวนซ้ำเป็นหมื่นครั้งได้โดยไม่เสียเวลาไปกับ Dispatch Overhead
2.2 จัดการงานแบบ Dynamic Scheduling (ไม่ติดกับดัก Chunking)
ระบบเดิมมักใช้วิธีหั่นงานแบ่งให้ Worker ล่วงหน้า (Static Chunking) แต่ mirai ใช้ระบบ Work Stealing / Queue: งานจะถูกหยอดลงในคิวกลาง เมื่อ Daemon ไหนรันเสร็จ ก็จะดึงงานชิ้นถัดไปมาทำทันที เหมาะอย่างยิ่งกับงานจำลองทางสถิติ หรืองาน Fit โมเดลที่แต่ละกลุ่มข้อมูลใช้เวลาคำนวณไม่เท่ากัน
2.3 ปลอดภัยจากปัญหา RAM บวมด้วย Strict Crate Isolation
in_parallel() ถูกออกแบบมาบนแนวคิด Crate (กล่องปิด) ฟังก์ชันที่ถูกห่อจะไม่สามารถแอบเข้าถึงตัวแปรใน Global Environment ได้โดยพลการ หากคุณไม่ได้ส่งเข้าไปตรงๆ ระบบจะไม่อนุญาตให้ผ่าน จึงตัดปัญหาเรื่อง R ก๊อปปี้ข้อมูลขนาดหลาย GB ส่งไปให้ Worker ทุกตัวจน RAM ระเบิด
2.4 คงไวยากรณ์เดิมของ purrr 100% (ไม่ต้องจำชื่อฟังก์ชันใหม่)
ลืมการต้องมานั่งจำ future_map(), future_map2(), future_imap_dfr() ไปได้เลย คุณยังคงใช้ map(), map_dbl(), map_df() เดิมที่คุ้นเคย เพียงแค่ครอบฟังก์ชันเป้าหมายด้วย in_parallel() เท่านั้น!
3. คู่มือการใช้งานจริงทีละขั้นตอน
ขั้นตอนที่ 1: เปิด Background Daemons ด้วย mirai::daemons()
ก่อนรัน ให้เราสั่งเปิดโปรเซสเบื้องหลัง (Daemons) ขึ้นมาเตรียมไว้ การเปิด Daemons ของ mirai ใช้ทรัพยากรเบามากและทำเพียงครั้งเดียวต่อ Session:
ปกติแนะนำให้ใช้ parallel::detectCores() - 1 เพื่อเหลือคอร์หลักไว้ให้ระบบปฏิบัติการและ R Session หลักทำงานได้อย่างลื่นไหล
ขั้นตอนที่ 2: ห่อฟังก์ชันด้วย in_parallel()
ใช้งานกับ purrr::map() หรือฟังก์ชันในตระกูลเดียวกันได้ทันที:
# ตัวอย่าง: คำนวณง่ายๆ กระจายขนานกันไปยัง Daemons
numbers <- 1:1000
squares <- numbers |>
map_dbl(in_parallel(\(x) x^2))ขั้นตอนที่ 3: การส่งผ่านตัวแปรและฟังก์ชันภายนอก (Data Dependencies)
นี่คือจุดที่สำคัญที่สุดของ in_parallel(): เพราะความปลอดภัยระดับ Crate ทำให้ Worker มองไม่เห็นตัวแปรภายนอกโดยอัตโนมัติ
หากฟังก์ชันข้างในต้องการใช้ตัวแปรหรือ Custom Function ที่อยู่นอก Scope ให้ส่งเป็น Named Arguments ต่อท้ายใน in_parallel():
# ฟังก์ชันจำลองการวิเคราะห์โมเดล
fit_growth_model <- function(data, cutoff) {
sub <- data[data$biomass > cutoff, ]
lm(yield ~ temp + rainfall, data = sub)
}
my_threshold <- 15.5
# ส่ง fit_growth_model และ my_threshold เข้าไปใน crate อย่างชัดเจน
results <- list_of_field_data |>
map(
in_parallel(
\(df) fit_growth_model(df, cutoff = my_threshold),
fit_growth_model = fit_growth_model,
my_threshold = my_threshold
)
)การบังคับระบุตัวแปรแบบนี้คือหัวใจที่ทำให้เครื่องของคุณปลอดภัยจากปัญหา RAM บวม เพราะมันรับประกันว่าจะส่งเฉพาะข้อมูลชิ้นที่จำเป็นจริงไปยัง Background Process เท่านั้น
ขั้นตอนที่ 4: การทำงานแบบ Graceful Fallback และ require_daemons()
- Silent Fallback: หากคุณเรียกใช้
in_parallel()แต่ลืมเปิดdaemons()เอาไว้ ระบบจะไม่พ่น Error ออกมาให้ตกใจ แต่จะ Fallback สลับไปรันแบบปกติ (Sequential) ให้อัตโนมัติ โค้ดของคุณจึงทำงานต่อได้เสมอ - Strict Enforcement: หากสคริปต์ของคุณเป็นงานหนักที่ จำเป็นต้องรันขนานเท่านั้น และไม่อยากให้แอบไปรัน sequential ให้ใส่คำสั่งนี้ไว้:
# บังคับว่าถ้าไม่มี daemons ให้แจ้ง Error ทันที
mirai::require_daemons()ขั้นตอนที่ 5: ปิด Daemons เมื่อเลิกใช้งาน
เมื่อรันงานเสร็จเรียบร้อย ให้คืนทรัพยากร CPU และ RAM ให้กับเครื่องคอมพิวเตอร์ของคุณ:
# ปิด Daemons ทั้งหมดใน Session
daemons(0)4. ตัวอย่างการใช้งานจริง: Bootstrap จำลองผลผลิตทางการเกษตร
ลองมาดูเคสตัวอย่างงานวิจัยทางการเกษตร: สมมติว่าเราต้องการทำ Non-parametric Bootstrap จำนวน 1,000 ซ้ำ เพื่อหาค่าช่วงความเชื่อมั่น (Confidence Interval) ของผลผลิตข้าว:
library(purrr)
library(mirai)
# 1. กำหนดจำนวน Daemons พร้อมตั้งค่า Random Seed ให้ผลลัพธ์สามารถ Reproduce ได้
daemons(6, seed = 2026)
# 2. จำลองข้อมูลแปลงทดลอง
set.seed(42)
n_plots <- 200
field_trial <- data.frame(
variety = sample(c("RD43", "KDML105", "Riceberry"), n_plots, replace = TRUE),
nitrogen = runif(n_plots, 50, 150),
yield = rnorm(n_plots, mean = 650, sd = 80)
)
# 3. เตรียมดัชนีสำหรับ Bootstrap 1,000 รอบ
B <- 1000
boot_indices <- replicate(B, sample(nrow(field_trial), replace = TRUE), simplify = FALSE)
# 4. ฟังก์ชันคำนวณสถิติ
calc_boot_coef <- function(idx, data) {
sample_df <- data[idx, ]
mod <- lm(yield ~ nitrogen, data = sample_df)
coef(mod)[["nitrogen"]]
}
# 5. รันแบบ Parallel ผ่าน in_parallel()
boot_nitrogen_effects <- boot_indices |>
map_dbl(
in_parallel(
\(idx) calc_boot_coef(idx, data = field_trial),
calc_boot_coef = calc_boot_coef,
field_trial = field_trial
)
)
# คำนวณ 95% Confidence Interval
quantile(boot_nitrogen_effects, probs = c(0.025, 0.975))
# ปิด Daemons
daemons(0)5. ผลการทดสอบประสิทธิภาพ (Benchmark)
เมื่อเปรียบเทียบการรันงานประเภท Short-to-Medium Tasks (งานขนาด 2,000–5,000 tasks ย่อย เช่น งานจำลอง Simulation หรือ Cross-Validation):
| เกณฑ์การเปรียบเทียบ | Sequential (purrr::map) |
Legacy (furrr::future_map) |
Modern (purrr + mirai) |
|---|---|---|---|
| Dispatch Overhead ต่อ Task | 0 (ไม่มี IPC) | ~1,000 – 3,000 µs (ระดับ ms) | ~15 – 25 µs (เร็วมาก) |
| เวลาที่ใช้ทั้งหมด (Total Time) | 1.0x (ช้าสุด) | เร็วขึ้น ~2.8x (โดน overhead ดึง) | เร็วขึ้น ~5.2x (เต็มประสิทธิภาพ) |
| การใช้ RAM เพิ่มเติม | ต่ำสุด | สูงมาก (เสี่ยง OOM จาก Env) | ต่ำมาก (Strict Crate) |
| การกระจายงาน (Scheduling) | ทีละรายการ | Static Chunking | Dynamic Work Stealing |
| ไวยากรณ์ที่ใช้ | map() |
future_map() |
map(in_parallel()) |
ในงานที่มี task ย่อยจำนวนมาก future_map() จะเสียเวลาไปกับการ serialize และ network socket overhead มหาศาล ในขณะที่ mirai แทบไม่มี latency ทำให้พลังของ CPU Multi-core ถูกนำไปใช้กับการคำนวณล้วนๆ
6. ข้อควรระวังและเทคนิคขั้นสูง (Gotchas & Best Practices)
1. การเรียกใช้ฟังก์ชันจาก Package อื่น
เนื่องจาก Worker ของ Daemons เริ่มต้นด้วยสภาพแวดล้อมที่สะอาด (Clean environment) หากฟังก์ชันของคุณเรียกใช้ package อื่นๆ แนะนำให้: - วิธีที่ 1: ระบุ Namespace ตรงๆ เสมอ เช่น dplyr::filter() หรือ stringr::str_detect() - วิธีที่ 2: ใช้คำสั่ง mirai::everywhere() เพื่อโหลด library เข้าไปในทุก Worker ตั้งแต่แรก: r everywhere(library(dplyr))
2. การสร้างตัวเลขสุ่มที่สืบย้อนผลได้ (Reproducible Parallel RNG)
หากงานของคุณเกี่ยวข้องกับการสุ่ม (rnorm, sample, bootstrap) อย่าลืมส่ง seed เข้าไปที่ฟังก์ชัน daemons():
# mirai จะสร้าง L'Ecuyer-CMRG streams แบบขนานที่ปลอดภัยและให้ผลลัพธ์คงที่ทุกครั้ง
daemons(6, seed = 12345)3. ตรวจสอบเวอร์ชันของ purrr
ความสามารถ in_parallel() ต้องการ purrr >= 1.1.0 หากคุณใช้เวอร์ชันเก่ากว่านั้น ให้ทำการอัปเดตก่อนใช้งาน:
packageVersion("purrr")7. ตารางสรุปภาพรวม (The Showdown)
| ประเด็น | parallel (parLapply) |
future / furrr |
purrr::in_parallel() + mirai |
|---|---|---|---|
| ปีและยุคสมัย | ดั้งเดิม (Base R) | ยุคกลาง (2018–2023) | มาตรฐานยุคใหม่ (2025–2026) |
| Engine เบื้องหลัง | R Sockets / Forking | R Sockets / Process pools | C / NNG (Nanomsg Next Gen) |
| Overhead | มิลลิวินาที (ms) | มิลลิวินาที (ms) | ไมโครวินาที (µs) |
| ความเสี่ยง RAM รั่ว | ปานกลาง | สูง | ต่ำมาก (ป้องกันด้วย Crate) |
| Task Queueing | Chunk ล่วงหน้า | Chunk ล่วงหน้า | Dynamic FIFO / Work Stealing |
| อนาคตของระบบ | บำรุงรักษาขั้นต่ำ | มีผู้ใช้เดิมเยอะ | แกนหลักของ Tidyverse, Shiny, targets |
สรุปส่งท้าย
ในยุคปัจจุบัน สถาปัตยกรรมของ R ได้ก้าวหน้าไปไกลมากแล้ว mirai ไม่ได้เป็นเพียงแพ็กเกจทางเลือกอีกต่อไป แต่มันได้กลายมาเป็น “กระดูกสันหลังใหม่” ด้าน Async และ Parallel ของระบบนิเวศน์ R ทั้งหมด (ไม่ว่าจะเป็น purrr, Shiny ExtendedTask, plumber2 หรือ targets ผ่าน crew)
ในงานครั้งถัดไปที่คุณต้องประมวลผลงานหนักๆ หรือทำ Data Simulation:
เปิด
mirai::daemons()แล้วห่อฟังก์ชันด้วยin_parallel()ดูสักครั้ง แล้วคุณจะไม่ต้องกังวลเรื่องพัดลมเครื่องหมุนค้างหรือปัญหา RAM เต็มอีกต่อไปครับ!
ติดตั้งแพ็กเกจเวอร์ชันล่าสุดได้จาก CRAN โดยตรง:
install.packages(c("purrr", "mirai"))และสามารถศึกษาเอกสารเชิงลึกเพิ่มเติมได้ที่ GitHub: r-lib/mirai หรือคู่มือของ purrr (tidyverse)