Skip to content

Commit f3ce0c5

Browse files
mattfidlerclaude
andauthored
linCmtB(): thread-safe forward-mode AD Jacobian solves (#1126)
* linCmtB(): thread-safe forward-mode AD Jacobian solves Forward-mode AD (the default linCmtB Jacobian path, sensType 3/30/auto) is stack-local fvar with no shared Stan arena, so parallel subject solves are safe. Two fixes enable it: - linCmtScaleInitPar/N/linCmtZeroJac read the current thread's __linCmtB slot (rx_get_thread) instead of the hardcoded [0] slot that another thread mutates and may resize during a parallel solve. - The notThreadLinCmtB solve-time guard now single-cores only the reverse-mode AD (31) and finite-difference paths; the forward-AD path threads like any thread-safe method. Validated: 80-subject linCmtB sensitivity solve is bitwise identical at cores=1 vs cores=4; reverse-AD path stays serial and correct. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * linCmtB(): regression test + NEWS for thread-safe forward-AD solves Add a parallel-vs-serial regression test (cores=1 == cores=2, bitwise) to the linCmtB sensitivity suite, refresh the stale "not thread safe" parse-time comment, and note the change in NEWS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * linCmtB(): skip finite-difference H setup on the AD paths setupLinH() ran shi21ForwardH() for the default AD sensType (3, and auto=100 which remaps to 3) -- a base solve plus several per-parameter probe solves used to size a finite-difference step. The AD Jacobian (linCmtFwdJac / reverse jacobian) never reads ind->linH, so those solves were pure waste on every linCmt sensitivity setup. Give linCmtSensIsAD() external linkage (its finite-difference set is exactly the sensTypes linCmtB reads ind->linH for) and early-out of setupLinH() for the AD paths with a constant linH fill. The now-dead case 3 shi21ForwardH branch is removed. Validated: AD linCmtB vs ODE reference unchanged (3.5e-7 < 1e-5); lincmt-solve (1623) and lincmt-solve-sens (9) pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * linCmtB(): address PR review -- shared sensType classifier, no narrowing - Move the linCmt sensitivity-method classifiers into a shared header src/linCmtSensType.h so linCmt.cpp, par_solve.cpp and rxData.cpp use one definition instead of a cross-TU forward declaration. Add linCmtSensForwardAdThreadSafe() (3/30/100) and document why auto (100) is safe -- setupLinH() remaps 100->3 and nothing resolves auto to reverse-mode AD or a finite-difference method -- and why reverse-mode AD (31) and the FD paths are excluded. The thread gate in rxData.cpp now calls this predicate. - Cast the __linCmtB pool size to int once per accessor (linCmtScaleInitPar/N, linCmtZeroJac) since rx_get_thread() takes an int, avoiding a narrowing conversion. - Skip the thread-safety regression test unless getRxThreads() >= 2 so it does not pass vacuously on a single-core runner. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 602960e commit f3ce0c5

7 files changed

Lines changed: 125 additions & 26 deletions

File tree

NEWS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@
8686
inside `linCmt()` and mixed `linCmt()`+ODE models, with Jacobian handling of
8787
the dosing events; `odeToLin()` preserves and renames them when converting.
8888

89+
- `linCmt()` sensitivity (`linCmtB`) solves now run in parallel across subjects
90+
on the default forward-mode AD Jacobian path (`linCmtSensType="AD"`), which is
91+
stack-local with no shared Stan arena. The reverse-mode AD (`"ADr"`) and
92+
finite-difference paths remain single threaded.
93+
8994
- Inductive linearization and matrix exponentials rewritten with a more
9095
NONMEM-like interface (automatic ODE->syntax translation retained) and
9196
symbolic-differentiation gradients.

src/linCmt.cpp

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
#include "timsort.h"
99
#define SORT gfx::timsort
1010
#include "linCmt.h"
11+
#include "linCmtSensType.h"
1112

1213

1314
extern rx_solving_options op_global;
@@ -395,17 +396,26 @@ extern "C" double linCmtA(rx_solve *rx, int id,
395396
#undef yp
396397
}
397398

399+
// These scaling/step-size helpers are read from the finite-difference H setup
400+
// (shi21ForwardH/gillForwardH) after ind_linCmtFH has populated THIS thread's
401+
// linCmtB slot, so they must read the same per-thread slot rx_get_thread()
402+
// selects -- not the hardcoded [0] slot, which another thread mutates (and may
403+
// resize) during a parallel linCmt solve. rx_get_thread() takes an int, so the
404+
// size_t pool size is cast once to avoid a narrowing conversion.
398405
extern "C" double linCmtScaleInitPar(int which) {
399-
return __linCmtB[0].lc.initPar(which);
406+
int tid = rx_get_thread((int)__linCmtB.size());
407+
return __linCmtB[tid].lc.initPar(which);
400408
}
401409

402410
extern "C" double linCmtScaleInitN() {
403-
Eigen::Matrix<double, Eigen::Dynamic, 1> theta = __linCmtB[0].lc.initPar();
411+
int tid = rx_get_thread((int)__linCmtB.size());
412+
Eigen::Matrix<double, Eigen::Dynamic, 1> theta = __linCmtB[tid].lc.initPar();
404413
return theta.size();
405414
}
406415

407416
extern "C" int linCmtZeroJac(int i) {
408-
return __linCmtB[0].lc.parDepV1(i);
417+
int tid = rx_get_thread((int)__linCmtB.size());
418+
return __linCmtB[tid].lc.parDepV1(i);
409419
}
410420

411421

@@ -484,20 +494,10 @@ extern "C" int linCmtZeroJac(int i) {
484494
* @author Matthew Fidler
485495
*
486496
*/
487-
// True for the automatic-differentiation jacobian methods (forward-mode
488-
// fvar = 3/30, reverse-mode = 31, and the auto/default path), which all want
489-
// the unscaled thetaSens + passthrough trueTheta. The finite-difference
490-
// methods (1,2,4,5,6,7,10,20,40,50) keep the scaled path.
491-
static inline bool linCmtSensIsAD(int sensType) {
492-
switch (sensType) {
493-
case 1: case 2: case 4: case 5: case 6: case 7:
494-
case 10: case 20: case 40: case 50:
495-
return false;
496-
default:
497-
return true;
498-
}
499-
}
500-
497+
// linCmtSensIsAD() (the AD jacobian classifier; forward-mode fvar 3/30,
498+
// reverse-mode 31, auto 100 -> unscaled thetaSens + passthrough trueTheta; the
499+
// finite-difference methods keep the scaled path) lives in linCmtSensType.h so
500+
// linCmt.cpp, par_solve.cpp and rxData.cpp share one definition.
501501
extern "C" double linCmtB(rx_solve *rx, int id,
502502
double _t, int linCmt,
503503
int ncmt, int oral0,

src/linCmtSensType.h

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#ifndef __LINCMTSENSTYPE_H__
2+
#define __LINCMTSENSTYPE_H__
3+
// Single source of truth for classifying the linCmt() sensitivity (Jacobian)
4+
// method encoded in rx->sensType. Shared by linCmt.cpp (thetaSens scaling),
5+
// par_solve.cpp (setupLinH step-size skip) and rxData.cpp (thread decision) so
6+
// the classification cannot drift between translation units.
7+
8+
// True for the automatic-differentiation Jacobian methods: forward-mode fvar
9+
// (3/30), reverse-mode (31) and the auto default (100). The finite-difference
10+
// methods (1,2,4,5,6,7,10,20,40,50) return false. linCmtB reads ind->linH only
11+
// on the finite-difference methods, so these are exactly the AD methods that do
12+
// not need finite-difference step-size estimation.
13+
static inline int linCmtSensIsAD(int sensType) {
14+
switch (sensType) {
15+
case 1: case 2: case 4: case 5: case 6: case 7:
16+
case 10: case 20: case 40: case 50:
17+
return 0;
18+
default:
19+
return 1;
20+
}
21+
}
22+
23+
// True for the forward-mode AD Jacobian methods that are safe to solve across
24+
// threads. 3 and 30 are explicit forward-mode fvar -- stack-local with no
25+
// shared AD state. 100 (auto) is included because it always resolves to the
26+
// forward-mode path: setupLinH() remaps 100 -> 3 and it is the only runtime
27+
// mutation of rx->sensType (nothing resolves auto to reverse-mode or a
28+
// finite-difference method). Reverse-mode AD (31) is deliberately excluded --
29+
// it uses Stan's shared ChainableStack. The finite-difference methods are
30+
// excluded -- they share a first-subject scaling/step-size setup that is not
31+
// per-thread.
32+
static inline int linCmtSensForwardAdThreadSafe(int sensType) {
33+
return (sensType == 3 || sensType == 30 || sensType == 100);
34+
}
35+
36+
#endif // __LINCMTSENSTYPE_H__

src/par_solve.cpp

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
#include "../inst/include/rxode2parseGetTime.h"
1818
#include "../inst/include/rxode2EventTranslate.h"
1919
#include "linCmtDiffConstant.h"
20+
#include "linCmtSensType.h"
2021

2122
#define SORT gfx::timsort
2223

@@ -6792,16 +6793,23 @@ void setupLinH(rx_solve *rx, int solveid,
67926793
rx_solving_options_ind *ind = &(rx->subjects[neq[1]]);
67936794
double *hh = ind->linH;
67946795
if (ind->linCmtHparIndex == -3) return; // already setup
6796+
if (linCmtSensIsAD(rx->sensType)) {
6797+
// AD Jacobians (forward-mode fvar 3/30, reverse-mode 31) are exact and
6798+
// never read ind->linH, so skip the finite-difference step-size estimation
6799+
// (shi21ForwardH/gillForwardH) -- it would run a base solve plus several
6800+
// probe solves per parameter that the AD path discards.
6801+
std::fill_n(ind->linH, 7, rx->sensH);
6802+
ind->linCmtH = NA_REAL;
6803+
ind->linCmtHparIndex = -3;
6804+
return;
6805+
}
67956806
switch (rx->sensType) {
67966807
case 1: // forward; shi difference
67976808
shi21ForwardH(rx, op, solveid, neq, dydt, u_inis);
67986809
break;
67996810
case 2: // central; shi
68006811
shi21CentralH(rx, op, solveid, neq, dydt, u_inis);
68016812
break;
6802-
case 3: // 3pt forward; shi
6803-
shi21ForwardH(rx, op, solveid, neq, dydt, u_inis);
6804-
break;
68056813
case 4: // 5-point endpoint difference; shi
68066814
shi21CentralH(rx, op, solveid, neq, dydt, u_inis);
68076815
for (int i = 0; i < 7; i++) {

src/print_node.h

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,8 +193,9 @@ static inline int nodeFunLinCmtB(char *value) {
193193
sAppendN(&sbt,"linCmtB", 7);
194194
tb.linCmt=2;
195195

196-
// right now linCmtB isn't thread safe,
197-
// the Jacobian can cause a null free in stan math currently.
196+
// Mark the model as linCmtB-bearing. The solve-time thread decision
197+
// (rxData.cpp) threads the forward-mode AD Jacobian path and keeps the
198+
// reverse-mode AD (stan shared arena) and finite-difference paths serial.
198199
if (tb.thread == threadSafe) {
199200
tb.thread = notThreadLinCmtB;
200201
}

src/rxData.cpp

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ extern "C" void ensureRworkPool(int nCores, int lrw, int liw);
5757
#include "rxThreadData.h"
5858

5959
#include "threadSafeConstants.h"
60+
#include "linCmtSensType.h"
6061
//#include "seed.h"
6162

6263
SEXP rxSaveState_(); // defined in rxSerialize.cpp
@@ -5937,6 +5938,13 @@ SEXP rxSolve_(const RObject &obj, const List &rxControl,
59375938
} else {
59385939
op->cores = asInt(rxControl[Rxc_cores], "cores");
59395940
int thread = INTEGER(rxSolveDat->mv[RxMv_flags])[RxMvFlag_thread];
5941+
// linCmtB is thread safe on the forward-mode AD Jacobian path: each thread
5942+
// evaluates its own subject on its own __linCmtB[rx_get_thread()] slot with
5943+
// stack-local fvar and no shared AD arena. linCmtSensForwardAdThreadSafe()
5944+
// (linCmtSensType.h) is the shared classifier -- it excludes reverse-mode
5945+
// AD (31, Stan's shared ChainableStack) and the finite-difference paths
5946+
// (first-subject scaling/step setup), which keep the single-core guard.
5947+
int linCmtBThreadSafe = linCmtSensForwardAdThreadSafe(rx->sensType);
59405948
if (op->cores == 0) {
59415949
switch (thread) {
59425950
case threadSafeRepNumThread:
@@ -5957,8 +5965,13 @@ SEXP rxSolve_(const RObject &obj, const List &rxControl,
59575965
rxSolveDat->throttle = false;
59585966
break;
59595967
case notThreadLinCmtB:
5960-
op->cores = 1;
5961-
rxSolveDat->throttle = false;
5968+
if (linCmtBThreadSafe) {
5969+
op->cores = getRxThreads(INT_MAX, false);
5970+
rxSolveDat->throttle = true;
5971+
} else {
5972+
op->cores = 1;
5973+
rxSolveDat->throttle = false;
5974+
}
59625975
break;
59635976
}
59645977
} else {
@@ -5979,8 +5992,13 @@ SEXP rxSolve_(const RObject &obj, const List &rxControl,
59795992
rxSolveDat->throttle = false;
59805993
break;
59815994
case notThreadLinCmtB:
5982-
op->cores = 1;
5983-
rxSolveDat->throttle = false;
5995+
if (linCmtBThreadSafe) {
5996+
// Thread safe (forward-mode AD); keep the user-requested core count.
5997+
rxSolveDat->throttle = true;
5998+
} else {
5999+
op->cores = 1;
6000+
rxSolveDat->throttle = false;
6001+
}
59846002
break;
59856003
}
59866004
}

tests/testthat/test-lincmt-solve-sens.R

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,35 @@ rxTest({
117117
expect_equal(outOde[[col]], outLin[[col]], tolerance=1e-5)
118118
})
119119
}
120+
121+
test_that("linCmtB forward-AD solves are thread safe (parallel == serial)", {
122+
skip_on_cran()
123+
# Without >= 2 available threads the cores = 2 request cannot exercise the
124+
# parallel path, so the comparison would pass vacuously; skip instead.
125+
# rxode2 exposes no per-solve "cores actually used" telemetry, so gate on
126+
# thread availability (this model's linCmtB carries the forward-AD flag that
127+
# the cores = 2 request now honors -- see rxData.cpp thread decision).
128+
skip_if_not(rxode2::getRxThreads() >= 2L)
129+
.nsub <- 60
130+
set.seed(1)
131+
.params <- data.frame(
132+
id = seq_len(.nsub),
133+
"THETA[1]" = log(4), "THETA[2]" = log(70), "THETA[3]" = log(1),
134+
"THETA[4]" = 0.1,
135+
"ETA[1]" = rnorm(.nsub, 0, 0.3),
136+
"ETA[2]" = rnorm(.nsub, 0, 0.3),
137+
"ETA[3]" = rnorm(.nsub, 0, 0.3),
138+
check.names = FALSE)
139+
.ev <- et(amt = 100, ii = 12, until = 48) |>
140+
et(seq(0, 48, by = 1)) |>
141+
et(id = seq_len(.nsub))
142+
.s1 <- rxSolve(lin, params = .params, events = .ev, cores = 1,
143+
returnType = "data.frame")
144+
.s2 <- rxSolve(lin, params = .params, events = .ev, cores = 2,
145+
returnType = "data.frame")
146+
.cols <- grep("sens_rx_pred.*ETA|^rx_pred_$", names(.s1), value = TRUE)
147+
for (.col in .cols) {
148+
expect_identical(.s1[[.col]], .s2[[.col]])
149+
}
150+
})
120151
})

0 commit comments

Comments
 (0)