Skip to content

perf: validate actor path elements without String.charAt - #3542

Open
pjfanning wants to merge 3 commits into
apache:mainfrom
pjfanning:actorpath-no-charat
Open

pjfanning wants to merge 3 commits into
apache:mainfrom
pjfanning:actorpath-no-charat

Conversation

@pjfanning

@pjfanning pjfanning commented Sep 13, 2026 •

Copy link
Copy Markdown
Member

Motivation

ActorPath.findInvalidPathElementCharPosition runs for every named actorOf. It scanned the name with String.charAt inside a pattern match with guards, plus a ValidSymbols.indexOf for every non-alphanumeric character.

String.charAt inlines to isLatin1() ? StringLatin1.charAt : StringUTF16.charAt, and that branch is profiled once, JVM-wide, in String.charAt's own bytecode. Any non-ASCII string handled anywhere in the process (a Jackson payload, a log line) pollutes that profile, after which C2 compiles both coders into every charAt loop. Measured locally, the validator halves in throughput once the profile is polluted.

Modification

  • Copy the element out once with getBytes(ISO_8859_1) — an intrinsic array copy for Latin-1 strings — and scan the byte[] with a 128-entry flag table (valid-char bit, hex-digit bit) instead of calling charAt per character. Characters outside Latin-1 encode as ? and 0x80–0xFF as negative bytes; both are invalid, so the accepted set and the reported position are unchanged.
  • Add directional ActorPathSpec tests for accepted names, rejected names and the reported error position, including Latin-1, non-Latin-1 and surrogate-pair input.
  • Add an exhaustive sweep over all 65,536 chars asserting isValidPathElement agrees with an independently defined valid-char/hex-digit predicate (at position 0, after another char, and inside a %XX escape).
  • Extend ActorPathValidationBenchmark with a polluted param that pre-warms String.charAt with UTF-16 strings, and keep the previous charAt-based validator there as charAtLoop* for comparison.

Result

Name validation no longer depends on the String.charAt coder profile. Local JMH (-f 3 -wi 5 -w 2s -i 10 -r 2s):

Benchmark polluted=false polluted=true
charAtLoopActor_1 (previous code) 108.3 ± 4.3 ops/µs 41.8 ± 0.5 ops/µs
handLoopActor_1 (this PR) 161.0 ± 6.8 ops/µs 162.0 ± 3.5 ops/µs
charAtLoop7000 (previous code) 0.152 ± 0.003 ops/µs 0.086 ± 0.004 ops/µs
handLoop7000 (this PR) 0.101 ± 0.014 ops/µs 0.096 ± 0.007 ops/µs

For typical short names this PR is ~1.5× faster unpolluted and ~3.9× faster polluted. For a pathological 7000-char name it is ~34% slower than an unpolluted charAt loop (the getBytes copy dominates) and roughly on par when polluted.

I tried the same treatment on the other hot charAt users, MurmurHash.stringHash and Helpers.base64, and measured no benefit (hash arithmetic and StringBuilder.append dominate; toCharArray only adds an allocation), so those are left as they are.

Tests

  • sbt "actor-tests/testOnly org.apache.pekko.actor.ActorPathSpec org.apache.pekko.actor.LocalActorRefProviderSpec" — 25 passed
  • sbt "actor-tests/testOnly org.apache.pekko.actor.ActorPathSpec" after adding the exhaustive sweep — 18 passed (sweep takes under a second)
  • sbt "actor-tests/testOnly org.apache.pekko.routing.ConsistentHashingRouterSpec" — passed
  • sbt "actor/mimaReportBinaryIssues" — no issues
  • sbt "bench-jmh/Jmh/compile" and sbt "bench-jmh/Jmh/run -f 3 -wi 5 -w 2s -i 10 -r 2s .*ActorPathValidationBenchmark.(handLoop|charAtLoop).*"
  • scalafmt on changed Scala files, git diff --check

References

None - performance follow-up to the hand-written actor name validator

@pjfanning
pjfanning force-pushed the actorpath-no-charat branch 2 times, most recently from 29cc3ae to 130f727 Compare September 13, 2026 17:02

@He-Pin He-Pin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verified the new byte-table validator is semantically equivalent to the old charAt scan (exhaustive differential check across all 65,536 code units, 0 divergences) and binary-compatible (only a private method body changed). Two suggestions inline.

[info] ActorPathValidationBenchmark.charAtLoopActor_1 true thrpt 5 49.306 ± 8.706 ops/us
[info] ActorPathValidationBenchmark.handLoop7000 false thrpt 5 0.176 ± 0.012 ops/us
[info] ActorPathValidationBenchmark.handLoop7000 true thrpt 5 0.142 ± 0.094 ops/us
[info] ActorPathValidationBenchmark.handLoopActor_1 false thrpt 5 138.841 ± 111.054 ops/us

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The handLoopActor_1 confidence intervals here (+/-111 and +/-281 ops/us) are wider than the scores themselves, so this run cannot support the unpolluted speedup claim for short names. Could you rerun with more forks/iterations (e.g. -f 3 -i 10) before merge?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I need to come back to this - I may have access to another machine later this week where I can run benchmarks with less noise

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Reran with -f 3 -wi 5 -i 10 (2s iterations) and updated the benchmark comment and PR description:

Benchmark polluted=false polluted=true
charAtLoopActor_1 (previous code) 108.3 ± 4.3 ops/µs 41.8 ± 0.5 ops/µs
handLoopActor_1 (this PR) 161.0 ± 6.8 ops/µs 162.0 ± 3.5 ops/µs
charAtLoop7000 (previous code) 0.152 ± 0.003 ops/µs 0.086 ± 0.004 ops/µs
handLoop7000 (this PR) 0.101 ± 0.014 ops/µs 0.096 ± 0.007 ops/µs

The short-name speedup holds with tight intervals (~1.5× unpolluted, ~3.9× polluted). The longer run also shows that for the 7000-char input the byte-table version is ~34% slower than charAt when unpolluted (the getBytes copy dominates) and roughly equal when polluted; the earlier noisy run hid that, and the description now says so. Actor names are short in practice, so I think the trade-off is worth it.

"must not be empty")
}

"accept valid path elements" in {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Consider adding an exhaustive sweep over all 65,536 chars asserting isValidPathElement matches an independently computed predicate; it runs in milliseconds and would permanently lock in equivalence with the previous charAt implementation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added an exhaustive sweep over all 65,536 chars in 5199810 (branch rebased on main too). It checks isValidPathElement against an independently defined valid-char/hex-digit predicate for each char at position 0, after another char, and in both hex positions of a %XX escape. Runs in under a second.

Motivation:
ActorPath.findInvalidPathElementCharPosition runs for every named
actorOf and scanned the name with String.charAt plus a pattern match
with guards and a ValidSymbols.indexOf per non-alphanumeric character.
String.charAt inlines to `isLatin1() ? StringLatin1.charAt :
StringUTF16.charAt`, and that branch is profiled once, JVM-wide, in
String.charAt's own bytecode. Any non-ASCII string handled anywhere in
the process pollutes it, after which C2 compiles both coders into every
charAt loop. Measured locally, the validator halves in throughput
(101 -> 49 ops/us for "actor-1") once the profile is polluted.

Modification:
- Copy the element out once with getBytes(ISO_8859_1) (an intrinsic
  array copy for Latin-1 strings) and scan the byte[] with a 128-entry
  flag table (valid char / hex digit) instead of calling charAt per
  character. Characters outside Latin-1 encode as '?' and 0x80-0xFF as
  negative bytes, both invalid, so accepted set and reported position
  are unchanged.
- Add directional ActorPathSpec tests for accepted names, rejected
  names and the reported position, including Latin-1, non-Latin-1 and
  surrogate-pair input.
- Extend ActorPathValidationBenchmark with a `polluted` param that
  pre-warms String.charAt with UTF-16 strings, and keep the previous
  charAt-based validator there as charAtLoop* for comparison.

Result:
Name validation no longer depends on the String.charAt coder profile:
138-162 ops/us for "actor-1" polluted or not, versus 101 unpolluted and
49 polluted before. Also tried the same for MurmurHash.stringHash and
Helpers.base64 and found no benefit (hash arithmetic and
StringBuilder.append dominate), so those are unchanged.

Tests:
- sbt "actor-tests/testOnly org.apache.pekko.actor.ActorPathSpec org.apache.pekko.actor.LocalActorRefProviderSpec" (25 passed)
- sbt "actor-tests/testOnly org.apache.pekko.routing.ConsistentHashingRouterSpec" (earlier run, passed)
- sbt "actor/mimaReportBinaryIssues" (no issues)
- sbt "bench-jmh/Jmh/compile" and Jmh/run of the pollution benchmark
- scalafmt on changed Scala files, git diff --check

References:
None - performance follow-up to the hand-written actor name validator
Sweep all 65,536 chars and assert isValidPathElement agrees with an
independently defined valid-char and hex-digit predicate, at position 0,
after another char, and inside a %XX escape.
Motivation:
Review feedback: the handLoopActor_1 confidence intervals in the
recorded single-fork run were wider than the scores, so they could not
support the short-name speedup claim.

Modification:
Replace the recorded results with a -f 3 -wi 5 -i 10 run and note that
for very long names the byte-table scanner is slower than an
unpolluted charAt loop because of the getBytes copy.

Result:
"actor-1": 161.0 +/- 6.8 / 162.0 +/- 3.5 ops/us (unpolluted/polluted)
versus 108.3 +/- 4.3 / 41.8 +/- 0.5 for the charAt loop. 7000 chars:
0.101 / 0.096 versus 0.152 / 0.086.

Tests:
- sbt "bench-jmh/Jmh/run -f 3 -wi 5 -w 2s -i 10 -r 2s .*ActorPathValidationBenchmark.(handLoop|charAtLoop).*"
- Not run - scalafmt, comment-only change

References:
None - review follow-up on apache#3542
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.

2 participants