Skip to content

perf(java): replace stream+flatMap with pre-sized for-loop in ArrayTransformUtils#5631

Open
ajzach wants to merge 3 commits intovalkey-io:mainfrom
ajzach:perf/java-array-transform-for-loop
Open

perf(java): replace stream+flatMap with pre-sized for-loop in ArrayTransformUtils#5631
ajzach wants to merge 3 commits intovalkey-io:mainfrom
ajzach:perf/java-array-transform-for-loop

Conversation

@ajzach
Copy link

@ajzach ajzach commented Mar 19, 2026

Summary

Seven methods in ArrayTransformUtils used stream().flatMap(entry -> Stream.of(key, value)).toArray() to interleave map entries into a flat array. This pattern allocates one Stream object per entry (via flatMap) plus SpinedBuffer infrastructure since the final array size is unknown to the stream pipeline.

Replaced with a single pre-sized array (map.size() * 2) and a plain for loop over entrySet() — allocates exactly one array, zero intermediate objects.

flattenAllKeysFollowedByAllValues additionally dropped two ArrayList instances, two intermediate toArray() calls, and a concatenateArrays() stream call — replaced with a single-pass index-offset write into one array.

These methods are called on every mset, hset, zadd, xadd, geoadd, and other bulk commands, so the GC pressure reduction scales directly with command throughput.

Benchmark results

JMH, AverageTime, GC profiler, 2 forks × 10 iterations, mapSize = 50:

Method Latency BEFORE Latency AFTER Alloc BEFORE Alloc AFTER
convertMapToKeyValueGlideStringArray 1029 ns/op 170 ns/op 6976 B/op 416 B/op (-94%)
convertMapToKeyValueStringArray 1207 ns/op 176 ns/op 6976 B/op 416 B/op (-94%)
flattenMapToGlideStringArray 1245 ns/op 181 ns/op 6976 B/op 416 B/op (-94%)
flattenMapToGlideStringArrayValueFirst 992 ns/op 186 ns/op 6976 B/op 416 B/op (-94%)
flattenAllKeysFollowedByAllValues 743 ns/op 190 ns/op 3840 B/op 416 B/op (-89%)
convertMapToValueKeyStringArray 2153 ns/op 968 ns/op 9376 B/op 2816 B/op (-70%)
convertMapToValueKeyStringArrayBinary 3302 ns/op 1533 ns/op 12576 B/op 6016 B/op (-52%)

The 416 B/op residual is exactly the output array (new GlideString[100] with compressed oops = 416 bytes for 50 entries). The remaining allocations in the Value*Key methods are unavoidable Double.toString() / gs() object creation per entry.

Test plan

  • Added ArrayTransformUtilsTest with 15 unit tests covering all modified methods (basic, empty, single-entry, size invariant)
  • Added ArrayTransformBenchmark via JMH plugin (me.champeau.jmh) under benchmarks/ for future regression tracking — run with ./gradlew :benchmarks:jmh
  • All existing unit tests pass

@ajzach ajzach requested a review from a team as a code owner March 19, 2026 13:58
…ansformUtils

Seven methods in ArrayTransformUtils used stream().flatMap(entry ->
Stream.of(key, value)).toArray() to build alternating key-value arrays.
This pattern allocates one Stream object per map entry (via flatMap) plus
internal SpinedBuffer infrastructure since the final size is unknown.

Replace with a single pre-sized array and a plain for-loop over entrySet().
The output array is allocated once at exact capacity (map.size() * 2), and
no intermediate objects are created.

flattenAllKeysFollowedByAllValues additionally dropped two ArrayList
instances, two intermediate toArray() calls, and a concatenateArrays()
stream — replaced with a single-pass index-offset write into one array.

JMH benchmark results (AverageTime, gc profiler, 2 forks x 10 iterations):

  Method (size=50)                         BEFORE          AFTER     alloc BEFORE  alloc AFTER
  convertMapToKeyValueGlideStringArray     1029 ns/op   170 ns/op    6976 B/op      416 B/op  (-94%)
  convertMapToKeyValueStringArray          1207 ns/op   176 ns/op    6976 B/op      416 B/op  (-94%)
  flattenMapToGlideStringArray             1245 ns/op   181 ns/op    6976 B/op      416 B/op  (-94%)
  flattenMapToGlideStringArrayValueFirst    992 ns/op   186 ns/op    6976 B/op      416 B/op  (-94%)
  flattenAllKeysFollowedByAllValues         743 ns/op   190 ns/op    3840 B/op      416 B/op  (-89%)
  convertMapToValueKeyStringArray          2153 ns/op   968 ns/op    9376 B/op     2816 B/op  (-70%)
  convertMapToValueKeyStringArrayBinary    3302 ns/op  1533 ns/op   12576 B/op     6016 B/op  (-52%)

The residual allocations after the change are exactly the output array
(e.g. new GlideString[100] with compressed oops = 416 bytes for size=50).
The remaining allocations in the Value*Key methods are unavoidable
Double.toString() / gs() object creation per entry.

These methods are called on every mset, hset, zadd, xadd, geoadd, and
other bulk commands, so the GC pressure reduction is proportional to
command throughput.

Adds unit tests for all modified methods and a JMH benchmark module
(me.champeau.jmh plugin) under benchmarks/ for future regression tracking.

Signed-off-by: Ariel Zach <[email protected]>
@ajzach ajzach force-pushed the perf/java-array-transform-for-loop branch from ec89654 to 96dfec7 Compare March 19, 2026 14:02
@yeoncheol-jang
Copy link
Contributor

Sorry. This is reviewing here. #5603

@ajzach
Copy link
Author

ajzach commented Mar 19, 2026

Sorry. This is reviewing here. #5603

This PR applies the same optimization pattern as #5603 (stream+flatMap → pre-sized array + for-loop), but extends it to all methods in ArrayTransformUtils that had the same issue — not just the two covered there:

Method #5603 This PR
convertMapToKeyValueStringArray
convertMapToKeyValueGlideStringArray
convertMapToValueKeyStringArray
convertMapToValueKeyStringArrayBinary
flattenMapToGlideStringArray
flattenMapToGlideStringArrayValueFirst
flattenAllKeysFollowedByAllValues

flattenAllKeysFollowedByAllValues had the worst allocation profile — it combined two ArrayList instances, two intermediate toArray() calls, and a concatenateArrays() stream on top of the flatMap pattern. Replaced with a single-pass index-offset write into one pre-sized array.

Results were measured with JMH + GC profiler (2 forks × 10 iterations) to have quantitative evidence before and after each change. Allocation reductions range from −52% to −94% depending on the method.

If #5603 merges first, happy to rebase and drop the two overlapping methods.

keysList.add(GlideString.of(entry.getKey()));
valuesList.add(GlideString.of(entry.getValue()));
result[i] = GlideString.of(entry.getKey());
result[i + size] = GlideString.of(entry.getValue());
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems alot better than concatenating afterwards.

Copy link
Collaborator

@jduo jduo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address the cases where the key and value are null (technically HashMap allows a null key). Add tests for these scenarios.

- Null keys and values now propagate as null elements in the result
  array instead of throwing NullPointerException implicitly
- Add 8 unit tests covering null key and null value cases for the
  four representative methods
- Fix stale JavaDoc/comment in ArrayTransformBenchmark that referenced
  the old flatMap+Stream implementation
Signed-off-by: Ariel Zach <[email protected]>

Signed-off-by: Ariel Zach <[email protected]>
@ajzach ajzach force-pushed the perf/java-array-transform-for-loop branch from b1e597e to 301aabd Compare March 20, 2026 13:05
@ajzach
Copy link
Author

ajzach commented Mar 20, 2026

@jduo Addressed in the latest commit. For all 7 methods:

  • convertMapToKeyValueStringArray / convertMapToValueKeyStringArray / convertMapToValueKeyStringArrayBinary: null value now propagates as a null element in the result array instead of NPE-ing on .toString()
  • convertMapToKeyValueGlideStringArray: null key/value already passed through (direct assignment), now explicitly tested
  • flattenMapToGlideStringArray / flattenMapToGlideStringArrayValueFirst / flattenAllKeysFollowedByAllValues: null key/value short-circuits the GlideString.of() call and places null in the result array

Added 8 unit tests covering null key and null value for the representative methods.

Add nullKey/nullValue tests for convertMapToValueKeyStringArray and
convertMapToValueKeyStringArrayBinary to match coverage of other methods.
Signed-off-by: Ariel Zach <[email protected]>
@ajzach ajzach force-pushed the perf/java-array-transform-for-loop branch from 618e686 to a8b446e Compare March 20, 2026 16:15
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.

4 participants