การปรับฟังก์ชั่นวัตถุประสงค์ R ให้เหมาะสมกับ Rcpp ช้าลงทำไม?


16

ขณะนี้ฉันกำลังทำงานกับวิธีแบบเบย์ที่ต้องการหลายขั้นตอนของการเพิ่มประสิทธิภาพของแบบจำลองการบันทึกหลายส่วนต่อการวนซ้ำ ฉันใช้ optim () เพื่อดำเนินการ optimisations เหล่านั้นและฟังก์ชั่นวัตถุประสงค์ที่เขียนใน R การทำโปรไฟล์เปิดเผยว่า optim () เป็นคอขวดหลัก

หลังจากขุดไปรอบ ๆ ฉันพบคำถามนี้ซึ่งพวกเขาแนะนำว่าการบันทึกฟังก์ชันวัตถุประสงค์ด้วยRcppสามารถทำให้กระบวนการเร็วขึ้น ฉันทำตามข้อเสนอแนะและบันทึกฟังก์ชั่นวัตถุประสงค์ของฉันRcppใหม่ แต่มันก็ช้าลง (ช้าลงประมาณสองเท่า!)

นี่เป็นครั้งแรกของฉันที่มีRcpp(หรืออะไรก็ตามที่เกี่ยวข้องกับ C ++) และฉันไม่สามารถหาวิธีการเขียนโค้ดเวกเตอร์ได้ มีความคิดอย่างไรที่จะทำให้เร็วขึ้น?

Tl; dr: การใช้งานฟังก์ชั่นปัจจุบันใน Rcpp ไม่เร็วเท่ากับ vectorised R; จะทำให้เร็วขึ้นได้อย่างไร?

ตัวอย่างที่ทำซ้ำได้ :

1) กำหนดฟังก์ชั่นวัตถุประสงค์ในRและRcpp: ความน่าจะเป็นของการสกัดกั้นแบบจำลองแบบมัลติโนเมียลเท่านั้น

library(Rcpp)
library(microbenchmark)

llmnl_int <- function(beta, Obs, n_cat) {
  n_Obs     <- length(Obs)
  Xint      <- matrix(c(0, beta), byrow = T, ncol = n_cat, nrow = n_Obs)
  ind       <- cbind(c(1:n_Obs), Obs)
  Xby       <- Xint[ind]
  Xint      <- exp(Xint)
  iota      <- c(rep(1, (n_cat)))
  denom     <- log(Xint %*% iota)
  return(sum(Xby - denom))
}

cppFunction('double llmnl_int_C(NumericVector beta, NumericVector Obs, int n_cat) {

    int n_Obs = Obs.size();

    NumericVector betas = (beta.size()+1);
    for (int i = 1; i < n_cat; i++) {
        betas[i] = beta[i-1];
    };

    NumericVector Xby = (n_Obs);
    NumericMatrix Xint(n_Obs, n_cat);
    NumericVector denom = (n_Obs);
    for (int i = 0; i < Xby.size(); i++) {
        Xint(i,_) = betas;
        Xby[i] = Xint(i,Obs[i]-1.0);
        Xint(i,_) = exp(Xint(i,_));
        denom[i] = log(sum(Xint(i,_)));
    };

    return sum(Xby - denom);
}')

2) เปรียบเทียบประสิทธิภาพของพวกเขา:

## Draw sample from a multinomial distribution
set.seed(2020)
mnl_sample <- t(rmultinom(n = 1000,size = 1,prob = c(0.3, 0.4, 0.2, 0.1)))
mnl_sample <- apply(mnl_sample,1,function(r) which(r == 1))

## Benchmarking
microbenchmark("llmml_int" = llmnl_int(beta = c(4,2,1), Obs = mnl_sample, n_cat = 4),
               "llmml_int_C" = llmnl_int_C(beta = c(4,2,1), Obs = mnl_sample, n_cat = 4),
               times = 100)
## Results
# Unit: microseconds
#         expr     min       lq     mean   median       uq     max neval
#    llmnl_int  76.809  78.6615  81.9677  79.7485  82.8495 124.295   100
#  llmnl_int_C 155.405 157.7790 161.7677 159.2200 161.5805 201.655   100

3) ตอนนี้เรียกพวกเขาในoptim:

## Benchmarking with optim
microbenchmark("llmnl_int" = optim(c(4,2,1), llmnl_int, Obs = mnl_sample, n_cat = 4, method = "BFGS", hessian = T, control = list(fnscale = -1)),
               "llmnl_int_C" = optim(c(4,2,1), llmnl_int_C, Obs = mnl_sample, n_cat = 4, method = "BFGS", hessian = T, control = list(fnscale = -1)),
               times = 100)
## Results
# Unit: milliseconds
#         expr      min       lq     mean   median       uq      max neval
#    llmnl_int 12.49163 13.26338 15.74517 14.12413 18.35461 26.58235   100
#  llmnl_int_C 25.57419 25.97413 28.05984 26.34231 30.44012 37.13442   100

ฉันค่อนข้างแปลกใจที่การนำเวกเตอร์ไปใช้ใน R นั้นเร็วขึ้น การใช้เวอร์ชันที่มีประสิทธิภาพมากขึ้นใน Rcpp (พูดกับ RcppArmadillo?) สามารถสร้างผลกำไรได้หรือไม่? เป็นความคิดที่ดีกว่าหรือไม่ที่จะบันทึกทุกอย่างใน Rcpp โดยใช้เครื่องมือเพิ่มประสิทธิภาพ C ++?

PS: การโพสต์ครั้งแรกที่ Stackoverflow!

คำตอบ:


9

โดยทั่วไปถ้าคุณสามารถใช้ฟังก์ชั่นแบบเวกเตอร์ได้คุณจะพบว่าเร็ว (เกือบ) เร็วเท่ากับรันโค้ดของคุณโดยตรงใน Rcpp นี่เป็นเพราะฟังก์ชั่นเวกเตอร์จำนวนมากใน R (ฟังก์ชั่นเวกเตอร์เกือบทั้งหมดในฐาน R) เขียนใน C, Cpp หรือ Fortran และเช่นนี้มักจะได้รับน้อย

ที่กล่าวว่ามีการปรับปรุงเพื่อให้ได้รับทั้งในRและRcppรหัสของคุณ การเพิ่มประสิทธิภาพมาจากการศึกษารหัสอย่างระมัดระวังและลบขั้นตอนที่ไม่จำเป็นออก (การกำหนดหน่วยความจำจำนวนเงิน ฯลฯ )

ให้เริ่มต้นด้วยการปรับRcppรหัสให้ดีที่สุด

ในกรณีของคุณการเพิ่มประสิทธิภาพหลักคือการลบเมทริกซ์และการคำนวณเวกเตอร์ที่ไม่จำเป็น รหัสอยู่ในสาระสำคัญ

  1. กะเบต้า
  2. คำนวณบันทึกของผลรวมของ exp (shift beta) [log-sum-exp]
  3. ใช้ Obs เป็นดัชนีสำหรับเบต้าที่ได้รับการเลื่อนและผลรวมของความน่าจะเป็นทั้งหมด
  4. sub-log-sum-exp

การใช้การสังเกตนี้เราสามารถลดรหัสของคุณให้เหลือ 2 ลูป โปรดทราบว่าsumเป็นเพียงอีกหนึ่ง for-loop (มากหรือน้อยfor(i = 0; i < max; i++){ sum += x }:) ดังนั้นการหลีกเลี่ยงผลรวมสามารถเร่งโค้ดให้เร็วขึ้น (ในสถานการณ์ส่วนใหญ่นี่คือการเพิ่มประสิทธิภาพที่ไม่จำเป็น!) นอกจากนี้การป้อนข้อมูลของคุณObsเป็นเวกเตอร์จำนวนเต็มและเราสามารถเพิ่มประสิทธิภาพรหัสโดยใช้IntegerVectorประเภทเพื่อหลีกเลี่ยงการคัดเลือกdoubleองค์ประกอบให้กับintegerค่า (คำตอบของ Credit to Ralf Stubner)

cppFunction('double llmnl_int_C_v2(NumericVector beta, IntegerVector Obs, int n_cat)
 {

    int n_Obs = Obs.size();

    NumericVector betas = (beta.size()+1);
    //1: shift beta
    for (int i = 1; i < n_cat; i++) {
        betas[i] = beta[i-1];
    };
    //2: Calculate log sum only once:
    double expBetas_log_sum = log(sum(exp(betas)));
    // pre allocate sum
    double ll_sum = 0;

    //3: Use n_Obs, to avoid calling Xby.size() every time 
    for (int i = 0; i < n_Obs; i++) {
        ll_sum += betas(Obs[i] - 1.0) ;
    };
    //4: Use that we know denom is the same for all I:
    ll_sum = ll_sum - expBetas_log_sum * n_Obs;
    return ll_sum;
}')

โปรดทราบว่าฉันได้ลบการจัดสรรหน่วยความจำค่อนข้างน้อยและลบการคำนวณที่ไม่จำเป็นใน for-loop ออก นอกจากนี้ฉันได้ใช้denomมันเหมือนกันสำหรับการวนซ้ำทั้งหมดและเพียงคูณเพื่อผลลัพธ์สุดท้าย

เราสามารถทำการเพิ่มประสิทธิภาพที่คล้ายกันในรหัส R ของคุณซึ่งส่งผลให้ฟังก์ชั่นด้านล่าง:

llmnl_int_R_v2 <- function(beta, Obs, n_cat) {
    n_Obs <- length(Obs)
    betas <- c(0, beta)
    #note: denom = log(sum(exp(betas)))
    sum(betas[Obs]) - log(sum(exp(betas))) * n_Obs
}

สังเกตว่าความซับซ้อนของฟังก์ชันลดลงอย่างมากทำให้ผู้อื่นอ่านง่ายขึ้น เพียงเพื่อให้แน่ใจว่าฉันไม่ได้สับสนในรหัสบางแห่งลองตรวจสอบว่าพวกเขากลับผลลัพธ์เดียวกัน:

set.seed(2020)
mnl_sample <- t(rmultinom(n = 1000,size = 1,prob = c(0.3, 0.4, 0.2, 0.1)))
mnl_sample <- apply(mnl_sample,1,function(r) which(r == 1))

beta = c(4,2,1)
Obs = mnl_sample 
n_cat = 4
xr <- llmnl_int(beta = beta, Obs = mnl_sample, n_cat = n_cat)
xr2 <- llmnl_int_R_v2(beta = beta, Obs = mnl_sample, n_cat = n_cat)
xc <- llmnl_int_C(beta = beta, Obs = mnl_sample, n_cat = n_cat)
xc2 <- llmnl_int_C_v2(beta = beta, Obs = mnl_sample, n_cat = n_cat)
all.equal(c(xr, xr2), c(xc, xc2))
TRUE

ดีนั่นคือการบรรเทา

ประสิทธิภาพ:

ฉันจะใช้ microbenchmark เพื่อแสดงประสิทธิภาพ ฟังก์ชั่นที่ได้รับการปรับให้เร็วที่สุดดังนั้นฉันจะเรียกใช้ฟังก์ชั่นตาม1e5เวลาเพื่อลดผลกระทบของตัวเก็บขยะ

microbenchmark("llmml_int_R" = llmnl_int(beta = beta, Obs = mnl_sample, n_cat = n_cat),
               "llmml_int_C" = llmnl_int_C(beta = beta, Obs = mnl_sample, n_cat = n_cat),
               "llmnl_int_R_v2" = llmnl_int_R_v2(beta = beta, Obs = mnl_sample, n_cat = n_cat),
               "llmml_int_C_v2" = llmnl_int_C_v2(beta = beta, Obs = mnl_sample, n_cat = n_cat),
               times = 1e5)
#Output:
#Unit: microseconds
#           expr     min      lq       mean  median      uq        max neval
#    llmml_int_R 202.701 206.801 288.219673 227.601 334.301  57368.902 1e+05
#    llmml_int_C 250.101 252.802 342.190342 272.001 399.251 112459.601 1e+05
# llmnl_int_R_v2   4.800   5.601   8.930027   6.401   9.702   5232.001 1e+05
# llmml_int_C_v2   5.100   5.801   8.834646   6.700  10.101   7154.901 1e+05

ที่นี่เราเห็นผลลัพธ์เหมือนเดิม ตอนนี้ฟังก์ชั่นใหม่จะเร็วกว่าประมาณ 35x (R) และ 40x เร็วกว่า (Cpp) เมื่อเทียบกับชิ้นส่วนแรกของพวกเขา น่าสนใจพอRฟังก์ชั่นที่ได้รับการปรับปรุงยังเร็วกว่าCppฟังก์ชั่นที่ได้รับการปรับปรุงของฉันเล็กน้อย (0.3 ms หรือ 4%) ทางออกที่ดีที่สุดของฉันที่นี่คือว่ามีค่าใช้จ่ายบางส่วนจากRcppแพคเกจและถ้าสิ่งนี้ถูกลบออกทั้งสองจะเหมือนกันหรืออาร์

ในทำนองเดียวกันเราสามารถตรวจสอบประสิทธิภาพโดยใช้ Optim

microbenchmark("llmnl_int" = optim(beta, llmnl_int, Obs = mnl_sample, 
                                   n_cat = n_cat, method = "BFGS", hessian = F, 
                                   control = list(fnscale = -1)),
               "llmnl_int_C" = optim(beta, llmnl_int_C, Obs = mnl_sample, 
                                     n_cat = n_cat, method = "BFGS", hessian = F, 
                                     control = list(fnscale = -1)),
               "llmnl_int_R_v2" = optim(beta, llmnl_int_R_v2, Obs = mnl_sample, 
                                     n_cat = n_cat, method = "BFGS", hessian = F, 
                                     control = list(fnscale = -1)),
               "llmnl_int_C_v2" = optim(beta, llmnl_int_C_v2, Obs = mnl_sample, 
                                     n_cat = n_cat, method = "BFGS", hessian = F, 
                                     control = list(fnscale = -1)),
               times = 1e3)
#Output:
#Unit: microseconds
#           expr       min        lq      mean    median         uq      max neval
#      llmnl_int 29541.301 53156.801 70304.446 76753.851  83528.101 196415.5  1000
#    llmnl_int_C 36879.501 59981.901 83134.218 92419.551 100208.451 190099.1  1000
# llmnl_int_R_v2   667.802  1253.452  1962.875  1585.101   1984.151  22718.3  1000
# llmnl_int_C_v2   704.401  1248.200  1983.247  1671.151   2033.401  11540.3  1000

ผลลัพธ์จะเหมือนเดิมอีกครั้ง

สรุป:

เป็นข้อสรุปสั้น ๆ มันเป็นมูลค่า noting ว่านี่เป็นตัวอย่างหนึ่งที่แปลงรหัสของคุณเพื่อ Rcpp ไม่คุ้มปัญหาจริงๆ นี่ไม่ใช่กรณีเสมอไป แต่บ่อยครั้งที่คุณควรพิจารณาฟังก์ชั่นที่สองของคุณเพื่อดูว่ามีบางส่วนของรหัสของคุณหรือไม่ โดยเฉพาะอย่างยิ่งในสถานการณ์ที่ใช้ฟังก์ชัน buildin vectorized มักจะไม่คุ้มค่ากับการแปลงรหัสเป็น Rcpp บ่อยครั้งที่คน ๆ หนึ่งสามารถเห็นการปรับปรุงที่ยอดเยี่ยมหากใช้for-loopsกับโค้ดที่ไม่สามารถแปลงเป็น vectorized ได้ง่ายเพื่อลบ for-loop


1
คุณสามารถObsถือเป็นการIntegerVectorปลดเปลื้องบางส่วนได้
Ralf Stubner

เป็นเพียงการรวมไว้ก่อนที่จะขอบคุณคุณสำหรับการสังเกตในคำตอบของคุณ มันผ่านมาโดยฉัน ฉันให้เครดิตกับคุณในคำตอบของฉัน @RalfStubner :-)
Oliver

