Repository navigation
6/8 Faster cross-validation: one pool forked once per call, faster interfaces (results unchanged) - #131
Merged
Conversation
…forked once per call
Why: on our 128-core server, ClassifyR was often slower with more cores. Measured causes, all in the
machinery rather than the models:
- every assay, classifier and combination started its own BiocParallel pool, and making a MulticoreParam
costs about 0.5 s on servers that allow many open files (worker count check calls showConnections());
- BiocParallel's MulticoreParam forks a new process for every chunk of tasks, and each forked process
copies much of the session's memory (about 0.5 GB with ClassifyR's dependencies) when R first collects
garbage in it, which showed as kernel time growing with the number of cores;
- macOS was not recognised ("Darwin"), so nCores and the seed were ignored there.
What:
- runTests is split into preparing a cross-validation (data checks, splits, final model), running its
splits and assembling the ClassifyResult; crossValidate prepares every assay, classifier and combination
first and runs all of their splits in one pool;
- the pool uses the parallel package: forked workers on Linux and macOS, made once per call after the data
are prepared, and socket workers on Windows, sent the task function and its data once each; tasks are
handed out two at a time as workers become free, interleaving cross-validations;
- each split sets the random number stream that bpmapply with the same seed would give it, so results are
identical to before for any number of cores and either kind of worker;
- missing values are checked per column and constant features found without converting to a matrix.
Evidence (verona, protocol Procedure 2 step 4, CoxNet on 7 views, 20 x 5 CV): before 624 s on 1 core,
113 s on 8, 64 s on 20, 56 s on 32 cores; with this series 184 s, 27.5 s, 13.2 s and 9.9 s (18.6x on 32
cores). Regression harness: identical results to the previous commit, and between 1 and 20 cores.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TfsjwkbDbHkX4kJZNe4ER4
…al results Why: profiling one fold showed time spent around the models rather than in them. - DataFrame to data.frame conversion took about 0.2 s per call for thousands of features (S4 dispatch per column); .asDataFrame builds the same data.frame directly (identical(), tested). Used by the random forest, random survival forest, Cox, SVM and penalised GLM interfaces. - Design matrices of numeric features are built directly instead of by model.matrix (identical, tested); 150 ms -> 14 ms at 2000 features for penalised GLM, Cox elastic net and XGBoost. - The SVM is fitted to a matrix when all features are numeric: the same model as the formula interface (same support vectors, coefficients and probabilities, tested); 425 ms -> 134 ms at 2000 features. - Random forest vote shares are counted per class over the vote matrix; fold models drop the forest grown only to rank features (it is used before the model is stored; the final model keeps it). - Cox elastic net tuning by C-index: .cvCoxnetC reproduces cv.glmnet(family = 'cox', type.measure = 'C') (same folds, fits and lambda.min; identical on 40 METABRIC subsets and in the tests) but counts concordant pairs for every lambda of a fold at once, matching survival::concordance bit for bit, instead of 1000 calls of concordance per fit. cv.glmnet is still used when settings of its own cross-validation are given. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TfsjwkbDbHkX4kJZNe4ER4
…request, untrack Rplots.pdf Why: R CMD check notes. Functions from stats, utils and S4Vectors (first, second, mcols<-) were used without being imported; src/Makevars only asked for the obsolete C++11 standard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TfsjwkbDbHkX4kJZNe4ER4
This was referenced Oct 9, 2026
Merged
… at a time Why: a timeline of the protocol's Procedure 1 on 20 cores showed each worker busy for only 7.5-8.3 s of the 12.8 s the tasks took: with 1500 short tasks (median 75 ms) handed out two at a time, workers waited for the main process between chunks. With about eight chunks per worker: Procedure 1 13.2 s -> 8.9 s on 20 cores, 9.4 s -> 8.1 s on 32; Procedure 2 step 4, with longer tasks, unchanged within noise. Results are identical (each task sets its own random number stream). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TfsjwkbDbHkX4kJZNe4ER4
…x instead of clashing with it cv.glmnet was always called with type = 'C', so extraParams = list(train = list(type.measure = 'deviance')) stopped with 'formal argument matched by multiple actual arguments'. Tuning by C-index remains the default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TfsjwkbDbHkX4kJZNe4ER4
…r features) attr(model, "features") partially matched the "featuresForTrain" attribute that runTest gives every model, so models fitted with the formula interface (any factor feature) were predicted with the numeric-matrix path and failed with "test data does not match model". Matching the attribute name exactly restores prediction on a data frame. Introduced by d6ff767; results for all-numeric data are unchanged. Adds tests with factor features. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TfsjwkbDbHkX4kJZNe4ER4
crossValidate's pool of workers is a BiocParallel param started once per call: MulticoreParam on Linux and macOS, whose forked workers find the task function in their copy of .ClassifyRenvir, and SnowParam on Windows, to which BiocParallel sends the task function once per worker. Tasks are still handed out in about eight chunks per worker. While the workers run, BLAS and OpenMP use one thread in each (RhpcBLASctl, set in the main session before the workers start; socket workers get OPENBLAS_NUM_THREADS and OMP_NUM_THREADS). A multithreaded OpenBLAS otherwise starts one thread per core in every worker, and randomForestSRC in forked workers could wait forever after the main session had used OpenMP. The random number streams of the splits are taken from a serial bplapply call with the cross-validation's RNGseed, which gives the same streams as before. RhpcBLASctl replaces parallel in Imports. Results are identical for a fixed seed and any number of cores. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y1PuRkfDQ3ApzuNoe5ovxW
Member
Author
|
@DarioS: following your point that Bioconductor packages must use BiocParallel, this PR no longer uses the parallel package. The stack above it (#132–#144) is rebuilt on top, and each branch gained exactly this one commit.
The same helper went into spicyR, lisaClust and Statial: SydneyBioX/spicyR#81, SydneyBioX/lisaClust#17 and SydneyBioX/Statial#18. 🤖 Generated with Claude Code |
DarioS
marked this pull request as ready for review
October 28, 2026 02:15
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Faster cross-validation, with identical results: one pool of workers forked once per call, and faster classifier interfaces.
Why
On our 128-core server, students found ClassifyR often slower with more cores. Measurements located the causes in ClassifyR's machinery, not in the server or the models: pure R work scaled to 16× on 20 cores through the same BiocParallel.
MulticoreParamcosts a fullgc()(about 0.5 s in a session with ClassifyR's dependencies): its worker check callsshowConnections(), which runsgc().MulticoreParam, unless started withbpstart, forks a new process for every chunk of tasks. Each forked process copies much of the session's memory (about 0.5 GB with ClassifyR's dependencies) when R first collects garbage in it. Kernel time grew with the number of cores: devel used 85 s of kernel time on 32 cores for a 139 s job.DataFrame→data.frameconversion cost 0.2 s per call;model.matrix0.15 s; the SVM formula interface 0.3 s; and Cox elastic net tuning by C-index calledsurvival::concordanceabout 1000 times per fit.What changes
runTestsis split into preparing, running the splits, and assembling the result.crossValidateprepares every assay, classifier and combination, then runs all splits in one BiocParallel pool: aMulticoreParamstarted once per call withbpstart()after the data are ready, so forked workers share the data and receive only task numbers; aSnowParamon Windows, sent the task once per worker.randomSurvivalForeston 8 cores never finished; now 4 s).parallelfunctions are used: random number streams per split come from BiocParallel (identical to before), and RhpcBLASctl replaces parallel in Imports.bpmapplywould have given it, so results don't depend on the number or kind of workers.runTestsstill uses any BiocParallel parameters it is given..asDataFrame(the samedata.frame, built directly);model.matrix);.cvCoxnetCreproducescv.glmnet(type.measure = "C")(same folds, fits and lambda) with a vectorised C-index that matchessurvival::concordancebit for bit.Do results change?
No. The regression harness (18 scenarios) gives results identical to part 5, apart from two differences in form only:
svminstead ofsvm.formula, with the same predictions.Results are also identical between 1 and 20 cores, and between forked and socket workers (tested).
Evidence
Protocol procedures on verona, measured with parts 6 and 7 together, in a session with no thread settings:
Elapsed time, and speed-up against the same build on 1 core:
A timeline of Procedure 1 on 20 cores showed workers waiting for the main process between small chunks of tasks; handing out about eight chunks per worker took it from 13.2 s to about 8 s. Pure R work reached 16× on 20 cores on this server, through BiocParallel or parallel alike, which is the ceiling.
472 tests pass.
Limits
Jobs of a few seconds still don't gain from many cores: forking and the memory copy cost about 0.5–1 s per worker. On a two-socket server, expect 10–16× on 20 cores rather than 20×.
🤖 Generated with Claude Code
https://claude.ai/code/session_01TfsjwkbDbHkX4kJZNe4ER4