Skip to content

Constant properties: computed once instead of on every update - #161

Closed
petlenz wants to merge 6 commits into
feature/short-fibre-local-nonlinearfrom
feature/constant-properties
Closed

petlenz wants to merge 6 commits into
feature/short-fibre-local-nonlinearfrom
feature/constant-properties

Conversation

@petlenz

@petlenz petlenz commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Outputs that depend only on parameters, and on inputs that are themselves constant, are now computed once instead of on every update. The global mean-field composite of constant phases therefore runs its kernel once: per update, Mori–Tanaka goes from 7.0 to 0.09 µs and self-consistent from 12.6 to 0.5 µs, with identical results. Stacked on #159.

Why

property_engine::update() ran every callback on every update. The global mean-field composite reads its phase tangents, fractions and Eshelby tensors over global edges. It could not know they never change, so it ran its kernel on every update: a full fixed-point iteration for self-consistent, with an Eshelby evaluation per step. The same happened one level down for elastic tangents built from moduli and for the Eshelby tensors.

Constness cannot be inferred from the graph, for two reasons:

  • Dependencies are per material. Every global input of a material is an edge to all of its outputs, so linear_elasticity::tangent appears to depend on the strain.
  • Callbacks write several outputs. An output without its own callback is written by another callback of its material. So "no callback" does not mean constant.

That is why producers declare it, per output.

What

  • material_base::constant_output(output[, {reads}]): declares that the output is a function of the parameters and of reads only.

    • The output is the reference add_output returned. Its address identifies the property, which the registry holds behind a unique_ptr. A value that is not an output of the material (a parameter, a local) throws at construction. There is no name to misspell and no lookup at build.
    • Without a reads list, all inputs of the material are reads. An input added later can then not be forgotten; at worst the output is not constant. A list narrows it (phase, weighted_sum, linear_elasticity with {}), and every listed read must be an input of the material.
    • Debug builds verify. Without NDEBUG the engine reruns the skipped callbacks on every update and throws logic_error naming the output when a constant output changes (relative tolerance √ε): a declaration that hides state or a read becomes a test failure. ctx.verify_constants(bool) switches it; tests that count calls turn it off.
  • add_callback(&callback, outputs...): one callback filling a group of outputs (references, pointers with null ones skipped, or ranges of pointers). The returned handle declares the group .constant([reads]) and orders it .runs_after(other). Null reads are skipped, so an absent optional input needs no filtering. mean_field_strain, scalar_split, isotropic_moduli, phase and the global composite use it:

    auto &concentrations{base::add_callback(&mean_field_strain::compute_concentrations, m_A).constant(phase_reads)};
    base::add_callback(&mean_field_strain::compute_strains, m_eps).runs_after(concentrations);
  • written_by(output, writer) / runs_after(output, earlier): the layer underneath add_callback: which callback fills an output that has no callback of its own, and the order of two callbacks of one material.

  • The engine at build:

    • A declared output is constant when it is not history and every read is a global edge from a constant property. Reads come first in the topological order, so one pass decides.
    • A callback runs once when its output is constant and every output it may write is constant too. An output without a callback may be written by its declared writer, or by every callback of its material when none is declared. So callbacks of one material are skipped one by one, and a callback that also writes a varying output keeps running.
    • Ordering fix: an output without a callback is now ordered after the callbacks that may write it. Before, a consumer could read it before its writer had run. mean_field_strain's strain_<inclusion> ran after stiff::stress in some hash orders. The extra edges cannot close a cycle: the material's global inputs already reach every output, so any path from the output back to its writer would already be a cycle.
  • Recomputation:

    • set_parameter() and get_mutable() bump a revision counter. Its address is stable: it is heap-held and bound to the materials at finalize().
    • After a bump, the next update() or update_property() reruns the constants.
    • update_property() computes a constant that has not run yet.
  • Producers declaring their pure outputs:

    Producer Constant output(s) Reads
    constant_scalar value —
    poisson_ratio_from_moduli value —
    linear_elasticity tangent —
    Depolarisation tensors tensor —
    isotropic_tangent tangent all inputs (K, G)
    isotropic_moduli K, G, ν all inputs (the tangent)
    Eshelby tensors tensor all inputs (ν)
    phase (one group: not constant when the tangent comes over a local edge, the previous pass's) all outputs all inputs
    scalar_split every part all inputs (the source)
    weighted_sum tangent weights, tangents (not the stresses)
    mean_field_composite_global tangent, concentrations all inputs (the phase properties)
  • mean_field_strain: concentrations and strains are separate callbacks. The concentrations are declared constant over the phase reads, and the strains run after them. With constant phases the concentrations are computed once; a phase read over a local edge (nonlinear) recomputes them every pass.

  • phase simplified: each output and the input it republishes form a relay, and input(key) reads the <key>_source/<key>_property pair (null without a source). The optional Eshelby and depolarisation tensors need no branches, and the edge kind of the tangent comes from its input. The parameters are unchanged. An empty tangent_source or fraction_source now fails at construction with a clear message instead of as a wiring error at finalize().

  • Not constant, as before:

    • the localised strains (history);
    • the local composites;
    • stresses;
    • anything reading a history or a local edge.
  • dump() marks constant properties. README and docs/mean_field.md describe the rule.

Measured

Global composite: matrix plus aligned spheroids (aspect ratio 5, c = 0.2), linear_stress on top, 2000 updates with a changing strain, -O2.

kernel before after stress checksum
Mori–Tanaka 7.01 µs / update 0.09 µs identical
self-consistent 12.55 µs / update 0.50 µs (mostly the first update) identical

Local localisation: the same phases through mean_field_strain, linear_stress per phase and mean_field_composite_local_linear, 2000 committed steps, -O2 -DNDEBUG.

kernel before after stress checksum
Mori–Tanaka 4.51 µs / update 1.14 µs identical
self-consistent 10.38 µs / update 1.64 µs identical

Tests

  • test_constant_properties (new), with a counting test material:
    • a constant chain is computed once over 5 updates;
    • a read of a varying property keeps the output in the loop;
    • an undeclared output is never constant;
    • a local edge is never constant;
    • a callback that also writes an undeclared output keeps running;
    • set_parameter() recomputes the constants (once, then they are skipped again);
    • a get_mutable() write recomputes them;
    • update_property() computes a constant that has not run yet;
    • all inputs are reads by default: an unused varying input keeps the output in the loop, a narrowed list does not;
    • declaring a parameter instead of an output throws, as does a read that is not an input of the material;
    • verification throws on hidden state (a value adding the call count) and passes a true constant;
    • with two callbacks, the constant one runs once while the other runs every update;
    • an undeclared varying output keeps every callback of its material running, and a declared varying one keeps its writer running;
    • callbacks and the outputs they write are ordered (writer before output; undeclared after every callback; runs_after);
    • a cycle of runs_after is rejected;
    • a writer without a callback throws, and so does a history output declared constant;
    • add_callback: a constant group runs once with its outputs ordered after it, a null output is left out, an output that has a callback already and an empty group throw.
  • Phase.* (3 tests, written before the refactor): inconsistent parameters (initial tangent without a local edge, unknown edge, empty source, Eshelby in 2D), a negative fraction, and which tensors are relayed. Against the old code only the empty-source checks failed.
  • MeanFieldLocal.ConstantPhasesAreLocalisedWithConcentrationsComputedOnce: a counting Mori–Tanaka kernel is called once over five committed steps, with stresses and strains equal to plain Mori–Tanaka. It failed before the split; the new API tests failed to compile.
  • MeanField.ConstantPhasesAreHomogenisedOnce:
    • a counting Mori–Tanaka kernel, registered through kernel_registry::add, is called once over five updates with a changing strain;
    • the stress follows the strain;
    • the tangent equals the plain Mori–Tanaka one exactly.
  • Red first: before the change it was called 5 times, and constant_output did not exist.

Gates (GCC 14, fetched dependencies):

  • Tests: 1182/1182, each also under ASan, 0 warnings. The build has no NDEBUG, so the whole suite ran with verification on: none of the declarations above changes on a rerun (self-consistent included).
  • Reports tool: builds, and all 14 docs/materials/*/report.json checks pass with it.

Notes

  • Opt-in by design: an output nobody declares behaves exactly as before.
  • Stability of the ordering fix: test_mean_field_local and test_short_fibre_composite were repeated 10× each, all passing.

An output declared with constant_output(name, {reads}) depends only on the
parameters and those inputs. At build the engine marks it constant when
every read is a global edge from a constant property; a callback is run
once when the outputs of its material without a callback of their own are
constant too. set_parameter() and get_mutable() bump a revision counter,
after which the constants are recomputed on the next update.

Producers declare their pure outputs: constant_scalar, poisson_ratio_from_moduli,
linear_elasticity (tangent), isotropic_tangent, isotropic_moduli, the Eshelby
and depolarisation tensors, phase (not over a local edge), scalar_split,
weighted_sum (tangent) and mean_field_composite_global (tangent and
concentrations). The global mean-field composite of constant phases runs its
kernel once.
…erified in debug builds

constant_output() takes the value add_output() returned instead of the property name; the address
identifies the property, and a value that is not an output of the material throws at construction.
Without a reads list the output reads all inputs of the material; a listed read must be one of them.
Debug builds rerun the skipped constant callbacks on every update and throw when a constant output
changes (ctx.verify_constants(bool) switches it).
written_by(output, writer) names the callback that fills an output without its own; runs_after(output,
earlier) orders two callbacks of a material. A callback-less output is now ordered after its writer, or
after every callback of its material when none is declared: before, a consumer could read it before the
callback that writes it had run (mean_field_strain's strain_<inclusion>, depending on the hash order).
A constant callback is skipped when every output it may write is constant.

mean_field_strain computes its concentrations in their own callback, declared constant over the phase
reads, and the strains after them: constant phases localise with the concentrations computed once.
add_callback(&callback, outputs...) binds a callback to several outputs (references, pointers with null
ones skipped, or ranges of pointers) and returns a handle that declares the group constant and orders
it after another group; it replaces binding the first output and naming the others written_by it.
constant() skips null reads (an absent optional input).

mean_field_strain, scalar_split, isotropic_moduli, phase and mean_field_composite_global use it.
The callback writes all outputs, so it is skipped only when all are constant; the group declared with
all inputs as reads says exactly that: constant unless the tangent comes over a local edge.
Each output and the input it republishes are one relay; input(key) reads "<key>_source" and
"<key>_property" and is null without a source, so the optional Eshelby and depolarisation tensors
need no branches and the edge of the tangent comes from its input. Parameters are unchanged; an
empty tangent_source or fraction_source now fails at construction instead of at finalize().
@petlenz

petlenz commented Oct 21, 2026

Copy link
Copy Markdown
Member Author

Superseded by a split into three stacked PRs with the same final code (the tree of #165 is identical to 9ab90c0):

@petlenz petlenz closed this Oct 21, 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.

1 participant