Skip to content

6/8 Faster cross-validation: one pool forked once per call, faster interfaces (results unchanged) - #131

Merged
DarioS merged 7 commits into
review/5-maintenancefrom
review/6-faster
Oct 28, 2026
Merged

DarioS merged 7 commits into
review/5-maintenancefrom
review/6-faster

Conversation

@ellispatrick

@ellispatrick ellispatrick commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

Part of a stack of 8 pull requests from the October 2026 audit of ClassifyR (AUDIT.md on branch audit-2026-10). Each part is based on the one before, so they need to be merged in order. Each one can be accepted, changed or rejected on its own merits. Draft PR #125 holds all eight together. Every part installs and passes its own tests.

Stack, merge in order: #126 tests and core fixes → #127 interface fixes → #128 metrics and plots → #129 pathways and criss-cross → #130 maintenance → #131 faster cross-validation (identical results) → #132 shared folds (changes results) → #133 release notes. The regression harness and timings are in SydneyBioX/ProjectClassifyR (private).

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.

  • A new pool per run: every assay, classifier and combination started its own BiocParallel pool. Making a MulticoreParam costs a full gc() (about 0.5 s in a session with ClassifyR's dependencies): its worker check calls showConnections(), which runs gc().
  • Forking: MulticoreParam, unless started with bpstart, 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.
  • Overhead around the models: DataFrame → data.frame conversion cost 0.2 s per call; model.matrix 0.15 s; the SVM formula interface 0.3 s; and Cox elastic net tuning by C-index called survival::concordance about 1000 times per fit.

What changes

  • One pool per call:
    • runTests is split into preparing, running the splits, and assembling the result.
    • crossValidate prepares every assay, classifier and combination, then runs all splits in one BiocParallel pool: a MulticoreParam started once per call with bpstart() after the data are ready, so forked workers share the data and receive only task numbers; a SnowParam on Windows, sent the task once per worker.
    • Threads: BLAS and OpenMP are set to one thread in the main session before the workers start, and restored afterwards. OpenBLAS otherwise starts one thread per core (128 on our server) in every worker: PCA + GLM on 8 cores took 68.8 s with 128 BLAS threads per worker and 7.4 s with one. Workers forked after the session has used several OpenMP threads can hang (randomSurvivalForest on 8 cores never finished; now 4 s).
    • No parallel functions are used: random number streams per split come from BiocParallel (identical to before), and RhpcBLASctl replaces parallel in Imports.
    • Tasks are handed out in about eight chunks per worker as workers become free.
    • Each split sets the random stream bpmapply would have given it, so results don't depend on the number or kind of workers.
    • runTests still uses any BiocParallel parameters it is given.
  • Interfaces:
    • .asDataFrame (the same data.frame, built directly);
    • numeric design matrices built directly (identical to model.matrix);
    • the SVM fitted to a matrix (the same model);
    • random forest vote shares counted per class;
    • fold models drop the importance-only forest.
  • Cox elastic net: .cvCoxnetC reproduces cv.glmnet(type.measure = "C") (same folds, fits and lambda) with a vectorised C-index that matches survival::concordance bit for bit.
  • Packaging: the stats and utils functions used are imported; the obsolete C++11 request is removed.

Do results change?

No. The regression harness (18 scenarios) gives results identical to part 5, apart from two differences in form only:

  • leave-one-out rankings are numerically equal but stored with another type;
  • the SVM's fitted model has class svm instead of svm.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:

1 core 8 cores 20 cores 32 cores
Procedure 1 (random forest, 100 × 5), devel 138.6 s 28.7 s (4.8×) 18.4 s (7.5×) 19.3 s (7.2×)
Procedure 1, this series 91.5 s 14.8 s (6.2×) 7.9 s (11.6×) 7.3 s (12.5×)
Procedure 2 step 4 (CoxNet, 7 views, 20 × 5), devel 624.1 s 113.3 s (5.5×) 64.0 s (9.7×) 55.9 s (11.2×)
Procedure 2 step 4, this series 178.1 s 25.3 s (7.0×) 12.3 s (14.4×) 10.6 s (16.8×)

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

ellispatrick and others added 3 commits October 10, 2026 04:52
…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
ellispatrick and others added 4 commits October 10, 2026 05:14
… 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
@ellispatrick

Copy link
Copy Markdown
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 pool is now a BiocParallel MulticoreParam, started once per call with bpstart(), with the task kept in .ClassifyRenvir so that only task numbers are sent. It is a SnowParam on Windows. The per-split RNG streams come from a serial bplapply with the cross-validation's RNGseed, which gives the same streams as before.
  • New: BLAS and OpenMP run one thread in each worker (RhpcBLASctl). OpenBLAS starts one thread per core in every worker otherwise, so 8 workers ran 128 threads each on our server.
  • Results: identical to the previous version of this branch in all 18 regression scenarios, at 1 and 8 cores. The tests pass on review/6, review/12, review/18 and integration.
  • Cost: MulticoreParam() and bpstart() each run a full gc() through showConnections(), adding about 1 s per crossValidate call. That is BiocParallel's own cost.

The same helper went into spicyR, lisaClust and Statial: SydneyBioX/spicyR#81, SydneyBioX/lisaClust#17 and SydneyBioX/Statial#18.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y1PuRkfDQ3ApzuNoe5ovxW

@DarioS
DarioS added this pull request to stack #148 October 28, 2026 02:13
@DarioS
DarioS marked this pull request as ready for review October 28, 2026 02:15

@DarioS DarioS left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

O.K.

@DarioS
DarioS merged commit cd815ec into review/5-maintenance Oct 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants