Skip to content

materials: split J2 from Drucker-Prager, and give plasticity its own elastic tangent - #39

Closed
petlenz wants to merge 7 commits into
feature/drucker-pragerfrom
refactor/split-j2-plasticity
Closed

materials: split J2 from Drucker-Prager, and give plasticity its own elastic tangent#39
petlenz wants to merge 7 commits into
feature/drucker-pragerfrom
refactor/split-j2-plasticity

Conversation

@petlenz

@petlenz petlenz commented Sep 4, 2026

Copy link
Copy Markdown
Member

Three plasticity classes served by one templated small_strain_plasticity, all of them depending on an external elastic material for a stiffness they can only use if it is isotropic. Split into dedicated classes, each owning its own tangent.

Every step is verified against the previous implementation — bit-identical except where noted — and all pre-existing tests pass unchanged.

Performance

Two binaries built from the actual commits, run interleaved so each pair sees the same
machine state, 15 pairs, speedup computed per pair:

old (median) new (median) paired speedup range
J2 1466.2 ns/step 211.5 7.00x 5.54-8.84x
Drucker-Prager 1176.3 ns/step 652.4 1.79x 1.58-1.90x

The range is the honest figure. The same binary measured 1171-2253 ns across runs, so any
single-run comparison on this machine is worth about one significant digit; earlier drafts of
this description quoted 7.5x and 1.9x from sequential runs, which sat at the optimistic end of
these intervals.

Four independent changes, in order:

1. J2 split out. It used none of the yield-function generality and paid for all of it — a yield normal distinct from the flow normal (identical when associative), a "modified" equivalent stress carrying pressure coupling it does not have, an apex branch a cylinder cannot reach.

2. The rank-4 collapse. compute_tangent formed C_e : (dN/dsigma) : C_e explicitly — two rank-4 x rank-4 contractions per plastic step. Every flow normal here is deviatoric plus a constant volumetric term, so dN/dsigma is deviatoric in both index pairs, and for isotropic C_e:

C_e : (dN/dsigma) : C_e  ==  4 G^2 (dN/dsigma)

Verified standalone at 2.5e-16 against the explicit form, for the J2 and DP derivatives independently, before any material was touched.

3. The redundant stress contraction. The return map contracted twice — once for the trial stress, once for the returned stress. The second is sig_trial - dl (C_e : N), no contraction needed.

4. Plasticity owns its elastic tangent. See below.

The elastic dependency was dishonest, and harmful

All three classes read the stiffness from an elastic_source's tangent. That looked like flexibility and was not: the closed forms require isotropic C_e — it is what makes C_e : N = 2G N and N : C_e : N = 3G true. rk_plasticity assumed it twice, also through effective_modulus(G) = 3G. A caller wiring an anisotropic tangent would have got silently wrong answers.

It also pulled a linear_elasticity into every plasticity graph, whose own stress output is not merely unused there but wrong — it is C : eps with eps_p ignored, so once yielding starts it is not the stress of anything:

step alpha j2::stress elastic::stress error
40 0.0094 106.25 107.69 +1.4%
120 0.1094 306.25 323.08 +5.5%
200 0.2094 506.25 538.46 +6.4%

A postprocessor logging elastic::stress from a plasticity graph gets that, under a name that reads as authoritative.

All three now build C_e from moduli they hold. j2_plasticity and rk_plasticity gain a K parameter; drucker_prager_plasticity needs nothing new, since K_bulk and G were already required for the cone. A plasticity graph no longer contains an elastic material at all.

linear_elasticity is untouched and keeps its users — for a genuinely elastic model C : eps is the answer, and isotropic_damage consumes both its stress and its tangent legitimately.

This also unblocks #33

small_strain_plasticity took its yield function as a C++ object through a yield_function parameter, which the JSON reader cannot convert. That is why Drucker-Prager had to stay unregistered in #36 — a document naming it would silently get a default-constructed cone (eta = beta = k = 0) that builds, runs, never yields, and looks like elasticity.

eta, beta, K_bulk are now ordinary scalars. A DP model built entirely from JSON reproduces the C++ reference exactly:

DP from JSON: alpha=0.042067012632805115
  (C++ reference alpha = 0.042067012632805115)

Verification

  • 50/50 tests, including the six-path DP tangent check and the apex branch
  • J2 equivalence over 60 steps: max|dstress| = 0, max|dalpha| = 0
  • DP and RK: bit-identical to all 17 digits against their pre-change output
  • The one exception: dropping the redundant contraction is algebraically equal but ordered differently, so it agrees to ~1 ULP rather than bit-for-bit. Stated in that commit.
  • Mutation-tested: a 0.5% error in either closed form fails the suite

A test that could not fail, fixed on the way. J2TangentTest bounded the whole run at 0.1, because the elastic->plastic transition step is genuinely inexact — a central difference straddling the yield surface averages two tangents. That made it blind: a 0.5% error in the tangent landed at 2.4e-4 and passed, while real plastic steps sit at 4.4e-10. Now bounded by regime, and the mutation that passed before now fails.

Measurement caveat

This machine's load moves absolute timings by 30%+, and I caught myself reporting a 300 ns "regression" that reversed sign under interleaving. The J2 figures are same-process A/B comparisons; the DP ones are interleaved across alternating runs. Ratios are meaningful; absolutes across commits are not.

Not done

drucker_prager_yield_function survives as an internal member rather than a template parameter — it holds the verified apex algebra, and rewriting that to save a file would trade real risk for a cosmetic gain.

Note on ordering

This targets feature/drucker-prager (#10), the root of the stack, so merging it means re-merging forward through nine descendants. Mechanical, but worth deciding alongside #10 rather than separately.

small_strain_plasticity is parameterised over a yield function so it can serve
Drucker-Prager. J2 used none of that generality and paid for all of it:

  - a yield normal distinct from the flow normal, identical for associative J2
  - a "modified" equivalent stress carrying pressure coupling J2 does not have
  - an apex branch a cylinder cannot reach

The cost was not only readability. The general consistent tangent forms
C_e : (dN/dsigma) : C_e -- two rank-4 x rank-4 contractions per plastic step.
For isotropic elasticity N is deviatoric, so C_e : N = 2G N and N : C_e : N =
3G, and the expression collapses to the standard closed form:

  C = C_e - (6G^2 dl/sig_eq) IIdev + (4G^2 dl/sig_eq - 4G^2/(3G+H')) N (x) N

algebraically identical to what the general path computes, not an
approximation of it.

  old small_strain_plasticity<j2> : 1262.2 ns/step
  new j2_plasticity               :  245.2 ns/step   (5.15x)

Equivalence over 60 steps on the same path:
  max |dstress|  = 0.000e+00   (bit-identical)
  max |dalpha|   = 0.000e+00   (bit-identical)
  max |dtangent| = 8.760e-14   (roundoff; different order of operations)

All nine pre-existing J2 tests pass unchanged, including the tangent checker.

ISOTROPY is not newly assumed. small_strain_plasticity already required it
through effective_modulus(G) = 3G in its residual; the closed form states it
instead of implying it. The scalar Newton is kept, because hardening is a
separate graph node and may be nonlinear.

Also fixes a blind spot the refactor exposed. J2TangentTest bounded the whole
run by 0.1, because the elastic->plastic transition step is genuinely inexact
(a central difference straddling the yield surface averages two tangents). That
made it blind to real errors: a 0.5% error in the tangent lands at 2.4e-4 and
passed. Fully plastic steps are now bounded at 1e-8 and the crossing step
separately at 0.1. Verified: the mutation that passed before now fails.
…gent

compute_tangent formed C_e : (dN/dsigma) : C_e explicitly -- two rank-4 x
rank-4 contractions per plastic step, and the dominant cost in both
Drucker-Prager and the RK integrators.

Every flow normal here is a deviatoric term plus a constant volumetric one, so
dN/dsigma is deviatoric in BOTH index pairs. For isotropic C_e that gives
C_e : X = 2G X and X : C_e = 2G X, so the whole expression is

    C_e : (dN/dsigma) : C_e  ==  4 G^2 (dN/dsigma)

Verified against the explicit form at 2.5e-16 for the J2 and Drucker-Prager
derivatives independently, before changing any material.

Isotropy is not newly assumed: effective_modulus() already required it, as 3G
for J2 and G + K*eta*beta for Drucker-Prager.

  Drucker-Prager: 1178.9 -> 761.3 ns/step  (1.55x)

Bit-identical output, to all 17 digits, on a 20-step path:

  before: alpha=0.042067012632805115  s00=108.98966076999213  C0000=251.4917329876391
  after : alpha=0.042067012632805115  s00=108.98966076999213  C0000=251.4917329876391

rk_plasticity shares compute_tangent and gets the same reduction; its three
tests pass unchanged. The DP tangent is independently checked against a
numerical derivative on six load paths including the apex branch, which is what
would catch the collapse being wrong rather than merely faster.
…Prager

After J2 moved out, small_strain_plasticity<Traits, YieldFunction> had exactly
one instantiation. A template parameter with one argument is not generality, it
is indirection, and it cost:

  - a has_apex_return concept plus three if constexpr / requires sites, guarding
    a branch the only remaining user always has;
  - a yield function passed as a C++ OBJECT through a "yield_function"
    parameter.

That second one was not just noise. The JSON reader has no converter for the
object, so Drucker-Prager could not be configured from a document at all -- the
blocker behind #33, where it had to stay unregistered because a document naming
it would silently get a default-constructed cone (eta = beta = k = 0), which
builds, runs, never yields, and looks like elasticity.

eta, beta and K_bulk are now ordinary scalar parameters. Verified: a
Drucker-Prager model built entirely from a JSON document reproduces the C++
reference bit-for-bit, alpha = 0.042067012632805115 either way.

The apex is now unconditional -- the concept and every if constexpr are gone --
because a cone always has one.

  Drucker-Prager: 1178.9 -> 721.9 ns/step over the two commits (1.63x)

Bit-identical to the pre-refactor implementation on a 20-step path, all 17
digits of alpha, stress and tangent.

The yield function survives as an internal member rather than a template
parameter: it holds the verified apex algebra, and rewriting that to save a
file would have traded a real risk for a cosmetic gain.
Profiling the step showed one rank-4 : rank-2 contraction at ~33 ns against
~245 ns for a whole J2 step, and the return map did two of them: once for the
trial stress, once for the returned stress.

The second is redundant:

    sigma = C_e : (eps - eps_p_old - dl N) = sig_trial - dl (C_e : N)

and for isotropic C_e, C_e : N = 2G dev(N) + K tr(N) I -- the same identity the
tangent collapse already uses. J2's flow is deviatoric so tr(N) = 0 and it
reduces to 2G N; Drucker-Prager's is not, so the volumetric term stays.

  J2 (same-process A/B):  245.2 -> 195.4 ns/step
  DP (interleaved A/B):   ~8%, medians 1037 -> 953 ns/step

NOT bit-identical this time, unlike the earlier steps: the result agrees to
about 1 ULP (s00 108.7500531151484 vs ...843) because the arithmetic is
algebraically equal but ordered differently. All 50 tests pass, including the
FD tangent checks on six load paths.

The apex return keeps its contraction: there eps_p comes from
apex_plastic_strain rather than dl*N, so the identity does not apply, and the
branch is rare.

Measurement note: absolute ns figures in this branch's earlier commits were
single runs taken at different times, and this machine's load moves them by
30%+. The J2 comparison above is a same-process A/B; the DP one is interleaved
across alternating runs. Ratios are meaningful, absolutes are not comparable
across commits.
Both plasticity classes read the elastic stiffness from an elastic_source's
"tangent" property. That looked like flexibility and was not: the closed forms
REQUIRE an isotropic C_e -- it is what makes C_e : N = 2G N and N : C_e : N =
3G true -- so accepting an arbitrary rank-4 tangent advertised a generality
neither class can honour.

It also dragged a linear_elasticity into every plasticity graph, and that
material's own "stress" output is not merely unused there, it is WRONG: it is
C : eps with eps_p ignored, so once yielding starts it is not the stress of
anything. Measured on a 200-step path it over-predicts by a growing margin:

  step  40  alpha 0.0094   j2 106.25   elastic 107.69   +1.4%
  step 120  alpha 0.1094   j2 306.25   elastic 323.08   +5.5%
  step 200  alpha 0.2094   j2 506.25   elastic 538.46   +6.4%

A postprocessor logging elastic::stress from a plasticity graph gets that,
under a name that reads as authoritative.

Both classes now build C_e from moduli they already hold or now take:
j2_plasticity gains a "K" parameter; drucker_prager_plasticity needs nothing
new, since K_bulk and G were already required for the cone. elastic_source is
gone from both, and a plasticity graph no longer contains an elastic material
at all.

  J2:  195.4 -> 168.1 ns/step
  DP:  ~720  ->  ~608 ns/step

Values agree with the pre-refactor implementation to about 1 ULP on alpha,
stress and tangent.

linear_elasticity is untouched and keeps its users: for a genuinely elastic
model C : eps IS the answer, and isotropic_damage consumes both its stress and
its tangent legitimately. rk_plasticity still takes an elastic_source; it is
the remaining templated class and is left alone here.

An earlier draft of this reasoning proposed dead-property elimination in the
property engine, with a declared-outputs mechanism to tell an unread property
from one a host reads through ctx.get(). That was solving the symptom. The
property should not be in the graph.
Same change as the other two plasticity classes, for the same reason.
rk_plasticity read the stiffness from an elastic_source while assuming isotropy
twice over: effective_modulus(G) = 3G in its stage residuals, and
compute_tangent's C_e : X = 2G X collapse. An arbitrary rank-4 tangent could
not have been honoured by either.

Takes "K" and builds C_e in the constructor; elastic_source is gone. Bit-
identical over a 40-step SDIRK3 path, all 17 digits:

  before: 0.01062302967272258  108.7500531151484  250.00062497968736  76.554019397938148
  after : 0.01062302967272258  108.7500531151484  250.00062497968736  76.554019397938148

That comparison matters here because the suite's rk-vs-j2 equivalence test now
has both sides changed; this one holds rk against its own pre-change output.
SDIRK3TangentCheck, which compares the tangent against a numerical derivative,
is the independent check and passes unchanged.

No plasticity material takes an elastic_source now. The one remaining consumer
is isotropic_damage, which reads elastic::stress legitimately -- damage scales
an elastic stress, and with no plastic strain in that model C : eps IS the
stress.
@petlenz petlenz changed the title materials: split J2 from Drucker-Prager; 5.2x / 1.6x faster, and unblocks JSON-configurable DP materials: split J2 from Drucker-Prager, and give plasticity its own elastic tangent Sep 5, 2026
@petlenz

petlenz commented Sep 5, 2026

Copy link
Copy Markdown
Member Author

Superseded — split into four PRs, because this had grown to seven commits spanning four independent arguments and could not be reviewed as one thing.

PR argument size
#41 one algebraic identity collapses two rank-4 contractions +16/-4
#42 J2 does not need the yield-function generality +213/-12
#43 with J2 gone, the template has one instantiation left — unblocks #33 +55/-74
#44 the elastic dependency is dishonest and harmful +80/-41

Each stacks on the previous, and each was verified independently at 50/50 before pushing. #40 (the scalar-Newton split) is rebased onto #44.

The rebuilt stack is tree-identical to this branch — git diff between them is empty — so the split reorganised history without changing a byte of the result.

Closing this rather than repointing it, since its branch is the fully-combined form and keeping it open would mean two paths to the same tree.

@petlenz petlenz closed this Sep 5, 2026
@petlenz
petlenz deleted the refactor/split-j2-plasticity branch September 6, 2026 20:35
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