Skip to content

Reduce per-bind allocations on the bind hot path#672

Open
vharseko wants to merge 2 commits into
OpenIdentityPlatform:masterfrom
vharseko:features/bind-7
Open

Reduce per-bind allocations on the bind hot path#672
vharseko wants to merge 2 commits into
OpenIdentityPlatform:masterfrom
vharseko:features/bind-7

Conversation

@vharseko

@vharseko vharseko commented Jul 2, 2026

Copy link
Copy Markdown
Member

Summary

Allocation-diet follow-up to the bind-path series (#660, #667, #668, #669,
#670): two allocation sites ran unconditionally for every bind, feeding
the GC pressure observed at saturation in #660 ("p99 is GC-bound").

Problem & Change

  1. BindOperationBasis.run() allocated a CancelRequest and its
    LocalizableMessage on every bind to cancel outstanding operations —
    but on a plain re-bind over a persistent connection the bind itself is
    the only operation in progress, so there was never anything to cancel.
    Both classes are immutable (the message is rendered per locale at use),
    so a single shared static final instance is now passed to the
    unconditional cancelAllOperationsExcept() call — zero allocations on
    every bind, including binds that do have operations to cancel (see the
    review follow-up below for the history).

  2. PasswordPolicyState.passwordMatches() processed every stored
    password value as
    ByteString → String (whole value) → String[] {scheme, payload} → ByteString (payload re-encoded) before handing it to the storage
    scheme. The {scheme} prefix is now parsed at the byte level and the
    payload is passed as a zero-copy subSequence — per stored value
    this removes the full-value String, one substring and the
    ByteString.valueOfUtf8 re-encode, leaving only the short lowercase
    scheme-name string. Malformed values fall back to
    UserPasswordSyntax.decodeUserPassword so error behaviour is
    byte-for-byte identical; the authPassword syntax path is unchanged.

Deliberately not included: a shared "anonymous" AuthenticationInfo
for setUnauthenticated()AuthenticationInfo is mutable
(setMustChangePassword, setAuthenticationDN), so a shared instance
would risk cross-connection corruption.

Benchmark

Same harness as the rest of the series: packaged server, je backend,
5,000 users ({SSHA}), JDK 11, 8-core host, both access loggers disabled;
authrate with 200 persistent connections re-binding as random users:

authrate -h 127.0.0.1 -p 1389 -c 200 -f -B 20 -d 80 -i 5 \
  -D "uid=user.%d,ou=People,dc=example,dc=com" -w password \
  -g "rand(0,5000)"

Interleaved A/B (old → new per round, warm-up run before each measurement,
cool-down before the series):

round old new
1 25,150 ops/s / 7.99 ms / p99.9 72 ms (environmental outlier, discarded: 5.6k ops/s with 0 errors while the old run in the same slot was healthy)
2 19,693 ops/s / 12.06 ms / p99.9 119 ms 25,459 ops/s / 8.87 ms / p99.9 126 ms (+29%)
3 23,730 ops/s / 9.41 ms / p99.9 107 ms 29,489 ops/s / 6.79 ms / p99.9 70 ms (+24%)

Allocation attribution (JFR jdk.ObjectAllocationInNewTLAB, 60 s window
during steady load): the share of allocation samples in password-matching
stacks drops from 3.8% to 2.6%, with String samples down 40%
(213 → 127) even though the new run processed more binds (47.6k vs 38.6k
total samples). The remaining byte[] allocations are the Base64 decode
inside the storage scheme itself, out of scope here. authrate errors: 0
in all runs.

Review follow-up (8938156)

The first version guarded the cancellation with
getOperationsInProgress().size() > 1, which drew a review concern about
a check-then-act window. The race is not actually reachable — both LDAP
stacks gate the connection while a bind is in progress
(LDAPClientConnection.processDataRead() stops reading the socket and
already-decoded messages are rejected with CONSTRAINT_VIOLATION;
LDAPClientConnection2 rejects likewise, and bindInProgress is set
before the bind operation is enqueued), so the in-progress set can only
shrink between the check and the call. The unconditional old code also
had the identical register-after-cancel window, so the guard changed no
semantics class. Still, the guard's benefit did not justify the debate:
since CancelRequest and LocalizableMessage are immutable, one shared
static final CANCEL_ALL_BY_BIND_REQUEST restores the unconditional call
verbatim and drops the allocations entirely — strictly better than the
guard on both axes.

Testing

mvn -P precommit -pl opendj-server-legacy verify for
BindOperationTestCase (1140), PasswordPolicyTestCase (143),
SaltedSHA1PasswordStorageSchemeTestCase (43):
1326 tests, 0 failures — re-run with the same result after the
review follow-up.

Files

  • opendj-server-legacy/src/main/java/org/opends/server/core/BindOperationBasis.java
  • opendj-server-legacy/src/main/java/org/opends/server/core/PasswordPolicyState.java

Two allocation sites ran unconditionally for every bind:

- BindOperationBasis.run() built a CancelRequest and its localized
  message even when the bind was the only operation in progress (the
  common case on a persistent connection), so nothing could be
  cancelled. Build them only when other operations exist.

- PasswordPolicyState.passwordMatches() materialized every stored
  password value as a String, split it into String[] components, and
  re-encoded the payload back into a ByteString before handing it to
  the storage scheme. Parse the {scheme} prefix at the byte level and
  pass the payload as a zero-copy sub-sequence instead; malformed
  values fall back to the String decoder to preserve error behaviour.
  The authPassword syntax path is unchanged.

JFR TLAB profiling under the PR OpenIdentityPlatform#660 benchmark scenario shows the
password-matching share of allocations drop from 3.8% to 2.6% (String
samples -40% at higher throughput); interleaved A/B rounds show
+24..+29% throughput with lower p99.9 on an 8-core host.
…in progress

The size() > 1 guard drew a review concern about a check-then-act window
(new operations cannot in fact register while a bind is in progress —
both LDAP stacks gate the connection — but the guard's benefit does not
justify the debate). CancelRequest and LocalizableMessage are immutable,
so a single shared instance restores the unconditional
cancelAllOperationsExcept() call while dropping the per-bind allocations
entirely — including on binds that do have operations to cancel.
@vharseko vharseko added the performance Performance / concurrency / lock-contention work label Jul 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

benchmark enhancement performance Performance / concurrency / lock-contention work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants