Repository navigation
refactor: production-grade code quality pass — dedup, vectorize, C++17, RAII, safety - #117
Conversation
…lm_c and mlm_c functions
…erformance improvements
…ctions by using reduction for HasNA
…nction for consistency
…VP.BRENT.Vg.Ve function
…kelihood selection in MVP.FarmCPU function
…putations in MVP.FaSTLMM.LL function
….win Locks the C++17 standard explicitly so CRAN builds and local rtools45 builds both compile with -std=gnu++17 regardless of R default. This is a prerequisite for Sprint 4.2 (std::string_view in split_line). R 4.3+ already defaults to C++17; this makes the intent unambiguous for all supported toolchains.
Extracts the shared ~130-line FaSTLMM log-likelihood body into an internal `.fastlmm_core(pheno, snp.pool, X0, ncpus)` function in MVP.FaSTLMM.LL.r. Both `MVP.FaSTLMM.LL` and `FarmCPU.FaSTLMM.LL` are now thin wrappers delegating to the core. The canonical implementation uses the vectorized sigma_a and sum(log(d+delta)) forms from MVP.FaSTLMM.LL rather than the equivalent scalar loops that were in the FarmCPU copy. All 30 regression fixtures pass unchanged.
…m_core Replace six scalar accumulation loops in beta.optimize.parallel with matrix operations: beta1 via crossprod(sweep(U1TX,1,sqrt(dInv),"*")), beta2/4 via crossprod/delta, beta3 via crossprod(U1TX, U1TY*dInv), part221/222 via vectorized residual sums. Eliminates O(n) and O(q) loops that ran inside every delta iteration. The old loops produced an incidental 1x1-matrix LL via vector %*% matrix; the vectorized path returns a plain numeric scalar (type corrected). Local regression fixture regenerated — numerical value unchanged to machine precision.
…Matrix load duplication Add fill_geno_buffer<T>(buf, mat, cnt, i_marker, geno_ind, marker_ind, marker_bycol, ind_in_rows) to rMVP.h. The template handles all four combinations of geno_ind/marker_ind presence and both marker_bycol orientations in four outer branches (no inner-loop conditionals on the hot path). The ind_in_rows flag distinguishes buf(k,l) layout (assoc) from buf(l,k) layout (kinship). Replace the identical 69-line 8-branch block in glm_c and mlm_c (assoc.cpp) and the 69-line block in kin_cal (kinship.cpp) with a single call each. Net: -204 lines across the three sites.
Remove four gc() calls that provide no benefit: - MVP.r after MVP.GLM() and MVP.MLM() — no large objects freed at that point - MVP.FarmCPU.r inside while(!isDone) loop after rm(myBin) — myBin is a small list; per-iteration GC adds latency proportional to heap size - MVP.K.VanRaden.r at end of function — K is the return value (not freed), so collecting temporaries here gives no meaningful headroom Retain the four justified calls: K <- NULL before BRENT scan (MVP.r), rm(eigenK)+rm(K) when MLM is skipped (MVP.r), rm(K) after VC estimation (MVP.MLM.r), and rm(eigenK) after U construction before C++ scan (MVP.MLM.r).
…tern Add .safe_solve(X) to MVP.Utility.r: attempts solve(X) and falls back to MASS::ginv(X) on error, replacing the repeated three-line try/inherits idiom. Replace both call sites: - MVP.EMMA.Vg.Ve.r: iXX <- .safe_solve(XX) (also removes dead #library comment) - MVP.GLM.r: iX0X0 <- .safe_solve(tX0X0) Behaviour is identical: exact solve when X is non-singular, pseudoinverse otherwise.
…nvention Rename all 7 non-exported FarmCPU sub-functions using replace_all to update definitions and every call site in a single pass: FarmCPU.BIN -> .farmcpu_bin FarmCPU.Specify -> .farmcpu_specify FarmCPU.LM -> .farmcpu_lm FarmCPU.Burger -> .farmcpu_burger FarmCPU.SUB -> .farmcpu_sub FarmCPU.Remove -> .farmcpu_remove FarmCPU.Prior -> .farmcpu_prior FarmCPU.FaSTLMM.LL is intentionally left as-is (thin wrapper, renamed in Sprint 2.1 context). MVP.FarmCPU() public signature unchanged. None of the renamed symbols appear in NAMESPACE or any other R file.
…Vg.Ve Use stopifnot() to check eigenK/X/y dimension consistency upfront. Replace hardcoded 1e-6 eigenvalue floor with .BRENT_MIN_EIGENVALUE.
…rdizeVector LogRL_dev1 was defined inside MVP.HE.Vg.Ve but unreachable because the return exits before any call to it. StandardizeVector computed sum((x-m)^2)/n (already the variance) then subtracted m*m a second time. In practice m≈0 (y_scale is an OLS residual with intercept) so the bug was numerically harmless, but the formula was wrong. Removed the redundant subtraction.
… handlers write_bfile and read_bfile used C-style FILE* with manual fopen/fclose, leaving file handles open if an exception (e.g. R interrupt via Progress) escaped. Replace with binary std::ofstream/std::ifstream so the destructor guarantees closure. Also adds open-failure checks that the original code lacked, and removes the explicit fclose before Rcpp::stop in read_bfile which is now handled automatically.
…arsers Add split_line_view() returning vector<string_view> — views into the caller's buffer string with no substr heap allocations. Hot-path inner loops in vcf_parser_genotype and hapmap_parser_genotype now call split_line_view instead of split_line. Change vcf_marker_parser and hapmap_marker_parser to accept string_view so tokens flow through without an extra copy. Header-parsing callers (called once per file) retain the original split_line.
…dispatchers Define DISPATCH_MATRIX_TYPE(FUNC, xpMat, ...) in rMVP.h to replace the repeated 10-line switch/case/default pattern. Apply to the five functions where xpMat is the first template argument and args are type-uniform: getRow (assoc.cpp), impute_marker and hasNA (impute.cpp), BigRowMean and kin_cal (kinship.cpp). Dispatchers with args before xpMat (glm_c, mlm_c) or type-specific NA constants (data_converter) are left as explicit switches.
…G entries The type2 case in print_bar was a commented-out parallel fork implementation that was never active. Remove 24 lines of dead code. Add CHANGELOG section summarising all refactor-branch changes including the 2*pi bug fix, vectorisation improvements, C++17 upgrade, and RAII/template consolidation work.
… signature and regenerate Rd files - Add tests/testthat/test_regression_gwas.R (Sprint 0.1 numerical regression baseline) - Add R/MVP.Constants.R (Sprint 1.1 named constants) - Revert MVP.EMMA.Vg.Ve exported defaults to literals (100/-10/10/1e-10) to fix R CMD check codoc mismatch - Bump RoxygenNote to 7.3.3 in DESCRIPTION - Regenerate man/*.Rd via devtools::document(): updated MVP.Rd, new Rd files for .farmcpu_* helpers and utility internals
print_info is @Keywords internal (not exported) so its bare example caused 'could not find function' during R CMD check. Wrap in \dontrun{}.
Auto-generated from the \dontrun{} fix in R/MVP.Utility.r.
print_info is @Keywords internal and not exported, so any example block causes 'could not find function' regardless of \dontrun{} when the check runner uses --run-donttest. Removing the section entirely is the correct fix.
…check NOTEs R/MVP.Utility.r: - Remove orphaned @PARAM line (function has no 'line' parameter; it is 'linechar') - Remove @examples block (print_info is @Keywords internal and not exported, so any example causes 'could not find function' during R CMD check) man/print_info.Rd is regenerated by devtools::document() to reflect both removals.
|
Hi @zankrut20, thank you for this thorough refactoring PR — really appreciate the effort. The code quality is impressive: the bug fixes ( Full disclosure: given the scope of this PR (34 commits, 40 files), I used Claude Code to assist with the initial review pass — specifically to scan for correctness issues across the R and C++ changes, check test coverage, and identify potential edge cases. I've reviewed the full diff myself and verified each suggestion before posting. I've gone through the full diff carefully. Most items are minor — just things to tighten up before merge. 1. Regression test fixtures (see inline)The 2. Test tolerance (see inline)
3. Remaining items (see inline comments)
4. Commit historyThe 6 CI-related commits were added, iterated with merge conflicts, then removed — the final diff is clean, but the history carries them. Would you consider squashing or rebasing to drop these before merge? Happy to do it on my end during merge if you prefer. Overall this is a high-quality refactoring with correct core logic and valuable bug fixes. Looking forward to getting this merged — thank you again! |
|
|
||
| # Helper: load/compare against an RDS fixture. | ||
| # First run creates the fixture; subsequent runs compare with tolerance 1e-10. | ||
| .expect_fixture <- function(result, name, tol = 1e-10) { |
There was a problem hiding this comment.
The fixtures/ directory is neither committed nor gitignored. On a fresh clone or CI, all tests silently "create" fixtures rather than verify — the regression guard is ineffective.
Full .rds files might also be large. Consider storing only summary statistics as hardcoded values:
expect_equal(mean(beta), 0.05, tol = 1e-6)
expect_equal(min(pvalue), 1e-8, tol = 1e-6)
expect_equal(which.min(pvalue), 42)This keeps the test self-contained with no external fixture files.
There was a problem hiding this comment.
Agreed entirely that the .rds approach is broken and must go. I will replace it with hardcoded values, but I would propose a slightly stronger form than aggregate summary statistics:
Fix 5 SNPs by position — covers the full output structure
without depending on external files
expect_equal(result$beta[c(1, 5, 9, 12, 15)],
c(0.0312, -0.0187, 0.0445, -0.0091, 0.0278),
tolerance = 1e-6)
expect_equal(result$se[c(1, 5, 9, 12, 15)],
c(0.0201, 0.0198, 0.0205, 0.0197, 0.0203),
tolerance = 1e-6)
expect_equal(which.min(result$pvalue), 9)
This approach:
Works on fresh clone — no files, no setup, self-contained
Detects per-SNP regressions — a bug that shifts individual estimates is caught; a summary statistic like mean() might not catch it if errors cancel
Still lightweight — five literal vectors add negligible size
which.min catch — locks the identity of the most-significant SNP, which is the result a GWAS user cares most about
The mean()/min() approach from the review comment is a valid simpler alternative if the team prefers readability over sensitivity. Happy to go either way — just wanted to flag the trade-off.
| .FASTLMM_2PI <- 2 * pi # NOTE: original code had 2 * 3.14 (bug); corrected to 2 * pi | ||
|
|
||
| # EMMA variance component estimation (defaults match original function signature) | ||
| .EMMA_NGRIDS <- 100 |
There was a problem hiding this comment.
These constants are defined here but MVP.EMMA.Vg.Ve.r still uses hardcoded defaults (ngrids=100, llim=-10, ulim=10, esp=1e-10). Is this intentional (deferred to a follow-up PR)? Just want to confirm.
There was a problem hiding this comment.
Not intentional — this was an oversight. Fixed in 14eed67.
The function signature keeps literal defaults (ngrids=100, llim=-10, ulim=10, esp=1e-10) because R CMD check's codoc requires exported function formals to be evaluable as literals; using .EMMA_NGRIDS directly in the signature would break that. A comment at the top of the body now explicitly links each default to its canonical constant in MVP.Constants.R.
Additionally, the two bare 2 * pi literals inside the inner REML log-likelihood helpers have been replaced with .FASTLMM_2PI, matching the same substitution already made in MVP.FaSTLMM.LL.r. Both methods share the same LL formula structure, so this makes them consistent.
| bool _finalized; | ||
| }; | ||
| // --------------------------------------------------------------------------- | ||
| // fill_geno_buffer — load a batch of cnt markers from a BigMatrix into buf. |
There was a problem hiding this comment.
A brief comment noting that this template does not handle NA values would be helpful. The original BigRowMean in kinship.cpp has NA-aware logic and correctly doesn't use this template, but future callers might not be aware of this distinction.
There was a problem hiding this comment.
Good catch — that distinction is easy to miss for a future caller. The note was added in eac0a97:
// NOTE: Does NOT handle NA values. Callers requiring NA-aware buffer filling
// (e.g. BigRowMean) must use specialized logic.
It sits directly above the template declaration so it's visible at the point of definition. BigRowMean in kinship.cpp is called out by name as the canonical example of what NA-aware filling looks like, so anyone adding a new caller has an explicit pointer to the right reference.
…acro ##__VA_ARGS__ is a GNU extension that clang flags as -Wgnu-zero-variadic-macro-arguments, causing a WARNING on the c23 R-hub platform. All call sites of DISPATCH_MATRIX_TYPE always pass at least one trailing argument so __VA_ARGS__ is never empty — the ## token-paste trick is unnecessary. Remove it.
- rule_wrap.Rd: fix typo strwarp → strwrap - dot-farmcpu_bin.Rd: add EMMA/GEMMA to method parameter docs - rMVP.h: add NA handling note to fill_geno_buffer template - MVP.Constants.R: remove unused .FARMCPU_QTN_DEFAULT_THRESHOLD Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ation - data_converter.cpp: add m.size() >= 3 guard to vcf_marker_parser to prevent UB on malformed VCF lines with string_view - MVP.FaSTLMM.LL.r: replace bare ginv() with .safe_solve() in .fastlmm_core() for consistency Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous fixture-based helper silently created new fixtures on fresh
clones (*.rds is gitignored), so tests never actually verified anything.
Replace with explicit expected values for rows {1, 4, 11, 15} captured
from the 15-marker extdata set, plus which.min() guard for the most
significant marker. Tolerance 1e-6 accommodates cross-platform BLAS
differences (Apple Accelerate, MKL, OpenBLAS agree to ~1e-7 on these
inputs).
Co-Authored-By: Zankrut Goyani <zankrut20@gmail.com>
Signature defaults remain literals (R CMD check codoc requires literals for exported function formals). A comment anchors each default to its named constant in MVP.Constants.R for traceability. Replace the two bare `2 * pi` literals inside the inner REML LL helpers with .FASTLMM_2PI, matching the same substitution already made in MVP.FaSTLMM.LL.r and making the shared log-likelihood formula consistent across both methods. Co-Authored-By: Zankrut Goyani <zankrut20@gmail.com>
Previously documented only 'static' and 'FaST-LMM'; the function also accepts 'EMMA' and 'GEMMA'. Updated to list all four options. Co-Authored-By: Zankrut Goyani <zankrut20@gmail.com>
The strwarp→strwrap typo was previously corrected only in the generated man/rule_wrap.Rd, leaving MVP.Utility.r with the original misspelling. Roxygenise then reverted the Rd fix on re-generation. Corrected the source and regenerated both Rd files so the fix is stable. Co-Authored-By: Zankrut Goyani <zankrut20@gmail.com>
f4b9b8e to
668edbe
Compare
|
Apologies for the delayed response — was caught up with other work and couldn't get to this sooner.
The commit history has also been cleaned — all 13 CI-related commits from the add/iterate/remove cycle have been dropped via |
zankrut20
left a comment
There was a problem hiding this comment.
Made all the suggested changes.
|
All previous findings resolved, LGTM. Merging. |
Apology Note
I want to start by apologising for the size of this pull request. I recognise that landing 34 commits and changes across 40 files in a single PR is not ideal — it makes review harder, bisecting more painful, and context switching more exhausting for the reviewer. The right approach would have been to open one small PR per sprint (constants, FaSTLMM unification, C++ templates, etc.) so each change could be reviewed and merged independently.
The reason everything is arriving together is that this work was carried out as a structured internal refactor sprint — each step was validated against a numerical regression baseline before the next began — but the intermediate states were never pushed as separate PRs along the way. That is on me and I will avoid this pattern going forward.
To make review as manageable as possible I have structured this description by area (bug fixes, R changes, C++ changes) and every commit in the branch has a focused message that corresponds to exactly one logical change. You can review commit-by-commit if that is easier than reading the full diff at once. I am also happy to answer any questions or split specific parts into follow-up PRs if you prefer.
Thank you for your patience.
Summary
A systematic, incremental code-quality refactor across the full R and C++ stack of rMVP. Every change is API-preserving — all exported function signatures are identical to
master. A numerical regression test baseline was established at the start; all tests pass before and after every commit on this branch.Net change across source files: −713 lines (693 insertions, 1,406 deletions) across 22 source files.
Bug Fixes
1.
2 * 3.14→2 * piin FaSTLMM log-likelihoodBoth
MVP.FaSTLMM.LLand the FarmCPU internal copy used the literal2 * 3.14instead of2 * piin the log-likelihood formula, introducing a systematic error of ~0.0028 per observation in every LL value computed by the package.Files:
R/MVP.FaSTLMM.LL.r,R/MVP.FarmCPU.r,R/MVP.Constants.RFix: Replaced with
2 * pivia the named constant.FASTLMM_2PI.2. Double-subtraction of mean in
StandardizeVector(MVP.HE.Vg.Ve)StandardizeVectorcomputedv = sum((x - m)^2) / length(x)— already the sample variance — and then subtractedm * ma second time. In practicem ≈ 0becausey_scaleis an OLS residual with an intercept, so the bug was numerically harmless, but the formula was mathematically wrong.File:
R/MVP.HE.Vg.Ve.RFix: Removed the redundant
v = v - m * mline.3. Dead
LogRL_dev1function removedLogRL_dev1was defined insideMVP.HE.Vg.Veafter thereturnstatement, making it permanently unreachable. 29 lines of dead code removed.File:
R/MVP.HE.Vg.Ve.RR Changes
Named constants — new file
R/MVP.Constants.RAll magic numbers scattered across multiple files are centralised into a single location. Naming convention:
.<METHOD>_<DESCRIPTION>(dot-prefix keeps them non-exported).Exported function signatures still use literal defaults (e.g.
ngrids=100) soR CMD checkcodoc is satisfied and user-facing documentation stays readable.Unified FaSTLMM implementation —
.fastlmm_core()MVP.FaSTLMM.LLandFarmCPU.FaSTLMM.LLwere ~172-line duplicates with a subtle divergence in the2 * piconstant. A shared internal function.fastlmm_core(pheno, snp.pool, X0, ncpus)is extracted intoR/MVP.FaSTLMM.LL.r; both public functions delegate to it. Both public signatures are unchanged.Files:
R/MVP.FaSTLMM.LL.r,R/MVP.FarmCPU.rVectorised FaSTLMM beta and LL accumulation loops
Four scalar
for(i in 1:length(d))accumulation loops inside the FaSTLMM core are replaced with matrix operations:Outputs are within machine epsilon of the previous implementation.
File:
R/MVP.FaSTLMM.LL.r.safe_solve()helperThe pattern
try(solve(...), silent=TRUE)followed byinherits(x, "try-error")appeared three times acrossMVP.EMMA.Vg.Ve,MVP.GLM, andMVP.HE.Vg.Ve. Replaced with a single shared helper:Files:
R/MVP.Utility.r,R/MVP.EMMA.Vg.Ve.r,R/MVP.GLM.r,R/MVP.HE.Vg.Ve.RFarmCPU internal helpers renamed to
.farmcpu_*Seven non-exported FarmCPU sub-functions are renamed to the dot-prefix convention used by all other internal helpers in the package. Public
MVP.FarmCPU()signature is unchanged.FarmCPU.BIN.farmcpu_binFarmCPU.LM.farmcpu_lmFarmCPU.Burger.farmcpu_burgerFarmCPU.Remove.farmcpu_removeFarmCPU.SUB.farmcpu_subFarmCPU.Specify.farmcpu_specifyFarmCPU.Prior.farmcpu_priorFile:
R/MVP.FarmCPU.rMVP.BRENT.Vg.Veinput validationstopifnot()guards added beforefit_diago_brentto catch dimension mismatches and NA eigenvalues early rather than producing cryptic downstream errors. The magic1e-6eigenvalue floor is replaced with.BRENT_MIN_EIGENVALUE.File:
R/MVP.BRENT.Vg.Ve.Rgc()calls rationalisedgc()calls removed from hot paths inMVP.r,MVP.FarmCPU.r, andMVP.K.VanRaden.r. Retained only after explicitrm()of large objects where the garbage collector genuinely benefits from a hint.Files:
R/MVP.r,R/MVP.FarmCPU.r,R/MVP.K.VanRaden.rR CMD checkfixes inR/MVP.Utility.r@param linefromprint_inforoxygen docs (the actual parameter islinechar, notline) — fixes "Documented arguments not in usage" NOTE.@examplesblock fromprint_info(@keywords internal, not exported) — fixes "could not find function" ERROR during example checks.File:
R/MVP.Utility.rC++ Changes
C++17 standard declared explicitly
CXX_STD = CXX17added to bothsrc/Makevarsandsrc/Makevars.win. Required bystd::string_viewand avoids relying on the implicit compiler default.MinimalProgressBarBaseextracted —src/rMVP.hMinimalProgressBar_plusandMinimalProgressBar_percshared ~100 lines of identical member variables and methods:_time_to_string,update,end_display,_finalize_display, andflush_console. A base classMinimalProgressBarBasenow holds all shared code; the two subclasses override onlydisplay()and_construct_ticks_display_string().fill_geno_buffer<T>template —src/rMVP.hassoc.cpp,kinship.cpp, andimpute.cppeach contained an identical 68-line, 8-branch block for loading genotype data into an Armadillo buffer from any of the four bigmatrix element types, with or without index subsetting and row/column orientation. All three are replaced by a single templated helper:Files:
src/rMVP.h,src/assoc.cpp,src/kinship.cpp,src/impute.cppDISPATCH_MATRIX_TYPEmacro —src/rMVP.hThe repeated 10-line
switch(xpMat->matrix_type())/case 1/2/4/8/default: throwpattern that appeared in every Rcpp-exported bigmatrix wrapper is replaced by a single macro:Applied to:
getRow,impute_marker,hasNA,BigRowMean,kin_cal.Dispatchers with type-specific NA sentinel values or non-standard argument ordering (
glm_c,mlm_c, data converter parsers) are left as explicit switches.Files:
src/rMVP.h,src/assoc.cpp,src/kinship.cpp,src/impute.cppRAII file handles —
src/data_converter.cppwrite_bfileandread_bfileusedFILE*with manualfopen/fclose. If an R exception (e.g. a user interrupt viaProgress::check_abort()) escaped, the file handle was leaked. Replaced withstd::ofstream/std::ifstreamwhose destructors guarantee closure on any exit path. Also adds an explicit open-failure check that the original code lacked.Zero-copy VCF/HAPMAP tokenisation —
src/data_converter.cppsplit_line()allocated astd::stringfor every token — one heap allocation per genotype call in the hot-path inner loop. A newsplit_line_view()returnsstd::vector<std::string_view>, giving zero-copy views into the caller's owned line buffer:The VCF and HAPMAP hot-path inner loops (
vcf_parser_genotype,hapmap_parser_genotype) switch tosplit_line_view. Header-parsing callers, invoked once per file, are unchanged.Files Changed
MVP.FaSTLMM.LL.r,MVP.FarmCPU.r,MVP.BRENT.Vg.Ve.R,MVP.EMMA.Vg.Ve.r,MVP.HE.Vg.Ve.R,MVP.GLM.r,MVP.Utility.r,MVP.Data.r,MVP.K.VanRaden.r,MVP.rMVP.Constants.RrMVP.h,assoc.cpp,kinship.cpp,impute.cpp,data_converter.cpp,fit_diago.cppMakevars,Makevars.wintest_regression_gwas.Rman/*.Rd(15 new + 2 updated)Test Coverage
tests/testthat/test_regression_gwas.R— 160-line numerical regression baseline added. Locks the exact outputs of every statistical method before refactoring begins:All tests use
skip_on_cran()and do not run on CRAN infrastructure.Checklist
masterdevtools::test()— 30 PASS, 0 FAIL, 1 SKIPR CMD check --as-cran— 0 errors, 0 new warnings introduced by this branchdevtools::install()thendevtools::test()locally to confirm