(Acest articol a fost publicat pentru prima dată pe JottR pe Rși cu amabilitate a contribuit la R-bloggeri). (Puteți raporta problema legată de conținutul acestei pagini aici)
Doriți să vă distribuiți conținutul pe R-bloggeri? dați clic aici dacă aveți un blog, sau aici dacă nu aveți.

Montați o plasă elastică cu validare încrucișată cv.glmnet(x, y) pe o matrice de design care se potrivește confortabil în memorie, iar apelul poate eșua. Motivul este că funcția are nevoie de mai multe ori dimensiunea x în timp ce rulează și nimic în scriptul tău nu spune așa ceva. Această postare este despre oferirea acestei cerințe o casă – a specificarea memorieiscris cu R lângă codul care îl cunoaște – mai întâi pentru un apel secvenţial simplu, apoi pentru același apel care rulează în paralel.
Această postare descrie idei experimentale și planuri de viitor pentru ecosistemul Futureverse. Cu o singură excepție – „futurize()”, care este astăzi pe CRAN – caracteristicile prezentate aici nu sunt încă implementate.
TL;DR
Notați de ce are nevoie apelul, ca o singură adnotare pe codul de altfel obișnuit;
fit <- cv.glmnet(x, y) |> resources(memory(4 * object.size(x)))
Mai bine, specificația memoriei nu trebuie să rămână la locul apelului. Dată cv.glmnet() poartă propria sa declarație, cadrele de paralelizare o pot folosi, de ex
fits <- lapply(xs, FUN = cv.glmnet, y = y) |> futurize()
Oricum, acea declarație este menită să facă două locuri de muncă;
- verificat înainte munca începe – paralel sau nu – eșuează rapid în loc de minute sau ore mai târziu, și
- predat unui cadru paralel, acesta decide, de asemenea, câte sarcini pot rula în paralel.
Scopul Proiect de resurse este de a studia, proiecta și implementa aceste caracteristici în Futureverse. Feedback-ul și sugestiile sunt binevenite.
Problemă: Nu avem nicio modalitate de a declara nevoile de memorie
Verificăm argumentele tot timpul. O stopifnot(is.matrix(x)) în vârful unei funcții se află a doua natură și în memoria noastră musculară. Este util pentru că ajută funcțiile să eșueze rapid atunci când sunt transmise argumente greșite și pentru că putem da un mesaj de eroare informativ. Cu toate acestea, nu avem nimic pentru a verifica dacă mașina este de fapt capabilă să ruleze funcția.
De exemplu, este posibil să nu aveți suficientă memorie disponibilă pentru a efectua un calcul. Dacă rămâneți fără memorie, puteți obține:
> fit <- cv.glmnet(x, y) Error: cannot allocate vector of size 1.7 Gb
Mai rău este atunci când sistemul de operare intervine primul și oprește procesul R. Nu există nicio eroare R de prins, nicio urmărire și nicio șansă de a închide cu grație:
$ Rscript fit-model.R Killed $
Nici unul nu vă spune de cât a fost nevoie, iar ambele ajung după ce lucrarea este deja în derulare.
După ce ați identificat problema ca fiind o problemă de memorie, devine adesea un joc de încercare și eroare și poate dura ceva timp pentru a vă da seama de câtă memorie aveți nevoie. Descoperirea ar putea ajunge apoi ca un comentariu de cod sursă în script sau ca o propoziție rar citită într-o pagină de ajutor a pachetului. Oricum, deoarece nevoile de memorie nu sunt în cod, R nu poate verifica, nu poate dimensiona paralelizarea din acesta și nu poate oferi mesaje de eroare informative atunci când rămâneți fără memorie.
Propunere: Verificați memoria și eșua rapid
Din măsurători rudimentare și inspecții de cod ale glmnetam gasit asta cv.glmnet(x, y) are nevoie de aproximativ 3-4 ori mai mult de memorie object.size(x) în plus față de x în sine. Putem îmbunătăți acest model de memorie, dar pentru simplitate, să presupunem cv.glmnet(x, y) nevoi 4 * object.size(x) adiţional memorie pentru a avea succes.
Să luăm ca exemplu o matrice de design numeric de 250.000 de rânduri și 1.000 de coloane, care înregistrează aproximativ 1,9 GiB de memorie:
x <- matrix(rnorm(250e3 * 1e3), nrow = 250e3, ncol = 1e3) format(object.size(x), units = "GiB") #> (1) "1.9 GiB"
Imaginați-vă a freeMemory() funcție de interogare câtă memorie are disponibilă procesul R curent. Cu asta, ne-am putea păstra fit <- cv.glmnet(x, y) suna ca:
stopifnot(freeMemory() >= 4 * object.size(x)) #> Error: freeMemory() >= 4 * object.size(x) is not TRUE
Asta eșuează imediat şi înainte încercarea de potrivire a modelului, dacă nu există suficientă memorie disponibilă. Ne-am putea imagina, de asemenea, un vocabular mai bogat care ne oferă mesaje de eroare mai informative, de ex
assert_resources(memory(4 * object.size(x))) #> Error: UnmetResourceError: requires memory 7.5 GiB, available 3.1 GiB
În acest proiect, propunem un expr |> resources(...) sintaxă pentru declararea și afirmarea nevoilor de resurse, inclusiv cerințele de memorie, lângă codul în care se aplică. În exemplul nostru, ar arăta astfel:
fit <- cv.glmnet(x, y) |> resources(memory(4 * object.size(x))) #> Error: UnmetResourceError: cv.glmnet(x, y) requires memory 7.5 GiB, available 3.1 GiB
Această sintaxă păstrează codul original și logica așa cum sunt, permițându-vă în același timp să declarați cerințele de resurse asupra cărora R poate acționa. În forma sa cea mai de bază, funcționează eficient ca:
fit <- {
assert_resources(memory(4 * object.size(x)))
cv.glmnet(x, y)
}
Dacă vă întrebați dacă acesta merită codul suplimentar, luați în considerare cine plătește atunci când lipsește. Chiar și un utilizator cu experiență poate petrece ore în șir găsind motivul unei erori „nu se poate aloca vector” sau un mesaj „OOM-ucis”. Un cercetător nou în clusterele de calcul de înaltă performanță (HPC) ar putea pierde o zi din cauza acestuia, ar putea trimite un bilet de asistență sau ar putea concluziona în liniște că R nu își poate gestiona datele.
Scopul acestui proiect este nu numai reducerea cantității de resurse de calcul irosite, ci și a resurselor umane irosite.
Lot, încă secvenţial
Acum să presupunem că nu putem monta un model, ci zece. Luați în considerare un studiu pe pacienți cu un obiectiv clinic (y) profilate în mai multe moduri, inclusiv expresie, număr de copii, miARN, metilare, mutație somatică, accesibilitate cromatinei, proteomică, fosfoproteomică, metabolomică și lipidomică. Să presupunem că vrem să știm care dintre aceste seturi de date omice prezic cel mai bine rezultatul. Avem unul yși zece x matrici de proiectare într-o listă:
xs <- list(...) # one design matrix per assay, ~8 GiB of them in total
Fără protecție de memorie, ne-am potrivi aceste modele ca:
fits <- lapply(xs, function(assay) cv.glmnet(assay, y))
Rezoluția tehnologiilor variază foarte mult, deci diferită x matricele variază foarte mult ca mărime. Metilarea este adesea de cea mai mare rezoluție. Să presupunem că matricea sa de proiectare are o poziție de 1,9 GiB xs((4))iar cel mai mic este o zecime din asta. La fel ca înainte, ne-am putea proteja împotriva suprasolicitarii memoriei utilizând:
fits <- lapply(xs, function(assay) {
cv.glmnet(assay, y) |> resources(memory(4 * object.size(assay)))
})
#> Error: UnmetResourceError: cv.glmnet(assay, y) requires memory 7.5 GiB, available 3.1 GiB
Acest lucru vă spune că unul dintre modelele de potrivire ar eșua din cauza memoriei insuficiente. Din păcate, nu eșuează instantaneu – nu reușește decât atunci când încearcă să se potrivească cu matricea de design prea mare. Având în vedere că cea mai mare matrice în acest caz se întâmplă să fie în poziția a patra, ați pierdut deja eforturi în procesarea a trei validări încrucișate înainte de a eșua. În cel mai rău caz, ar fi putut procesa nouă din cele zece matrici de proiectare, înainte de a eșua.
Ar fi mai bine dacă apelul de reducere a hărții eșuează instantaneu, înainte de a încerca să se potrivească vreun model. Am putea face acest lucru să se întâmple dacă mutăm declarația în exterior, astfel încât întreaga verificare să aibă loc înainte ca modelul să se potrivească:
fits <- lapply(xs, function(assay) cv.glmnet(assay, y)) |> resources(function(assay) memory(4 * object.size(assay))) #> Error: UnmetResourceError: cv.glmnet(assay, y) requires memory 7.5 GiB for xs((4)), available 3.1 GiB
Ceea ce este nou este că specificația este a funcţie mai degrabă decât o declarație fixă. Oarecum simplificat, acest lucru verifică în mod eficient nevoile de memorie pentru toate potrivirile de validare încrucișată, înainte de a le potrivi. Ceva de genul:
fits <- {
assert_resources(lapply(xs, function(assay) memory(4 * object.size(assay))))
lapply(xs, function(assay) cv.glmnet(assay, y))
}
Acesta este argumentul din partea de sus a acestei postări aplicat unui lot, mai degrabă decât unui apel, și este locul în care eșecul rapid salvează cel mai mult. Evitați irosirea tuturor apelurilor, ceea ce înseamnă mai puține resurse de calcul irosite, depanare mai rapidă și remedieri mai rapide.
Declarația aparține funcției
În cele două exemple de mai sus, apelant trebuia să declare nevoile de memorie. Asta e puțin înapoi. Pentru a face asta, a trebuit să investighez ce cv.glmnet() nevoilor și nimeni nu ar trebui să repete acest exercițiu pentru fiecare funcție de modelare pe care o apelează. Ar fi mai bine dacă autorul funcției ar putea preciza asta. O abordare ar fi atașarea unei funcții de specificare a resursei la funcția în sine;
resources(cv.glmnet) <- function(x, ...) memory(4 * object.size(x))
Tehnic, asta stabilește attr(cv.glmnet, "resources") după ce ați validat definiția funcției.
În continuare, putem avea resources() căutați astfel de atribute de „resurse” și folosiți-le ca implicite, dacă sunt găsite. Deci, dacă este setat, apelul de afirmare a memoriei va deveni:
fit <- cv.glmnet(x, y) |> resources() #> Error: UnmetResourceError: cv.glmnet(x, y) requires memory 7.5 GiB, available 3.1 GiB
Observați cum sarcina nu mai revine apelantului, ci întreținătorul funcției, de a declara resurse.
Apelul map-reduce funcționează în același mod, odată ce îi spunem ce funcție este folosită:
fits <- lapply(xs, function(assay) cv.glmnet(assay, y)) |> resources(cv.glmnet) #> Error: UnmetResourceError: cv.glmnet(assay, y) requires memory 7.5 GiB for xs((4)), available 3.1 GiB
Am primit o funcție care poartă o declarație, resources() va folosi aceste declarații în mod implicit.
Funcții care se păzesc
Cu o funcție de resursă atașată funcției, the cv.glmnet() funcția ar putea proteja cu ușurință resursele în avans folosind:
cv.glmnet <- function(x, y, ...) {
assert_resources()
# ... the real work
}
Acest lucru ar elimina nevoia de utilizare a apelantului |> resources();
fit <- cv.glmnet(x, y) #> Error: UnmetResourceError: cv.glmnet(x, y) requires memory 7.5 GiB, available 3.1 GiB
Atașându-l la codul pe care nu îl mențineți
Chiar dacă o funcție nu conține o declarație de resursă, puteți atașa una, de ex
cv.glmnet <- glmnet::cv.glmnet resources(cv.glmnet) <- function(x, ...) memory(4 * object.size(x))
Ar putea fi în valoare de făcut chiar și pentru o singură funcție într-un singur script de analiză, de preferință în partea de sus a scriptului. Ajută să aduni astfel de declarații într-un singur loc și evită aglomerarea codului, mai ales dacă aceeași funcție este utilizată în mai multe locuri.
Paralel: aceeași specificație de memorie decide câte sarcini rulează simultan
Până acum, am discutat doar despre specificațiile resurselor în procesarea secvențială. Un alt obiectiv al acestui proiect este de a le folosi și în procesare paralelă. De exemplu, deja astăzi putem folosi futuriza pachetul și acesta futurize() funcția de paralelizare lapply(), purrr::map(), foreach()și prieteni prin acea țeavă unică. În cazul nostru, am putea folosi:
fits <- lapply(xs, function(assay) cv.glmnet(assay, y)) |> futurize()
Fără protecție a memoriei, există un risc mare ca să rămâneți fără memorie înainte de a rămâne fără CPU. Din acest motiv, scopul este de a sprijini și:
fits <- lapply(xs, function(assay) cv.glmnet(assay, y)) |> resources(function(assay) memory(4 * object.size(assay))) |> futurize()
Aici specificațiile resurselor nu numai că ar proteja împotriva suprasolicitarii memoriei, ci ar fi utilizate și pentru programarea sarcinilor paralele, de exemplu prin limitarea numărului de sarcini paralele care necesită memorie, care rulează simultan pe aceeași mașină.
Pe o singură mașină
Luați în considerare utilizarea plan(multisession) unde aveți acces la 32 GiB de memorie și 16 nuclee CPU. Asta inițiază 16 lucrători paraleli. Fiecare lucrător este un proces R separat, iar fiecare sarcină exportă matricea pe care urmează să o încadreze, deci o sarcină costă acea matrice plus ce se potrivește cv.glmnet() nevoi pe deasupra.
Este puțin probabil să puteți rula 16 sarcini paralele de adaptare a modelelor cu doar 32 GiB. Chiar înainte de a trece la paralelizare, știți că matricele dvs. de design sunt în xs ocupați 8 GiB din acea memorie, lăsându-vă cu cel mult 24 GiB pentru sarcinile paralele. Pentru a se potrivi testului de metilare, trebuie să exportați matricea de 1,9 GiB către lucrător și apoi alți 7,5 GiB pentru a o potrivi, însumând 9,4 GiB. O sarcină pe una dintre testele mici costă puțin sub 4 GiB.
Dacă ați lansat orbește 16 potriviri paralele, este foarte probabil să rămâneți fără memorie, iar ucigașul de memorie fără memorie (OOM) al sistemului de operare vă poate termina analiza. Având în vedere că niciunul din acest cadru de specificare a resurselor nu există astăzi, cel mai bun lucru pe care îl puteți face este să lucrați singuri numerele și să creați doar atât de mulți lucrători paraleli pe care vă puteți permite:
## 32 GiB RAM, ~8 GiB for the R session holding 'xs', 9.4 GiB for the largest task plan(multisession, workers = parallelly::availableCores(max = (32 - 8) / 9.4)) #> 2 workers
Aici folosim availableCores() din paralel pachet, care respectă alocările obișnuite ale procesorului, limitându-l și manual printr-o ecuație personalizată a limitei memoriei. Două sarcini paralele simultane este cea mai bună presupunere pe care o aveți. Cu toate acestea, cu specificațiile de resurse de mai sus, futurize() ar putea probabil să se descurce mai bine și să se potrivească simultan cu modele suplimentare de validare încrucișată, în special pe cele mai mici.
Pe un cluster de calcul
Pe un cluster de calcul de înaltă performanță (HPC), programator de locuri de muncă (de exemplu, Slurm și SGE) decide pe ce noduri de execuție aterizează jobul dvs., în funcție de cantitatea de memorie pe care o solicită. De exemplu, declarația Slurm #SBATCH --mem=10G îi spune planificatorului că acest job necesită 10 GiB de RAM pentru a rula.
Prin declararea unor astfel de nevoi de memorie în R;
plan(future.batchtools::batchtools_slurm) fits <- lapply(xs, function(assay) cv.glmnet(assay, y)) |> resources(function(assay) memory(4 * object.size(assay))) |> futurize()
cel viitor framework-ul ar putea lucra împreună cu viitor.batchtools pentru a traduce fiecare dintre nevoile de resurse calculate în declarații înțelese de planificatorul de joburi, care apoi poate găsi sloturi de dimensiuni adecvate pe cluster – toate în timp ce maximizează utilizarea memoriei, dar fără a rămâne niciodată fără memorie.
În mod ideal, totul este ascuns
La fel ca cu resources()dacă cv.glmnet() își declară nevoile proprii de resurse, futurize() poate profita și de asta. Asta ar închide cercul astfel încât codul existent deja astăzi, de ex
fits <- lapply(xs, FUN = cv.glmnet, y = y) |> futurize()
ar deveniți conștienți de resurse, protejați împotriva suprasolicitarii și optimizați programarea peste noapte – totul fără modificări de cod.
Outro
Puff, asta a fost destul de lung și, totuși, am ajuns să acopăr doar o mică parte din ceea ce Proiect de resurse vizează. Am discutat cum ne putem descurca memorie din interiorul R, dar există multe alte resurse de calcul care ne limitează. De exemplu, dorim, de asemenea, să gestionăm timpul de perete, spațiul scratch, nucleele GPU și memoria GPU.
Dacă aveți alte gânduri sau idei, ne-ar plăcea să auzim de la dvs. Vă rugăm să contactați forumul Futureverse Discussions.
Fie ca viitorul să fie cu tine!
Henrik
