Repository navigation
Copy ImmutableLinkedHashMap without rehashing or tuple allocation - #1479
Conversation
14c1a27 to
b79daf9
Compare
JsObject +/- copied the backing LinkedHashMap with an initial capacity equal to the entry count, so it rehashed once while being filled, and went through the tuple iterator. Size the copy for the final entry count and fill it from entrySet directly: 1.5-1.9x faster +/-, with the same entries, order and memory footprint.
b79daf9 to
e750297
Compare
|
Note on the force-pushes: the first run had a formatting miss in the Scala 3 test (fixed). The second run failed one existing test on JDK 21 / Scala 3.3: |
cchantep
left a comment
There was a problem hiding this comment.
It builds JSON with chains of js + (k -> v)
The current optimization makes each JsObject.+ cheaper, but a chain like:
js + (k1 -> v1) + (k2 -> v2) + ...still copies the backing map for every append.
A JsObject builder could accumulate the fields in a mutable structure and materialize the immutable object once:
JsObject.builder()
.add("a", 1)
.add("b", 2)
.add("c", 3)
.result()That would still require one copy. The important point is avoiding the repeated copies for such use case.
Review feedback: blank lines around the copy loop in both ImmutableLinkedHashMap sources, and before the final assertion in JsObjectSpec.
Agreed. A chain of
While I was checking val b = Json.newBuilder
(1 to 8).foreach(i => b += (s"f$i" -> i))
Json.stringify(b.result())
// {"f7":7,"f6":6,"f1":1,"f8":8,"f5":5,"f3":3,"f4":4,"f2":2} (underlying: immutable.HashMap)
Json.stringify(JsObject((1 to 8).map(i => s"f$i" -> JsNumber(i))))
// {"f1":1,"f2":2,"f3":3,"f4":4,"f5":5,"f6":6,"f7":7,"f8":8}The existing test only uses 2 fields, and I'd like to keep this PR focused on the copy, and open that fix as a separate PR. It changes the observable behaviour of a public API, so it probably deserves its own review and a 3.0.x backport. Would that work for you, or would you rather have it here? |
footprint took its argument by value, so both layouts were the same graph and their difference was always empty: the test compared 0 with 0 and could not fail. Take it by name, like assertSize, and require a non-zero size.
|
Two updates:
|
|
Queued — the merge queue status continues in this comment ↓. |
Merge Queue Status
This pull request spent 20 seconds in the queue, including 2 seconds running CI. Required conditions to merge
|
Pull Request Checklist
Purpose
JsObject.+/-(updated/removedon the backingImmutableLinkedHashMap) copy the wholejava.util.LinkedHashMapeach time. That copy did two avoidable things:for ((k, v) <- this)), i.e. aTuple2pluswithFilter/foreachclosures per entry.The copy is now sized with
ceil(n / 0.75)for the final number of entries, and filled straight fromentrySet(). The change is the same in the 2.12 and 2.13+ sources, and touches about 20 lines.Background Context
Behaviour is unchanged: same entries, same order (overwriting a key keeps its position), same
equals/hashCode.Memory is unchanged too. The old code reached exactly the same final table size, just through an extra rehash. That's why
updatedcheckscontainsKey: an overwrite has to be sized forn, notn + 1, or it would allocate a larger table than before. The existingJsonMemoryFootprintSpecexact-size assertions pass untouched. A new case checks that an object built with+takes the same memory as one built withJsObject(fields), for 1 to 20 fields.I didn't use
putAll: on JDK 17HashMap.putMapEntriespre-sizes withs / 0.75 + 1(fixed in 19 by JDK-8281631), which would make the footprint JDK-dependent.++is untouched. It doesn't go through this copy; the benchmark below confirms it's unchanged.JMH, added to
JsObjectBench: Scala 3.3.8, JDK 25, i7-1360P pinned to P-cores,-wi 8 -i 8 -f 2, ops/ms, 99.9% CI.oPlusNewKeyoPlusExistingKeyoMinusoBuildByAdds(fold+from empty)oConcatAll
+/-/ build intervals are disjoint; theoConcatones overlap.In an application: I found this profiling lichess. It builds JSON with chains of
js + (k -> v), and the copy was 11–14% of the CPU of its tournament-list endpoints. With play-json 3.0.6 plus this patch (it applies cleanly to 3.0.x), an A/B of 4 interleaved rounds showed about 2% less CPU per request on those endpoints. That's within the run-to-run noise, so the clear win is at theJsObjectlevel rather than end to end. The app's 586 tests and its JSON golden snapshots pass unchanged.Checked locally:
+play-jsonJVM/testFullon 2.12 / 2.13 / 3.3,+mimaReportBinaryIssues,validateCode,docs/testFull, and+play-jsonJS/Test/compile/+play-jsonNative/Test/compile. I didn't have Node or clang locally, so the JS and Native tests themselves are left to CI.References
None. I didn't find an existing issue about this.
AI disclosure: I directed and verified this work; Claude Code (Claude Opus) assisted with the profiling, the patch, tests, benchmarks and this description.