feat(#5165): add PhSticky memoizing decorator#5249
Conversation
🚀 Performance AnalysisAll benchmarks are within the acceptable range. No critical degradation detected (threshold is 100%). Please refer to the detailed report for more information. Click to see the detailed report
✅ Performance gain: |
b99e460 to
b674871
Compare
|
@asmirnov-backend this is a good piece of code, but it's still disconnected from the rest of the code base. Imagine, we merge this and tomorrow someone will open this repo and ask a question: "What this class is doing here, what it's for?" He won't find an answer to this question and will delete the class. Read again: https://www.yegor256.com/2015/02/12/top-down-design.html |
e10120c to
f63c747
Compare
Wraps an object and memoizes the result of `take` for a single predefined φ-expression, keyed on `(origin.forma()).name`; every other expression is delegated and rebuilt. The key comes from `forma()`, which is side-effect-free, so building it on every taking never activates nor dataizes the object. Covered by `PhStickyTest`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
….xsl Every object formed during transpilation (base and method bodies) is wrapped in `new PhSticky(...)` before its `PhSafe` locator, routing its takes through the memoizer. Adds asserts to the `fetching` transpile pack covering the wrapping. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
f63c747 to
7aa3d33
Compare
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ze nan.is-finite Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…avoid clash with KEY Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
|
@yegor256 could you please take a look again. Now PhSticky memo only |
gemshrine
left a comment
There was a problem hiding this comment.
Three concerns about the invariants held by this decorator — see the inline comments on equals, take, and copy.
|
|
||
| @Override | ||
| public boolean equals(final Object obj) { | ||
| return this.origin.equals(obj); |
There was a problem hiding this comment.
PhSticky.equals(x) returns origin.equals(x); that reads as symmetric relative to origin, but relative to this it isn't. For any x where origin.equals(x) == true:
phSticky.equals(x)→origin.equals(x)→truex.equals(phSticky)→ typicallyobj instanceof <x's class>(or an identity check) →false, becausephStickyis aPhSticky, not the classxrecognises as equal to itself.
That violates the symmetry clause of Object.equals and produces inconsistent HashMap / Set / List.contains behaviour depending on which side of the pair is the receiver.
The same delegation also breaks reflexivity in the common case: phSticky.equals(phSticky) → origin.equals(phSticky) → usually false, because origin doesn't unwrap PhSticky and doesn't hold phSticky by identity.
A symmetric shape:
@Override
public boolean equals(final Object obj) {
final Phi other;
if (obj instanceof PhSticky) {
other = ((PhSticky) obj).origin;
} else {
other = (Phi) obj;
}
return this.origin.equals(other);
}| public Phi take(final String name) { | ||
| final Phi taken; | ||
| if (PhSticky.KEY.equals(String.join(".", this.locator, name))) { | ||
| taken = PhSticky.MEMORY.computeIfAbsent(PhSticky.KEY, ignore -> this.origin.take(name)); |
There was a problem hiding this comment.
MEMORY is keyed only by the constant KEY, not by origin identity. Because to-java.xsl wraps every formed object as new PhSticky(origin, "@loc"), any subtree whose composite locator + "." + name equals "Φ.nan.is-finite" — regardless of what origin is — hits this same slot and gets whatever Phi the first caller inserted:
Phi first = new PhSticky(Phi.Φ.take("nan"), "Φ.nan").take("is-finite");
Phi second = new PhSticky(mockOrigin, "Φ.nan").take("is-finite");
// second == first (mockOrigin.take never runs, mockOrigin's answer is lost)For the currently-scoped constant Φ.nan.is-finite this is probably intended (there's a single "true" origin at that locator in the whole codebase). But the invariant on which correctness rests — that the transpiler-baked locator uniquely identifies the origin behaviour — is a hidden coupling with the rest of the pipeline. Worth documenting on the class so a future addition to KEY doesn't silently violate it.
A defensive tightening: also key on origin.forma() (or System.identityHashCode(this.origin) if forma() is too heavy on the hot path), so a mismatched wrapping can't poison the cache for the real target.
|
|
||
| @Override | ||
| public Phi copy() { | ||
| return new PhSticky(this.origin.copy(), this.locator); |
There was a problem hiding this comment.
copy() returns a fresh PhSticky wrapping origin.copy(), but MEMORY is process-wide keyed only on KEY. Once the original has populated MEMORY[KEY], calling take("is-finite") on the copy silently returns the original's cached Phi, not origin.copy().take("is-finite"). Any client that uses copy() to obtain an independent object — expecting its taken attributes to be built afresh so mutations to the copy don't leak into the original — gets a shared instance here for the targeted expression.
If that's fine for the class of constants covered by this initial slice (the class javadoc argues Φ.nan is context-free), it'd help to spell out on the class that copy and sticky interact this way, because at the Phi API level a .copy() whose taken attribute is sameInstance as the original's is surprising.
morphqdd
left a comment
There was a problem hiding this comment.
Read the actual PR diff and the wrapping logic in to-java.xsl closely. The narrow scope (one hardcoded target, nan.is-finite) is a reasonable first vertical slice and I don't see a correctness bug in the memoization itself. But the implementation raises real design and performance questions worth resolving before this pattern gets extended to more targets, since a few of these get harder to fix once other PRs build on top of this shape.
| <xsl:with-param name="indent" select="$indent"/> | ||
| <xsl:with-param name="rho" select="$rho"/> | ||
| </xsl:apply-templates> | ||
| <xsl:call-template name="sticky"> |
There was a problem hiding this comment.
This unconditionally wraps every formed object in the entire generated codebase in new PhSticky(...), to benefit exactly one hardcoded expression (nan.is-finite). That is a real allocation cost paid everywhere for a benefit that applies nowhere except one call site. Since the transpiler already knows @loc statically at this point, could the wrap itself be gated here (e.g. <xsl:if test="@loc = 'Φ.nan'">) instead of wrapping unconditionally and pushing the filtering into PhSticky.take() at runtime?
| /** | ||
| * The single expression whose taking is memoized. | ||
| */ | ||
| private static final String KEY = "Φ.nan.is-finite"; |
There was a problem hiding this comment.
KEY is a single hardcoded string and MEMORY is effectively a one-entry map (new ConcurrentHashMap<>(1)). The PR description frames this as a "first vertical slice toward memoization" with more targets to follow, but as written this class has no path to a second target without being rewritten: there's no way to add a key without touching this exact string comparison. If the intent is general memoization, would it be worth structuring this now as a set of keys (even a single-element one to start) so the next increment is additive instead of a rewrite?
| @Override | ||
| public Phi take(final String name) { | ||
| final Phi taken; | ||
| if (PhSticky.KEY.equals(String.join(".", this.locator, name))) { |
There was a problem hiding this comment.
String.join(".", this.locator, name) allocates a new string on every take() call, for every wrapped object in the program, just to compare against one constant. Given every formed object is now wrapped (see the to-java.xsl comment), this runs constantly. Checking "is-finite".equals(name) first would let almost every call short-circuit before any allocation, since the vast majority of take() calls will never be for is-finite at all.
| public Phi take(final String name) { | ||
| final Phi taken; | ||
| if (PhSticky.KEY.equals(String.join(".", this.locator, name))) { | ||
| taken = PhSticky.MEMORY.computeIfAbsent(PhSticky.KEY, ignore -> this.origin.take(name)); |
There was a problem hiding this comment.
computeIfAbsent with a mapping function that calls back into this.origin.take(name) is safe here because nan.is-finite is a leaf computation with no further sticky-hitting dependencies. But this is exactly the shape (ConcurrentHashMap.computeIfAbsent whose lambda can re-enter the same map) that throws IllegalStateException: Recursive update in the JDK if the computation ever indirectly calls back into a take() for the same key. Worth a comment here, or a note in the class javadoc, warning future sticky targets away from anything that could recurse through the same key, since nothing here would catch that until it hangs or throws in production.
| /** | ||
| * The table of memoized objects, shared across all stickies. | ||
| */ | ||
| private static final Map<String, Phi> MEMORY = new ConcurrentHashMap<>(1); |
There was a problem hiding this comment.
MEMORY is static, process-wide, and never evicted. Harmless when it holds exactly one entry forever, but worth deciding now, before more targets are added, whether this needs an eviction policy or a bound. Retrofitting that later, once multiple call sites depend on the current unbounded behavior, is more work than deciding it now.
| @SuppressWarnings("PMD.AvoidDuplicateLiterals") | ||
| final class PhStickyTest { | ||
|
|
||
| @Test |
There was a problem hiding this comment.
This test populates the real, static, process-wide nan.is-finite cache entry for the remainder of the JVM's life. eo-runtime's test suite runs with junit.jupiter.execution.parallel.enabled = true (see the module's pom.xml). Is there anything elsewhere in the suite that dataizes nan.is-finite and could be affected by whether this test has already run and populated the cache? This is the same class of bug (claimed-safe shared static state under parallel test execution) that turned out to be real in ConcurrentCache (issue #5720) elsewhere in this codebase. Worth confirming there's no order dependency, or resetting MEMORY between tests if that's feasible.
| @SuppressWarnings("PMD.AvoidDuplicateLiterals") | ||
| final class PhStickyTest { | ||
|
|
||
| @Test |
There was a problem hiding this comment.
None of the three tests exercise actual concurrent access to MEMORY, only sequential calls. The whole reason it's a ConcurrentHashMap is presumably to be safe under concurrent take() calls from multiple threads, but that property is asserted by the type choice, not verified by a test. A test that hits take() from multiple threads concurrently and asserts every thread observes the same instance would actually verify the thread-safety claim.
| @Test | ||
| void memoizesTheTargetedExpressionOfNan() { | ||
| MatcherAssert.assertThat( | ||
| "PhSticky must serve the very same is-finite of nan across the program, but it didnt", |
There was a problem hiding this comment.
"but it didnt" is missing an apostrophe, should be "didn't". Same typo repeated in the other two test messages below (lines 32 and 45).
| @@ -0,0 +1,122 @@ | |||
| /* | |||
There was a problem hiding this comment.
This branch is reported BEHIND master and the raw diff against current upstream/master shows this is missing several recent to-java.xsl changes (the trackLocations/coverage params, PhTerminator handling, eo:package-name helpers). Worth rebasing before this lands, so the two new sticky call sites are actually tested against the current shape of the file rather than an older one.
morphqdd
left a comment
There was a problem hiding this comment.
Two more findings after digging further, on top of the review I already submitted.
| * @since 0.74 | ||
| */ | ||
| @SuppressWarnings("PMD.TooManyMethods") | ||
| public final class PhSticky implements Phi { |
There was a problem hiding this comment.
This branch's own Phi.java ends after forma(), no normalized() method. Current upstream/master's Phi.java has a normalized() method with no default implementation (Phi normalized();), added after this branch diverged. PhSticky does not implement it. This compiles fine on this branch today, but once this is rebased onto current master, PhSticky implements Phi will fail to compile until normalized() is added here too. Worth handling as part of the rebase already flagged in the other review, not after, since it will block the build otherwise.
| - //java[contains(text(), 'r = new PhSticky(r, "')] | ||
| input: | | ||
| # Comment. | ||
| [] > foo |
There was a problem hiding this comment.
//java[contains(text(), 'rb1 = new PhSticky(rb1, "')] only checks that a PhSticky wrapper appears, not that the @loc value baked into it is correct. Since the entire correctness of the memoization depends on the baked locator being exactly right (a wrong or inconsistent locator would either silently disable memoization by never matching KEY, or worse, match across two genuinely different expressions), this test would not catch either failure mode. Worth asserting the full generated line, or at least the specific locator value, not just that some PhSticky(...) call is present.



Part of #5165. First vertical slice toward memoization: the decorator and its transpilation seam.
PhStickymemoizes the result oftakefor a single targeted expression, keyed on a compile-time constant locator baked in by the transpiler (key.name). Deciding whether to memoize is one string comparison — it never activates, dataizes nor reflects upon the wrapped object (unlikeφTerm(), which would). The first real target isΦ.nan.is-finite; a second taking of it anywhere yields the very samePhi. Every other expression is delegated and rebuilt as usual.to-java.xslwraps every formed object innew PhSticky(origin, "<loc>"), baking the object's@locas the key and putting the seam in place.Follow-ups in #5165: value-level keying via
φTerm()(the baked locator is source identity, enough for context-free constants likenan) and purity gating via theImpureannotation (#5245), so impure objects are never shared.