Fit a grouped call's levels under the plan, and stop the formula exporting the session (#338, #329) - #347
Conversation
…environment bnec_group() dispatched its levels with a for loop whatever future plan was set. It now goes through the same dispatcher the model loop uses, renamed bnec_parallel_lapply() because it serves both, and plan_group_levels() decides which of the two loops the plan drives by counting the rounds each arrangement takes. A nested plan drives both and is honoured without being counted. Each parallel level is given its own cmdstanr compile directory, because every level fits the same equations and cmdstanr does not lock its cache (#338). A formula records the environment it was created in, and serialising it writes that environment out in full, so a parallel fit sent the calling session to every worker and every saved fit kept it. narrow_formula_environment() rebuilds it to hold only the names the formula mentions and the data does not supply, before the model frame is built, in bnec(), amend() and bnec_group() (#329). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B7T9mkqxuRAKcdXmN5pctD
…the walk stopped Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B7T9mkqxuRAKcdXmN5pctD
…y the compile cache on the worker
- Every level is given a seed realised in the calling session and derived from
`seed` where one was passed, so the arrangement no longer decides the
model-averaged estimates and the worker count cannot change an answer.
- The level futures carry their own subset rather than the whole data frame,
which was L times the transfer the levels between them need.
- The compile directory is keyed on the process, not the level, so a worker
that draws three levels compiles each equation once rather than three times.
- A list plan whose first strategy is sequential now asks for the release
arrangement explicitly, where before it fitted both loops in sequence and
said nothing.
- An unbounded worker count is counted rather than reported as unreadable.
- do.call("bnec", ...) by name, so an error raised in a level names bnec().
- A NULL binding is kept rather than deleted by the formula narrowing.
- The nested-plan recipe asserts its inner worker count with I(), without
which parallelly refuses it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B7T9mkqxuRAKcdXmN5pctD
f823fe3 to
2f29d0c
Compare
…level as it starts - fit_level() calls set.seed() on every path, so bnec_group() now saves and restores .Random.seed and the generator kind around the whole loop. Without it a sequential grouped call left the session at the last level's seed, and at a constant where `seed` was supplied. - On the release arrangement the levels are dispatched one at a time and announced before each starts. Dispatching them together put every per-level message at the end, because a sequential future relays its conditions at collection. - The realised level seeds are stored on the returned object, and the sampler kind is pinned where a seed was supplied, so the same seed means one thing. - A list plan with nothing parallel after the first strategy is not read as a request. - Inf reads as unbounded in the message as well as in the count. - dots[["seed"]] rather than dots$seed, which partial-matches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B7T9mkqxuRAKcdXmN5pctD
…pler - level_seeds and weights_method are documented as slots of bayesnecgroupfit, and ?bnec_group says the realisation is kept and why. - set.seed() names the generator as well as the sampler, so one seed means one thing in a session set to L'Ecuyer-CMRG as well as to the pre-3.6.0 sampler. - The concurrent message no longer names the next strategy where that strategy is sequential. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B7T9mkqxuRAKcdXmN5pctD
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B7T9mkqxuRAKcdXmN5pctD
A grouped call under a plan cost a Stan compilation per worker and did not return inside testthat on this host. The cross-arrangement equality it was asserting is a property of the per-level seeding, which is pinned without fitting, so the fitting test now runs with no plan: 173 s, two real fits, and it asserts the per-level rows, the ordering, the stored seeds and the narrowed formula. The measurements are recorded in its comment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtT7CUAA7JYq8Nz19NoBJk
|
The change was reviewed over four rounds by an independent reviewer working from The findings that matteredWrong answersA grouped call's model-averaged estimates Wasted transfer, wasted compilation, a disturbed RNG streamEach level future was sent the whole data frame: 38.71 MiB over seven levels Documented calls that did not do what they saidThe nested plan Every finding, and what was done with itFirst round
Second round, on the revision
Third round
Raised and not doneA test that builds the recipe's nested clusterThe reviewer measured that Deleting the per-worker compile directories after useThat gives back the |
The help page had 927 words on fitting the levels in parallel, 53 per cent of
its details block, where ?bnec's equivalent section is 449 and defers the
worked treatment to vignette("example2"). The mechanics -- why the inner
worker count needs I(), the nested and sequential-outer plans, the connections
warning, the per-worker compile directories and what a level worker holds --
now sit with the rest of the parallel material in example2, under a new section
at the end of it. ?bnec_group keeps the rule, the reproducibility guarantee and
level_seeds, which are properties of the call and of the returned object.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtT7CUAA7JYq8Nz19NoBJk
Two test defects, both found on CI and neither in the package. An absolute byte bound on a narrowed formula and on the model frame built from it held here and not on any CI platform, where the frame measured 1.2 MiB against 0.0032 MiB locally through the same test_check path. What a serialised environment weighs depends on the rest of its chain, so the tests now assert that the large object is no longer reachable from the formula, and compare each size against the unnarrowed object rather than against a constant. And dirname() returns forward slashes where tempdir() returns the platform separator, so three path assertions compared equal paths that differed by separator alone on Windows. They normalise both sides. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtT7CUAA7JYq8Nz19NoBJk
|
CI runs were failing repeatedly following the Claude solution. Codex sol (high) fixed this. Changes:
Verification:
|
What
Two changes to what reaches a
futureworker, which land together because thefirst multiplies the second.
bnec_group()fitted its levels one after another whatever plan was set. It nowdispatches them through the dispatcher the model loop already uses, and decides
which of the two loops the plan drives (#338).
A formula records the environment it was created in, and serialising it writes
that environment out in full. Every fit therefore sent the calling session to
every worker, and kept it in the saved fit. The formula's environment is now
rebuilt to hold only the names the formula mentions and the data does not
supply (#329).
A grouped call also gives different numbers than it did. Every level is now
given a seed realised in the calling session, so that the arrangement cannot
change the estimates; the reason is under Reproducibility below.
Why it matters
A grouped call multiplies the model set by the number of levels, so it is where
a model-averaged analysis stops being practical. Seven herbicides at eleven
equations is 77 fits.
The export made the parallel path unusable from any session that already holds a
fit, which is what precompiling a vignette does: it failed at
future.globals.maxSizerather than merely slowing. A grouped call would havesent that environment once per level as well as once per model, so #338 without
#329 multiplies a failure. Two further consequences of the same fix: a saved fit
no longer stores the session it was fitted in, and
amend()no longer exportsit either, by way of the
.Environmentof the model frame'stermsattribute.One premise of #338 does not survive measurement, and it changes what the level
loop buys. One plan drives one loop, because
futureevaluates a nested futuresequentially unless the plan is a list, so dispatching the levels takes the
workers away from the model loop rather than adding to them. Counting the two in
rounds of one fit, the levels take
ceiling(L / W) * Mand the modelsL * ceiling(M / W). The level arrangement cannot go below M rounds howevermany workers there are, and the model arrangement reaches L, so with fewer
levels than equations the levels are the smaller count only in a band. Seven
levels of eleven equations at the four cores #338 measured is 22 rounds against
21, so that call is left exactly as it is; at eight workers it is 11 against 14,
and the levels take the plan.
bnec_group()makes that comparison and reportsit. A nested plan drives both loops and is honoured without being counted, and
plan(list(sequential, ...))asks for the release arrangement explicitly.Reproducibility
A grouped call's estimates no longer depend on how it was arranged, and this
needed making true rather than being inherited.
bnec()realises the seed forits weighted posterior draw from whatever stream it is running in, so a level
fitted in a worker drew from that worker's stream and a level fitted in the
parent from the session's. Since the arrangement is read off the worker count,
one script at one seed would have reported different model-averaged estimates on
a four-core and an eight-core machine, which nothing in the released package
does.
Every level is therefore given a seed realised in the calling session before the
levels are dispatched, derived from
seedwhere one was passed and from thesession's stream otherwise, and the sampler kind is pinned so that the same
seedmeans one thing in a session set to the pre-3.6.0 sampler. The realisedseeds are stored on the returned object. The calling session's own stream and
generator kind are restored afterwards, on both arrangements.
Two consequences. A grouped call now repeats under
seedalone with noset.seed()in the session, which is more thanbnec()offers for a single set.And a grouped call reports different numbers than earlier versions did, because
the levels are seeded rather than drawing from the stream as each is reached;
they are another realisation of the same weighting.
Evidence
Nested futures
R 4.6.1,
future1.70.0. Inside a worker ofplan(multisession, workers = 2)the strategy is
sequential,nbrOfWorkers()is 1, andtempdir()differsbetween workers. Inside a level worker of
plan(list(tweak(multisession, workers = 2), tweak(multisession, workers = I(3)))),nbrOfWorkers()is 3 andthe model loop parallelises over them. Under
plan(list(sequential, tweak(multisession, workers = I(3))))the parent reports one worker and thelevel future reports 3, which is what makes that plan the release arrangement
rather than a fully sequential run.
The
I()is needed rather than decorative:futurepinsmc.coresto 1 insidea worker,
parallellyreads that as the core budget, and a bareworkers = 4inner count returns Attempting to set up 4 localhost parallel workers with only
1 CPU cores available ... The hard limit is set to 300% where
I(4)returns 4.The size of the export
R 4.6.1, a 76 MiB vector bound beside the formula in the environment that
created it, measured with
serialize():bayesnecformulanarrow_environment()builds around itThe model frame is the route
amend()exports, through the.Environmentofits
termsattribute. A formula whose environment is the global environment isleft alone, because R serialises that one by reference.
Separately, on a 200,000-row frame over seven levels: what each level future
is sent fell from 5.53 MiB per future, 38.71 MiB over the seven, to 5.53 MiB for
the seven subsets together, because the subsets are now the dispatched elements
rather than indices into a data frame held in the exported environment.
Dispatch packing
R 4.6.1,
plan(multisession, workers = 4), a body that sleeps two secondsstanding in for one fit, after one call to pay the namespace load: four elements
took 2.2 s under
future_lapply()directly and 4.6 s underbnec_parallel_lapply()on its first call; seven elements took 4.1 s, which isthe two rounds
group_loop_rounds()counts and not the one a chunk-per-workerdispatch would have given.
Wall clock
No wall-clock measurement of a real grouped call was taken. Three attempts were
made and none completed: the development host was running other work throughout,
and repeated
multisessioncluster creation in one session stalls on it oftenenough that two sleep-based harnesses and one four-worker grouped fit all
exceeded twenty minutes, while the same dispatcher measured on its own returned
in seconds. The arrangement claim therefore rests on the arithmetic, which is
unit tested, on the packing measurement above, and on the
futuresemanticsmeasured at the top of this section. The shapes where the level loop is the
smaller count need eight or more workers, so the measurement that would settle
it belongs on the HPC and not here.
Tests
tests/testthat/test-parallel_models.Rgains the round arithmetic includingboth shapes #338 quotes and the unbounded-worker case, the five decisions
plan_group_levels()can take, the compile directories asserted through a realfuture, four properties of the formula narrowing including the case where it
saves nothing and the
NULLbinding that used to be dropped, the level seedingand its sampler pinning, that a seeded body gives one answer on both
arrangements, that a grouped call leaves the caller's stream where it found it,
and one grouped fit, with no plan set, asserting the per-level rows, the
ordering and naming of the returned fits, the recorded level seeds and the
narrowed formula.
Nothing in the suite fits under a plan, deliberately. Cross-arrangement equality
is a property of the per-level seeding and is pinned without fitting; what a
grouped call under a plan would add is a Stan compilation per worker. It is also
unreliable on at least one machine: on a WSL2 host a single
bnec()fit inside amultisessionworker did not return in 300 s, with the workers asleep inpollhaving used 9 s of processor time, while the same fit with no plan returns in
173 s and
bnec_parallel_lapply()over a body that loadsbrms, raises, orcalls
bnec()without fitting returns in under 3 s. That path predates thisbranch --- it is the model loop #184 added --- and the interaction was not
identified.
Implementation detail
#338, the level loop
R/bnec_group.R. Theforloop is replaced bybnec_parallel_lapply(), whichis
bnec_model_lapply()renamed: it serves both loops now and its guarantees --one element per chunk,
future.seed = TRUE, the RNG kind restored inside theworker, the caller's stream restored after -- are properties of the dispatch
rather than of what is dispatched.
plan_group_levels()inR/parallel_models.Ris the counterpart ofplan_model_set()and reads the decision from the plan for the same reason: anargument beside it would duplicate state the plan already holds. It returns
dispatchandconcurrentseparately.dispatchsays whether the levels gothrough
futureat all;concurrentsays whether two of them can be fitted atone time, and it is what decides whether the compile cache has to be separated
and whether a per-level message can still be printed in order. A list plan whose
first strategy is sequential has
dispatchwithoutconcurrent, which is therelease arrangement asked for explicitly -- entering a future is what advances
the plan to the inner strategy, so the model loop still gets all of it. A list
plan with nothing parallel after the first strategy is not read as a request at
all.
group_loop_rounds()is the pure function the decision rests on and is unittested on its own. Ties go to the models, which is what the release does; an
unbounded worker count is counted as one round of whichever loop is dispatched,
and
NAis reserved for a count that genuinely could not be read.The comparison assumes every fit takes the same time, and that is wrong in a
known direction: the 23 equations differ in fitting time by an order of
magnitude, a round is set by its slowest member, and the level arrangement puts
M fits in a round against the model arrangement's W. Where M is the larger
the count therefore favours the levels more than the clock would, which is the
second reason ties go to the models.
n_modelsis the set the formula asks for,not the set that will be fitted:
check_models()runs inside each level'sbnec()call and may drop an equation, so this is a scheduling estimate and themessage says so.
The compile cache
Every level fits the same equations, so parallel levels
build the same Stan programs at once, and
cmdstanrdoes not lock its cache --the hazard
hpc/precompile-hpc.sh:255serialises its job array to avoid.worker_stan_cache_dir()gives each worker process a directory of its own,under
cmdstanr_write_stan_file_dirwhere the user has set one andtempdir()otherwise. Keyed on the process rather than on the level: a level key would make
a worker that draws three levels compile every equation three times, which for
the #338 shape is 77 compilations against at most one set per worker. It is
applied on both backends although only
multicoreshares atempdir(), becausea directory the user chose is shared by every worker whatever the backend. What
this adds is compilation on a cold cache, and
group_loop_rounds()countssampling only, so on short fits the arrangement the count prefers can be the
slower one in wall clock;
vignette("example2")says so under The levels of agrouped call, and says that the per-process directories accumulate under a root
the user set and are safe to delete. The worked treatment of the level loop
lives there rather than in
?bnec_group, which keeps the rule, thereproducibility guarantee and
level_seedsand defers the rest, as?bnecdefers to the same vignette. The
root is read in the parent and passed into the worker:
futureexports globalsand not options, so read inside the worker a persistent cache would have been
honoured under a forking plan and silently ignored under every other one.
rstanunderrstan_options(auto_write = TRUE)caches in one place a forkedworker shares with its parent and offers no argument that changes it, so that is
documented rather than worked around.
Peak memory
Peak memory is reported rather than capped. A worker fitting a level holds
that level's whole model-averaged set, so W level workers hold up to W of
them where fitting in sequence holds one. The count above already declines to
dispatch the levels at the small worker counts where memory is tightest, which
is the case #190 hits.
Argument forwarding
do.call("bnec", ...)replaces the forwarding of..., because...cannot be put in the environmentnarrow_environment()builds. By name rather than by value, so that an error raised inside a level
still names
bnec(). Two consequences were checked rather than assumed. Theformula reaches
bnec()with its environment intact, which #319 needs forcrf()to resolve a symbol -- pinned by a test. And the family arrives as avalue rather than as the symbol it was, which
family_link_source()alreadytreats as
"symbol";R/validate_family.Rnamesdo.call()as that case, andthe family has in any event been validated and marked by then.
The elements dispatched
The dispatched elements are the subsets
themselves, built once with
split(data, grp), so neither the data frame northe grouping vector is in the exported environment.
split()orders its piecesby
levels(grp), which is the orderlevsis in, and an empty level cannotarise because a level under four observations is refused earlier.
#329, what the formula carries
narrow_formula_environment()inR/parallel_models.R. It walks up fromenvironment(formula), copying each name the formula mentions anddatadoesnot supply, and stops at the first environment R serialises by reference --
tested with
environmentName(), which is non-empty for exactly those. Thereplacement is parented at the environment the walk stopped at, not at the
package namespace, so the lookup chain is the one the formula had: a formula
written in a frame whose parent is
baseenv()still resolves throughbaseenv(), which is what thetrials()test added by #334 rests on.Blanking the environment would have been wrong:
model.frame()resolves a termagainst
datafirst and the formula's environment second, so a formula naminganything outside
dataneeds it, and that includes the model argument ofcrf()where it is a variable. Since #319 merged intodevthat resolution isdeliberate rather than incidental --
formula_eval_env()evaluates in a framewhose parent is
formula_env(). This branch was rebased ontodevafter thatmerge and the formula test files were run against it. A test asserts that a
modsvariable holding the model set survives, and another that a name bound toNULLis kept rather than dropped --vals[[nm]] <- NULLdeletes, which leftsuch a name to resolve against the parent chain, where a decoy of the same name
answered for it.
Applied in
bnec(),amend()andbnec_group()before the model frame isbuilt, so that the frame, the
brmsformula and the stored fit are narrowed byone call, and on the sequential path as well as the parallel one, for the reason
narrow_environment()gives: a name left behind then fails on the firstordinary call rather than only for whoever sets a plan.
Two limits, both measured and both documented:
left where it is and resolved through the parent chain, because copying it
would reintroduce the size this removes. A worker's global environment is not
the caller's, so such a name was already out of reach under a plan and still
is.
so the name is copied with everything it closed over. Measured at 76.294 MiB
before and after: unchanged, and there is nothing to be saved, because the
object is needed. A helper written in a
knitrchunk and used in a formulatherefore still gives a large fit.
One pre-existing behaviour was found and left alone:
model.frame()on abayesnecformuladoes not resolve a user function named insidecrf()from theformula's environment at all, failing with "could not find function" both before
and after this change.
Not done here
L x Mfits as one set would pack the workers better than any nesting andneeds no comparison at all, but it means separating
bnec()'s fitting loopfrom its preparation and its assembly, which is a change to the package's
central function and is not this PR.
expand_manec()still realisesw_draw_seedfrom the ambient stream. Thelevel seeds make that harmless for a grouped call; deriving it from
brm_args$seedwould close it forbnec()as well, and is the change Model-averaged output is not reproducible: predict() on a bayesmanecfit differs between calls #216decided against.
brm(file = )passed through...names one file for every model of a setand every level of a group, so the second fit reads back the first. That is a
defect of
bnec()rather than of the level loop, and predates it.🤖 Generated with Claude Code
https://claude.ai/code/session_01B7T9mkqxuRAKcdXmN5pctD