2
ในขณะที่คุณเห็นในตัวอย่างของเล่นนี้ (ตัดเท่านั้นรุ่น MNL) ทำนายเชิงเส้น ( beta) Obsคงที่มากกว่าการสังเกต ถ้าเรามีเวลาที่แตกต่างกันพยากรณ์การคำนวณโดยนัยของdenomแต่ละที่จะกลายเป็นสิ่งที่จำเป็นขึ้นอยู่กับมูลค่าของเมทริกซ์ออกแบบObs Xดังที่ได้กล่าวไปแล้วว่าฉันกำลังนำข้อเสนอแนะของคุณไปใช้กับรหัสที่เหลือของฉันพร้อมกับผลกำไรที่ดีจริงๆ :) ขอบคุณ @RalfStubner @Oliver และ @thc สำหรับคำตอบที่เฉียบแหลมมากของคุณ! ตอนนี้ย้ายไปที่คอขวดของฉันต่อไป!
smildiner

1
ฉันดีใจที่เราสามารถช่วย ในกรณีทั่วไปมากขึ้นการคำนวณ denstract substracting ในแต่ละขั้นตอนของวินาทีfor-loopซึ่งจะทำให้คุณได้รับมากที่สุด นอกจากนี้ในกรณีทั่วไปฉันขอแนะนำให้ใช้model.matrix(...)เพื่อสร้างเมทริกซ์ของคุณสำหรับการป้อนข้อมูลในฟังก์ชั่นของคุณ
โอลิเวอร์

9

ฟังก์ชัน C ++ ของคุณสามารถทำได้เร็วขึ้นโดยใช้การสังเกตต่อไปนี้ อย่างน้อยก็อย่างแรกอาจใช้กับฟังก์ชั่น R ของคุณ:

  • วิธีที่คุณคำนวณจะเหมือนกันสำหรับทุกคนdenom[i] iดังนั้นจึงเหมาะสมที่จะใช้double denomและทำการคำนวณนี้เพียงครั้งเดียว ฉันยังแยกตัวประกอบการลบคำทั่วไปนี้ในที่สุด

  • การสังเกตของคุณเป็นเวกเตอร์จำนวนเต็มทางด้าน R และคุณใช้มันเป็นจำนวนเต็มใน C ++ เช่นกัน การใช้การIntegerVectorเริ่มต้นด้วยทำให้ไม่จำเป็นต้องใช้การหล่อมาก

  • คุณสามารถสร้างดัชนีการNumericVectorใช้IntegerVectorใน C ++ ได้เช่นกัน ฉันไม่แน่ใจว่าสิ่งนี้ช่วยเพิ่มประสิทธิภาพได้หรือไม่ แต่มันทำให้รหัสสั้นลงเล็กน้อย

  • การเปลี่ยนแปลงบางอย่างที่เกี่ยวข้องกับสไตล์มากกว่าประสิทธิภาพ

ผลลัพธ์:

double llmnl_int_C(NumericVector beta, IntegerVector Obs, int n_cat) {

    int n_Obs = Obs.size();

    NumericVector betas(beta.size()+1);
    for (int i = 1; i < n_cat; ++i) {
        betas[i] = beta[i-1];
    };

    double denom = log(sum(exp(betas)));
    NumericVector Xby = betas[Obs - 1];

    return sum(Xby) - n_Obs * denom;
}

สำหรับฉันฟังก์ชั่นนี้จะเร็วกว่าฟังก์ชั่น R ของคุณประมาณสิบเท่า


ขอบคุณสำหรับคำตอบของคุณ Ralph ไม่ได้ระบุประเภทอินพุต ฉันได้รวมสิ่งนี้ไว้ในคำตอบของฉันเช่นกันโดยให้เครดิตแก่คุณ :-)
Oliver

7

ฉันสามารถคิดสี่ศักยภาพการเพิ่มประสิทธิภาพมากกว่า Ralf และตอบ Olivers

(คุณควรยอมรับคำตอบของพวกเขา แต่ฉันแค่ต้องการเพิ่ม 2 เซ็นต์ของฉัน)

1) ใช้// [[Rcpp::export(rng = false)]]เป็นส่วนหัวของข้อคิดเห็นในฟังก์ชันในไฟล์ C ++ แยก สิ่งนี้ทำให้ความเร็วของเครื่องของฉันเพิ่มขึ้น ~ 80% (นี่เป็นคำแนะนำที่สำคัญที่สุดจาก 4 ข้อ)

2) ชอบcmathเมื่อทำได้ (ในกรณีนี้ดูเหมือนว่าจะไม่ได้สร้างความแตกต่าง)

3) หลีกเลี่ยงการจัดสรรเมื่อเป็นไปได้เช่นอย่าเปลี่ยนbetaเป็นเวกเตอร์ใหม่

4) เป้าหมายยืด: ใช้SEXPพารามิเตอร์แทน Rcpp เวกเตอร์ (ทิ้งไว้เป็นแบบฝึกหัดให้กับผู้อ่าน) เวกเตอร์ Rcpp นั้นเป็นตัวหุ้มที่บางมาก แต่มันก็ยังคงเป็นตัวห่อหุ้มอยู่และมีค่าใช้จ่ายเล็ก ๆ

optimข้อเสนอแนะเหล่านี้จะไม่ได้เป็นสิ่งที่สำคัญหากไม่ได้สำหรับความจริงที่ว่าคุณโทรฟังก์ชั่นในวงตึงตัวใน ดังนั้นค่าใช้จ่ายใด ๆ จึงสำคัญมาก

ม้านั่ง:

microbenchmark("llmnl_int_R_v1" = optim(beta, llmnl_int, Obs = mnl_sample, 
                                      n_cat = n_cat, method = "BFGS", hessian = F, 
                                      control = list(fnscale = -1)),
             "llmnl_int_R_v2" = optim(beta, llmnl_int_R_v2, Obs = mnl_sample, 
                                      n_cat = n_cat, method = "BFGS", hessian = F, 
                                      control = list(fnscale = -1)),
             "llmnl_int_C_v2" = optim(beta, llmnl_int_C_v2, Obs = mnl_sample, 
                                      n_cat = n_cat, method = "BFGS", hessian = F, 
                                      control = list(fnscale = -1)),
             "llmnl_int_C_v3" = optim(beta, llmnl_int_C_v3, Obs = mnl_sample, 
                                      n_cat = n_cat, method = "BFGS", hessian = F, 
                                      control = list(fnscale = -1)),
             "llmnl_int_C_v4" = optim(beta, llmnl_int_C_v4, Obs = mnl_sample, 
                                      n_cat = n_cat, method = "BFGS", hessian = F, 
                                      control = list(fnscale = -1)),
             times = 1000)


Unit: microseconds
expr      min         lq       mean     median         uq        max neval cld
llmnl_int_R_v1 9480.780 10662.3530 14126.6399 11359.8460 18505.6280 146823.430  1000   c
llmnl_int_R_v2  697.276   735.7735  1015.8217   768.5735   810.6235  11095.924  1000  b 
llmnl_int_C_v2  997.828  1021.4720  1106.0968  1031.7905  1078.2835  11222.803  1000  b 
llmnl_int_C_v3  284.519   295.7825   328.5890   304.0325   328.2015   9647.417  1000 a  
llmnl_int_C_v4  245.650   256.9760   283.9071   266.3985   299.2090   1156.448  1000 a 

v3 rng=falseคือคำตอบของโอลิเวอร์กับ v4 พร้อมด้วยคำแนะนำ # 2 และ # 3 รวมอยู่ด้วย

ฟังก์ชั่น:

#include <Rcpp.h>
#include <cmath>
using namespace Rcpp;

// [[Rcpp::export(rng = false)]]
double llmnl_int_C_v4(NumericVector beta, IntegerVector Obs, int n_cat) {

  int n_Obs = Obs.size();
  //2: Calculate log sum only once:
  // double expBetas_log_sum = log(sum(exp(betas)));
  double expBetas_log_sum = 1.0; // std::exp(0)
  for (int i = 1; i < n_cat; i++) {
    expBetas_log_sum += std::exp(beta[i-1]);
  };
  expBetas_log_sum = std::log(expBetas_log_sum);

  double ll_sum = 0;
  //3: Use n_Obs, to avoid calling Xby.size() every time 
  for (int i = 0; i < n_Obs; i++) {
    if(Obs[i] == 1L) continue;
    ll_sum += beta[Obs[i]-2L];
  };
  //4: Use that we know denom is the same for all I:
  ll_sum = ll_sum - expBetas_log_sum * n_Obs;
  return ll_sum;
}
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.