Skip to content

perf(engine): convert a NamedTuple-record batch once at FitLogDensity construction - #132

Merged
seabbs merged 1 commit into
mainfrom
perf/batch-record-conversion-115
Aug 12, 2026
Merged

seabbs merged 1 commit into
mainfrom
perf/batch-record-conversion-115

Conversation

@seabbs-bot

Copy link
Copy Markdown
Collaborator

Summary

Fixes #115: batch record scoring allocated per record, in the ForwardDiff
gradient loop.

FitLogDensity gains a scored_data field, prepared once at
distribution_to_logdensity construction via a new
_prepare_scored_data/_prepare_default_loglik_data hook (generic no-op
unless an extension overrides it). logdensity calls loglik(obj, scored_data) instead of loglik(obj, data).

The ComposedDistributions extension overrides the hook for
Sequential/Parallel composers scoring an AbstractVector{<:NamedTuple}
batch under the default loglik: each record is converted once, via CD's
own _named_value_vector, to its Missing-admitting vector form and held
for the life of the problem, instead of being re-converted on every
logdensity evaluation.

A caller-supplied loglik always sees the untouched data it was handed —
only the default reducer's batch is ever swapped, so a custom loglik
reading data in some other shape cannot be silently broken by this.

Public-surface note

observations(prob) is unchanged: it still returns exactly what the caller
passed to distribution_to_logdensity, by identity (===). The converted
batch lives on a new internal scored_data field with no public accessor,
consistent with the existing internal-only extra_state/concrete_fields
fields. No caller of template/observations/flat_priors needs to change
(checked ext/DistributionsInferenceBijectorsExt.jl,
ext/DistributionsInferenceDynamicPPLExt.jl,
ext/DistributionsInferenceAdvancedMHExt.jl — none touch .data directly).

Measurements

Benchmarked on a two-leaf Parallel composed distribution
(onset_admit uncertain-Gamma, admit_death fixed-LogNormal), 200
NamedTuple records, matching the rand(d, n) row shape the issue
describes.

Before (current main):

time memory allocs
logdensity, NamedTuple batch 54.5 μs 122.5 KiB 3418
logdensity, plain-vector batch 7.1 μs 16.3 KiB 418
ForwardDiff.gradient, NamedTuple batch 58.4 μs 135.5 KiB 3426
ForwardDiff.gradient, plain-vector batch 10.7 μs 19.9 KiB 426

After (this PR):

time memory allocs
logdensity, NamedTuple batch 7.2 μs 16.3 KiB 418
ForwardDiff.gradient, NamedTuple batch 10.5 μs 19.9 KiB 426

The NamedTuple-record batch now matches the plain-vector batch almost
exactly — a ~7.6x speedup and ~7.5x allocation reduction for logdensity,
and a ~5.6x speedup and ~6.8x allocation reduction under
ForwardDiff.gradient, consistent with (slightly exceeding) the issue's
reported ~5x.

The single-record logpdf(obj, record) cost itself is unchanged (235ns/688B
NamedTuple vs 32ns/96B vector) — that path isn't touched, only the batch
held by FitLogDensity for the life of a fit.

Correctness: the Missing dispatch

The Missing-admitting element type is load-bearing (keeps the censored
logpdf(d, ::AbstractVector{>:Missing}) specialisation selected). Verified,
not just reasoned about:

  • A new test (test/composed_ext.jl) builds a batch with a missing field,
    confirms the cached scored_data matches CD's own
    _named_value_vector conversion exactly, confirms which(logpdf, ...)
    resolves to the AbstractVector{>:Missing} method (not the plain one),
    and confirms logdensity through the cached batch matches a hand-rolled
    uncached NamedTuple walk to rtol=1e-12.
  • A new test (test/engine.jl) confirms a caller-supplied loglik always
    receives the untouched data by identity, and that scored_data defaults
    to data (by identity) when no extension hook applies.

Type stability

scored_data's type is a fresh struct type parameter (SD), inferred
concretely in both cases checked (Vector{Vector{Union{Missing,Float64}}}
for the CD batch, identity for the generic no-op). @code_warntype on
FitLogDensity(...) and logdensity(...) shows no instability introduced
by this change. logdensity's body is Any-typed for this fixture, but
that traces to ComposedDistributions.reconstruct itself returning a
Union-typed tree (pre-existing on main, unrelated to this PR — confirmed
by @code_warntypeing reconstruct directly, unchanged by this diff).

Testing

  • task test-ad (all AD backends): 1386/1386 pass.
  • task test (full suite incl. quality): 627/627 pass.
  • task format / task test-formatting: clean.

This was opened by a bot. Please ping @seabbs for any questions.

… construction

Batch record scoring allocated a fresh Missing-admitting vector per
record per evaluation, via ComposedDistributions' _named_value_vector
inside logpdf(d, ::NamedTuple) -- directly in the ForwardDiff gradient
loop for the shape rand(d, n) produces.

Add a scored_data field to FitLogDensity, prepared once at
distribution_to_logdensity construction via a new
_prepare_scored_data/_prepare_default_loglik_data hook (a no-op unless
overridden). The ComposedDistributions extension overrides it for
Sequential/Parallel composers scoring an AbstractVector{<:NamedTuple}
batch under the default loglik, converting each record once and
holding the converted form for the life of the problem.

observations(prob) still returns exactly what the caller passed in;
the converted batch is held separately and only reaches loglik when
loglik is the default reducer, so a caller-supplied loglik always
sees the untouched data.

Closes #115.
@github-actions

Copy link
Copy Markdown
Contributor

Try this Pull Request!

Option 1: Julia Package Manager

Open Julia and type:

import Pkg
Pkg.activate(temp=true)
Pkg.add(url="https://github.com/EpiAware/DistributionsInference.jl", rev="perf/batch-record-conversion-115")
using DistributionsInference

Option 2: Local Checkout

If you have the repo locally:

git checkout perf/batch-record-conversion-115
julia --project=. -e "using Pkg; Pkg.instantiate()"

@codecov

codecov Bot commented Aug 12, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

Flag Coverage Δ
ad-enzyme-forward 17.01% <83.33%> (+0.81%) ⬆️
ad-enzyme-reverse 17.01% <83.33%> (+0.81%) ⬆️
ad-forwarddiff 13.88% <83.33%> (+0.86%) ⬆️
ad-mooncake-forward 13.88% <83.33%> (+0.86%) ⬆️
ad-mooncake-reverse 13.88% <83.33%> (+0.86%) ⬆️
ad-reversediff 13.88% <83.33%> (+0.86%) ⬆️
unit 98.21% <100.00%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/engine.jl 100.00% <100.00%> (ø)
src/protocol.jl 97.82% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

Copy link
Copy Markdown
Contributor

📖 Documentation preview is ready!

View the docs for this PR at: https://EpiAware.github.io/DistributionsInference.jl/previews/PR132/

This preview will be updated automatically when you push new commits.

@seabbs
seabbs merged commit 47105a2 into main Aug 12, 2026
25 checks passed
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.

Batch record scoring allocates per record; convert the batch once at as_logdensity construction

2 participants