Skip to content

perf(gb-bot): replace tileNumber/isHonor string parsing with lookup tables#106

Closed
Arbousier1 wants to merge 4 commits into
devfrom
codex/perf-gb-bot-lookups
Closed

perf(gb-bot): replace tileNumber/isHonor string parsing with lookup tables#106
Arbousier1 wants to merge 4 commits into
devfrom
codex/perf-gb-bot-lookups

Conversation

@Arbousier1

Copy link
Copy Markdown
Collaborator

Summary

Replace per-call String allocation + Integer.parseInt parsing in GbRoundSupport.tileNumber() and the ordinal-range check in isHonor() with two ordinal-indexed lookup tables computed once at class init.

Motivation

GbRoundSupport.tileNumber() is called inside the GB bot discard-decision hot loop (GbBotDecisionService.discardPreference). Every call allocates a new String via tile.name().substring(1, 2) and parses it with Integer.parseInt. Because the String escapes into parseInt, escape analysis cannot eliminate it. isHonor() performs a two-ordinal comparison on every call.

discardPreference is invoked once per hand tile in bestDiscardChoice, and internally iterates every candidate tile calling isHonor + tileNumber multiple times per candidate. For a 14-tile hand this produces dozens of short-lived String allocations per decision.

Change

Precompute two private static final arrays indexed by MahjongTile.ordinal():

  • TILE_NUMBERS[ord] — the 1-9 value for suited tiles (M1-M9, P1-P9, S1-S9, including red-fives), 0 for honors/flowers/unknown.
  • TILE_HONORS[ord]true for EAST..RED_DRAGON, false otherwise.

tileNumber(tile) now returns TILE_NUMBERS[tile.ordinal()] (after the same null/flower/honor guard), and isHonor(tile) returns TILE_HONORS[tile.ordinal()].

Behavior

Unchanged. The lookup values are computed from the original expressions during static initialization. The same control-flow guard (null || isFlower || isHonor) is preserved in tileNumber. The only edge-case difference: tileNumber(MahjongTile.UNKNOWN) now returns 0 instead of throwing NumberFormatException — but UNKNOWN is never passed to tileNumber (all callers guard with isFlower()/isHonor() or only pass suited tiles from hands/walls).

Target profile

gb-bot — must_pass: gb.duplicate.time, gb.mixed.time, gb.unique.time.

…ables

GbRoundSupport.tileNumber() allocated a new String via name().substring(1,2)
and parsed it with Integer.parseInt on every call. In the GB bot discard
decision hot loop (GbBotDecisionService.discardPreference), tileNumber and
isHonor are called once per candidate tile per hand tile, producing
repeated short-lived String allocations that escape into parseInt and
cannot be escape-analyzed away.

Precompute two ordinal-indexed arrays once at class init:
- TILE_NUMBERS: the 1-9 value for suited tiles, 0 for honors/flowers/unknown
- TILE_HONORS: the EAST..RED_DRAGON honor range as a boolean mask

tileNumber() and isHonor() now perform a single array load instead of
string allocation + parsing. Behavior is unchanged: the same control flow
(null/flower/honor guard) is preserved, and the lookup values are computed
from the original expressions during static init.

Target profile: gb-bot (must_pass: gb.duplicate.time, gb.mixed.time,
gb.unique.time).
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@Arbousier1 Arbousier1 added performance-ab Run the base-owned paired performance gate performance-gb-bot Use the GB bot decision performance profile labels Jul 18, 2026
…allocation

bestDiscardChoice allocated a 45-element GbTingResponse[] on every call to
memoize ting evaluations by tile ordinal. On the duplicate-hand hot path
measured by GbBotDecisionBenchmark.duplicateHand the memo only ever writes
a single slot, so the array allocation dominates the per-iteration allocation
footprint without delivering reuse benefit.

Replace the per-call allocation with a ThreadLocal buffer that is cleared via
Arrays.fill at the start of each call. Semantics are unchanged: the array is
indexed by MahjongTile.ordinal(), fully reset before use, and never escapes
the method. Each worker thread gets its own buffer, so there is no contention
or cross-thread visibility concern.

This eliminates ~376 bytes of heap allocation per discard decision (object
header + 45 pointer slots), which should push the duplicateHand improvement
above the 3% AB gate threshold that the lookup-table-only commit could not
clear on its own.
… hot path

suggestedDiscardIndex only needs the discard index, but went through
bestDiscardChoice which always allocates a DiscardChoice record (int index,
long readyScore, int discardPreference) just to immediately extract .index()
and discard the record. On the duplicateHand hot path measured by
GbBotDecisionBenchmark.duplicateHand this is a per-iteration allocation that
the caller never benefits from.

Add a bestDiscardIndex variant that returns primitive int directly, avoiding
the record allocation. The loop body is identical to bestDiscardChoice; only
the return shape differs. bestDiscardChoice is retained unchanged for the
reaction/kan callers that need the full (index, readyScore, discardPreference)
triple.

This is a pure allocation elimination — no behavior change, no ThreadLocal
overhead, no shared mutable state. Pairs with the lookup-table optimization
in the previous commit to push duplicateHand above the 3% AB gate threshold.
@Arbousier1

Copy link
Copy Markdown
Collaborator Author

Closing after 3 inconclusive AB attempts (lookup tables, ThreadLocal tingMemo reverted, bestDiscardIndex). The gb-bot hot path's allocation savings are within JIT optimization noise for this benchmark. Will revisit with a different approach.

@Arbousier1 Arbousier1 closed this Jul 18, 2026
@Arbousier1
Arbousier1 deleted the codex/perf-gb-bot-lookups branch July 18, 2026 05:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance-ab Run the base-owned paired performance gate performance-gb-bot Use the GB bot decision performance profile

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant