Skip to content

[ISSUE #11003] Reduce allocations in hot-path request headers' toString() - #11004

Open
qianye1001 wants to merge 2 commits into
apache:developfrom
qianye1001:task/header-tostring-perf-20260903-155003
Open

[ISSUE #11003] Reduce allocations in hot-path request headers' toString()#11004
qianye1001 wants to merge 2 commits into
apache:developfrom
qianye1001:task/header-tostring-perf-20260903-155003

Conversation

@qianye1001

@qianye1001 qianye1001 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Which Issue(s) This PR Fixes

Brief Description

Replaces Guava MoreObjects.toStringHelper with a pre-sized StringBuilder in the toString() of the 10 request headers on the core send / consume / ack / offset-commit data path, under remoting/.../protocol/header/.

The helper form allocates, per call and before the resulting String is even built:

  1. one ToStringHelper object;
  2. one ValueHolder head plus one ValueHolder node per .add(...) (a singly linked list);
  3. getClass().getSimpleName(), which itself allocates (getName() + substring);
  4. an internal StringBuilder(32) that grows through repeated Arrays.copyOf once the rendering exceeds 32 characters;

and then traverses that list to render. For a header with N fields that is roughly N + 3 short-lived objects plus an O(N) traversal per call. These toString() methods are on hot paths (request logging, exception messages, troubleshooting output), so at high throughput this is a steady stream of short-lived garbage and CPU spent purely on rendering.

The replacement uses a single chained append sequence with a per-class initial capacity.

Semantics preserved exactly:

  • .omitNullValues() (AckMessageRequestHeader, ChangeInvisibleTimeRequestHeader, NotificationRequestHeader) becomes conditional appends that skip null entries. Primitive-typed fields are always emitted, matching today's behaviour since they autobox to a non-null value.
  • Conditional entries - NotificationRequestHeader's .add("isLiteConsumer", isLiteConsumer ? true : null) is preserved by evaluating the expression into a local and null-checking it, so the entry is still rendered only when true.
  • The unused com.google.common.base.MoreObjects import is dropped from each touched file.

Capacity estimate

The initial capacity is derived per class from the fixed skeleton (class name + field names + = / , separators) plus a per-field value allowance based on realistic widths rather than a flat guess:

field kind allowance
topic / group strings 50
properties / subscription / extraInfo 128
queueId, defaultTopicQueueNums 3
maxMsgNums / maxMsgNum 2
long / Long (timestamps, offsets) 20
other int / Integer 11
boolean / Boolean 5
other strings 16

SendMessageRequestHeaderV2 uses compressed field names (a..n), so the allowance is applied by semantic field (a=producerGroup, b=topic, c=defaultTopic, i=properties, ...) rather than by identifier.

This matters more than it looks. Measured on a 348-character rendering, allocation and throughput by initial capacity:

capacity alloc vs exact-fit throughput vs exact-fit young GC count
16 (what javac emits for +) 229% 0.56x 40
exact fit 100% 1.00x 18
1.6x exact 127% 0.88x 23
3.4x exact 202% 0.58x 37
13.8x exact 635% 0.29x 117

new char[cap] is allocated and zero-filled in proportion to the capacity, while toString() copies only the real length - so slack is pure waste and it accelerates eden filling. Over-sizing is therefore not free, which is why the estimate is per-field.

Measured effect

On the real classes, JDK 8 runtime, this module's target 1.8 bytecode, realistic field values (~50-char topic/group, ~120-char properties). Allocation measured with ThreadMXBean.getThreadAllocatedBytes:

Header alloc before alloc after reduction throughput
SendMessageRequestHeader (send) 4056 B 2432 B -40.0% 1.91x
SendMessageRequestHeaderV2 (sendV2) 4520 B 2000 B -55.8% 2.29x
PopMessageRequestHeader (pop) 3744 B 1552 B -58.5% 2.55x
PopLiteMessageRequestHeader (popLite) 3472 B 1360 B -60.8% 2.81x
PullMessageRequestHeader (pull) 6624 B 3008 B -54.6% 2.21x
NotificationRequestHeader (notification, omitNullValues) 3424 B 2632 B -23.1% 1.43x
AckMessageRequestHeader (ack, omitNullValues) 3328 B 1632 B -51.0% 2.14x
ChangeInvisibleTimeRequestHeader (omitNullValues) 3568 B 1928 B -46.0% 1.96x
UpdateConsumerOffsetRequestHeader (offset commit) 1944 B 1848 B -4.9% 1.46x
ConsumerSendMsgBackRequestHeader (retry) 3288 B 1312 B -60.1% 2.79x

Note on the choice of form: plain + concatenation is more concise and on a JDK 9+ bytecode target compiles to invokedynamic / StringConcatFactory.makeConcatWithConstants, which beats anything hand-written - measured 4.53x and -73% allocation on JDK 21. But at this module's target 1.8, javac lowers + to new StringBuilder() with the default capacity of 16, so the growth-and-copy steps cancel out most of the benefit of dropping Guava: measured on a 348-char rendering, + gives 1.27x and -2.6% allocation versus 2.07x and -48.3% for the pre-sized builder. The pre-sized builder also stays good on newer targets (1.95x on JDK 21), so it is not a liability; if the project later raises the bytecode target, switching these to plain concatenation would be a worthwhile follow-up.

Scope note: GetMaxOffsetRequestHeader, GetMinOffsetRequestHeader, QueryConsumerOffsetRequestHeader and RecallMessageRequestHeader were deliberately dropped from this change and are byte-identical to develop. Together with the admin/low-frequency headers, the remaining toStringHelper users can be a follow-up.

How Did You Test This Change?

The rendered strings end up in logs that people and external tooling parse, so the requirement is that output stays byte-for-byte identical. Testing focused on proving that rather than on eyeballing the diff.

1. Differential test against the original compiled bytecode (primary evidence). A pristine worktree at the merge base (bee586bcd) and the modified tree are compiled into two separate output directories, then loaded into two independent class loaders in one JVM. For each header the test instantiates both versions, applies an identical field-value plan to both via reflection, and compares toString(). This compares against the real original implementation, so it does not depend on any assumption about how Guava formats things.

Value plans per class: all-default (exercising field initializers such as order = Boolean.FALSE, suspend = false, unitMode = false), all-null, all-non-null, each field individually null with all others set, each field individually set with all others null, boolean/Boolean fields in both states (this is what covers the conditional isLiteConsumer entry), and 40,000 randomized adversarial value sets per class drawing from null, empty string, \r, \n, \t, \r\n\t, other control characters (\u0001, \u0002), non-ASCII and surrogate-pair text, 300- and 2000-character strings, %RETRY%-prefixed groups, hex message ids, and Integer/Long MAX_VALUE/MIN_VALUE.

Result: 400,380 cases, 0 mismatches across the 10 classes.

2. Negative control on the test itself. An earlier iteration of this change silently dropped the conditional isLiteConsumer entry (its .add(...) value is a ternary rather than a plain field reference, so a naive rewrite lost it). Deleting that handling from the final code makes the differential test fail immediately and print the exact divergence:

orig=NotificationRequestHeader{consumerGroup=V, topic=V, queueId=7, pollTime=77, bornTime=77, order=true, attemptId=V, isLiteConsumer=true, clientId=V}
new =NotificationRequestHeader{consumerGroup=V, topic=V, queueId=7, pollTime=77, bornTime=77, order=true, attemptId=V, clientId=V}

This confirms the test detects this class of regression instead of passing vacuously.

3. Bytecode-level scope check. Decompiling the built classes confirms toString() in the 10 touched headers has zero MoreObjects references, while the 4 deliberately-excluded headers still show theirs (5, 4, 6 and 5 references respectively) - i.e. nothing outside the intended scope was altered. For an earlier revision of this change the same technique confirmed that all non-toString methods were byte-identical to the original.

4. Existing test suite and style gates.

  • mvn -pl remoting test: 174 tests, 0 failures, 0 errors (includes SendMessageRequestHeaderV2Test, RpcRequestHeaderTest, ProxyProtocolTest, FastCodesHeaderTest).
  • checkstyle with the project's style/rmq_checkstyle.xml at the validate phase: 0 violations (this also confirms dropping the now-unused MoreObjects import is clean).
  • mvn -pl remoting -am compile: BUILD SUCCESS.

5. Diff scope. git diff --stat origin/develop touches exactly the 10 intended header files and nothing else; each file's change is confined to its toString() body plus removal of the unused import.

…toString()

Replace Guava MoreObjects.toStringHelper with a pre-sized StringBuilder in the
toString() of 14 high-frequency send / consume / ack / offset request headers.

The helper form allocates a ToStringHelper plus one ValueHolder linked-list node
per add() call, resolves getClass().getSimpleName(), and grows an internal
StringBuilder(32) by repeated copying, before traversing the list to render.
For a header with N fields that is about N+3 short-lived objects per call.

The pre-sized builder keeps the rendered output byte-for-byte identical,
including omitNullValues() semantics and the conditional
.add("isLiteConsumer", isLiteConsumer ? true : null) entry in
NotificationRequestHeader.

Measured at the module's current target 1.8: 1.62x-2.60x faster toString() and
32%-48% less allocation per call.

Verified byte-identical by a differential test that loads the original and the
modified classes in separate class loaders and compares toString() over
560,483 cases (all-default / all-null / all-non-null / each field individually
null / each field individually set / boolean fields in both states / 40,000
randomized adversarial value sets per class). mvn -pl remoting test passes
(174 tests) and checkstyle reports 0 violations.

@RockteMQ-AI RockteMQ-AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Summary

Replaces Guava MoreObjects.toStringHelper with pre-sized StringBuilder in 14 hot-path request header toString() methods. Allocation reduced by 36–48% with 1.8–2.1x throughput improvement. Semantics are preserved correctly:

  • Primitive-typed fields (int queueId, long pollTime, long bornTime) are always emitted — matches original behavior since they can never be null.
  • Nullable fields (String, Integer, Boolean) are properly null-guarded with the first flag pattern, preserving the original omitNullValues() contract.
  • The isLiteConsumer ? true : null conditional in NotificationRequestHeader is correctly materialized into a local Object and null-checked.

Benchmarks are convincing (560K+ differential test cases, per-class allocation measurements). CI is still running but misspell-check and check-license already pass.

LGTM — clean, well-tested, and a meaningful reduction in GC pressure on the request path.


Automated review by github-manager-bot

@codecov-commenter

codecov-commenter commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 214 lines in your changes missing coverage. Please review.
✅ Project coverage is 48.71%. Comparing base (bee586b) to head (b225249).
⚠️ Report is 4 commits behind head on develop.

Files with missing lines Patch % Lines
...ing/protocol/header/NotificationRequestHeader.java 0.00% 48 Missing ⚠️
...tocol/header/ChangeInvisibleTimeRequestHeader.java 0.00% 44 Missing ⚠️
...oting/protocol/header/AckMessageRequestHeader.java 0.00% 35 Missing ⚠️
...ting/protocol/header/PullMessageRequestHeader.java 0.00% 17 Missing ⚠️
...ng/protocol/header/SendMessageRequestHeaderV2.java 0.00% 16 Missing ⚠️
...ting/protocol/header/SendMessageRequestHeader.java 0.00% 15 Missing ⚠️
...oting/protocol/header/PopMessageRequestHeader.java 0.00% 14 Missing ⚠️
...g/protocol/header/PopLiteMessageRequestHeader.java 0.00% 10 Missing ⚠️
...tocol/header/ConsumerSendMsgBackRequestHeader.java 0.00% 9 Missing ⚠️
...ocol/header/UpdateConsumerOffsetRequestHeader.java 0.00% 6 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##             develop   #11004      +/-   ##
=============================================
- Coverage      48.74%   48.71%   -0.04%     
- Complexity     13736    13751      +15     
=============================================
  Files           1381     1381              
  Lines         101562   101669     +107     
  Branches       13210    13255      +45     
=============================================
+ Hits           49505    49523      +18     
- Misses         46007    46105      +98     
+ Partials        6050     6041       -9     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…en capacity estimate

Scope: drop GetMaxOffsetRequestHeader, GetMinOffsetRequestHeader,
RecallMessageRequestHeader and QueryConsumerOffsetRequestHeader, keeping the 10
headers on the core send / consume / ack / offset-commit data path. Those four
are now byte-identical to develop.

Capacity estimate: size each StringBuilder from the fixed skeleton (class name +
field names + separators) plus a per-field value allowance based on realistic
widths instead of a flat guess - 50 for topic/group strings, 128 for
properties/subscription/extraInfo, 3 for queueId/defaultTopicQueueNums, 2 for
maxMsgNums, 20 for long, 11 for other int, 5 for boolean, 16 for other strings.
SendMessageRequestHeaderV2 uses compressed field names (a..n), so the allowance
is applied by semantic field rather than by identifier.

Measured allocation is very sensitive to this: for a 348-char rendering, sizing
the builder at 16 costs 229% of the exact-fit allocation and 0.56x throughput,
at the exact length it is the optimum, and at 4800 it costs 635% and 0.29x.
Over-sizing is therefore not free, which is why the estimate is per-field.

Re-verified against a pristine build of the merge base: 400,380 differential
cases over the 10 classes, 0 mismatches. mvn -pl remoting test passes
(174 tests) and checkstyle reports 0 violations.

@RockteMQ-AI RockteMQ-AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Summary

Replaces Guava MoreObjects.toStringHelper() with manual StringBuilder-based toString() across 10 hot-path request header classes. Clean mechanical refactor that reduces object allocation.

Review Notes

  • Pre-allocated StringBuilder sizes (e.g., 391, 191) are reasonable and avoid resizing
  • Null-field omission logic correctly mirrors Guava's omitNullValues() behavior
  • Output format (ClassName{field=value, ...}) is compatible with the original
  • Primitive fields in NotificationRequestHeader (queueId, pollTime, bornTime) could skip the !first guard since they're never null, but this is cosmetic and doesn't affect correctness

LGTM — straightforward performance improvement with no behavioral changes.


Automated review by github-manager-bot

@RockteMQ-AI RockteMQ-AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Summary

Replaces Guava MoreObjects.toStringHelper with pre-sized StringBuilder in 10 hot-path request headers. Exceptional PR quality.

Key strengths:

  • 40-60% allocation reduction per toString() call on send/consume/ack/offset-commit paths
  • Per-class capacity estimates based on realistic field widths, not flat guesses
  • Semantics preserved exactly: omitNullValues() becomes conditional appends, primitive fields always emitted
  • 400,380 differential test cases (including 40,000 adversarial random sets per class) with 0 mismatches
  • Negative control test verifies the test catches regressions (the isLiteConsumer case)
  • Clear scope: 4 deliberately-excluded headers still show their MoreObjects references

Minor observation:
The pre-sized StringBuilder approach is optimal for JDK 8 target. When the project eventually moves to JDK 9+ bytecode, plain concatenation would be even better (invokedynamic/StringConcatFactory). The PR body notes this as a worthwhile follow-up.

LGTM — exemplary PR with thorough testing and clear documentation 👍


Automated review by github-manager-bot

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.

[Enhancement] Reduce allocations in hot-path request headers' toString() by replacing Guava MoreObjects.toStringHelper

3 participants