pgn2 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/release.yml +103 -0
- data/.gitignore +2 -1
- data/CHANGELOG.md +55 -0
- data/README.md +38 -15
- data/TODO.md +38 -0
- data/bench/IMPROVEMENTS.md +92 -0
- data/bench/baseline_moves.pre-quickwins.txt +23 -0
- data/bench/baseline_moves.txt +6 -6
- data/bench/baseline_parse.pre-quickwins.txt +26 -0
- data/bench/baseline_parse.txt +6 -6
- data/docs/superpowers/specs/2026-08-13-pgn-performance-quick-wins-design.md +227 -0
- data/lib/pgn/game.rb +30 -19
- data/lib/pgn/lexer.rb +56 -37
- data/lib/pgn/move.rb +5 -2
- data/lib/pgn/move_calculator.rb +11 -8
- data/lib/pgn/parser.rb +5 -15
- data/lib/pgn/pgn_parser.rb +84 -76
- data/lib/pgn/pgn_parser.y +25 -20
- data/lib/pgn/position.rb +3 -2
- data/lib/pgn/version.rb +1 -1
- data/spec/parser_spec.rb +6 -38
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4a5ce857277684a9f89c02d35941d7c455df2ca8a18f557985f3ca92eed414b3
|
|
4
|
+
data.tar.gz: 5f126a3da7a6f314748495919168336bb7b97d07e5ad0c9f2ac9b9956a8bb4be
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '079c4e3c1dd292c58fac802349c8d9d8054efa4a01288090bd0a38472029b8848a162cd4264fe83135c80d14fb9ea65d531009ddc6dbd6237e30c4f4bb9efda8'
|
|
7
|
+
data.tar.gz: e3ec9dbcf8dad46e7e8ec623642d468253a4ee6d5899dd9d1ac7486e60416b9069a49e34de092e08744439b9c40dce84074a6ddcaed91a584d88a65be6646b9a
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Auto-releases on any push to `main` whose version (lib/pgn/version.rb) does
|
|
4
|
+
# not yet have a matching `v<x.y.z>` tag. It gates on the RSpec matrix, then
|
|
5
|
+
# creates the tag and pushes the gem to rubygems.org in this same workflow
|
|
6
|
+
# (a tag pushed by the GITHUB_TOKEN does NOT trigger other workflows, so the
|
|
7
|
+
# publish happens here directly to avoid that limitation).
|
|
8
|
+
#
|
|
9
|
+
# Manual releases still work: `git tag vX.Y.Z && git push origin vX.Y.Z` fires
|
|
10
|
+
# publish.yml (human-pushed tags do trigger workflows).
|
|
11
|
+
#
|
|
12
|
+
# Requires the RUBYGEMS_API_KEY repository secret (already set).
|
|
13
|
+
|
|
14
|
+
on:
|
|
15
|
+
push:
|
|
16
|
+
branches: [main]
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
check:
|
|
20
|
+
name: Detect version bump
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
outputs:
|
|
23
|
+
version: ${{ steps.ver.outputs.version }}
|
|
24
|
+
should_release: ${{ steps.ver.outputs.should_release }}
|
|
25
|
+
steps:
|
|
26
|
+
- uses: actions/checkout@v4
|
|
27
|
+
- id: ver
|
|
28
|
+
run: |
|
|
29
|
+
version="$(ruby -Ilib -rpgn/version -e 'print PGN::VERSION')"
|
|
30
|
+
echo "version=$version" >> "$GITHUB_OUTPUT"
|
|
31
|
+
if git ls-remote --tags origin "refs/tags/v$version" | grep -q .; then
|
|
32
|
+
echo "should_release=false" >> "$GITHUB_OUTPUT"
|
|
33
|
+
echo "v$version already tagged; skipping release"
|
|
34
|
+
else
|
|
35
|
+
echo "should_release=true" >> "$GITHUB_OUTPUT"
|
|
36
|
+
echo "v$version is new; will release"
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
test:
|
|
40
|
+
name: RSpec (ruby ${{ matrix.ruby }})
|
|
41
|
+
needs: check
|
|
42
|
+
if: needs.check.outputs.should_release == 'true'
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
strategy:
|
|
45
|
+
fail-fast: true
|
|
46
|
+
matrix:
|
|
47
|
+
ruby: ['3.1', '3.2', '3.3', '3.4']
|
|
48
|
+
steps:
|
|
49
|
+
- uses: actions/checkout@v4
|
|
50
|
+
- uses: ruby/setup-ruby@v1
|
|
51
|
+
with:
|
|
52
|
+
ruby-version: ${{ matrix.ruby }}
|
|
53
|
+
bundler-cache: true
|
|
54
|
+
- run: bundle exec rspec
|
|
55
|
+
|
|
56
|
+
release:
|
|
57
|
+
name: Tag & publish
|
|
58
|
+
needs: [check, test]
|
|
59
|
+
if: needs.check.outputs.should_release == 'true'
|
|
60
|
+
runs-on: ubuntu-latest
|
|
61
|
+
environment: release
|
|
62
|
+
permissions:
|
|
63
|
+
contents: write
|
|
64
|
+
steps:
|
|
65
|
+
- uses: actions/checkout@v4
|
|
66
|
+
- uses: ruby/setup-ruby@v1
|
|
67
|
+
with:
|
|
68
|
+
ruby-version: '3.3'
|
|
69
|
+
bundler-cache: true
|
|
70
|
+
|
|
71
|
+
- name: Create and push tag v${{ needs.check.outputs.version }}
|
|
72
|
+
run: |
|
|
73
|
+
git config user.name "github-actions[bot]"
|
|
74
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
75
|
+
git tag "v${{ needs.check.outputs.version }}" -m "v${{ needs.check.outputs.version }}"
|
|
76
|
+
git push origin "v${{ needs.check.outputs.version }}"
|
|
77
|
+
|
|
78
|
+
- name: Build gem
|
|
79
|
+
run: bundle exec rake build
|
|
80
|
+
|
|
81
|
+
- name: Configure RubyGems credentials
|
|
82
|
+
run: |
|
|
83
|
+
mkdir -p "$HOME/.gem"
|
|
84
|
+
printf -- ":rubygems_api_key: %s\n" "${RUBYGEMS_API_KEY}" > "$HOME/.gem/credentials"
|
|
85
|
+
chmod 0600 "$HOME/.gem/credentials"
|
|
86
|
+
env:
|
|
87
|
+
RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
|
|
88
|
+
|
|
89
|
+
- name: Push gem
|
|
90
|
+
run: gem push pkg/*.gem
|
|
91
|
+
|
|
92
|
+
- name: Verify publication
|
|
93
|
+
continue-on-error: true
|
|
94
|
+
run: |
|
|
95
|
+
ver="${{ needs.check.outputs.version }}"
|
|
96
|
+
for i in 1 2 3 4 5 6; do
|
|
97
|
+
if gem fetch pgn2 --version "$ver" --platform gem >/dev/null 2>&1; then
|
|
98
|
+
echo "pgn2 $ver published to rubygems.org"
|
|
99
|
+
exit 0
|
|
100
|
+
fi
|
|
101
|
+
sleep 5
|
|
102
|
+
done
|
|
103
|
+
echo "gem push succeeded but fetch verify timed out; check https://rubygems.org/gems/pgn2"
|
data/.gitignore
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,60 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.2.0 (2026-08-13)
|
|
4
|
+
|
|
5
|
+
### Summary
|
|
6
|
+
|
|
7
|
+
Parse allocation quick win: collapse `PGN::Lexer#scan_one`'s per-token
|
|
8
|
+
3-element tuple. No public API changes; serialized PGN/FEN output stays
|
|
9
|
+
byte-identical. See `bench/IMPROVEMENTS.md` for the per-step deltas.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- **Lexer hot path**: `PGN::Lexer#scan_one` now returns the matched string
|
|
13
|
+
directly (stashing its type/discarded flag in ivars) instead of a
|
|
14
|
+
3-element `[type, m, discarded]` tuple, so the parser path (`next_token_pair`)
|
|
15
|
+
allocates only the single `[type, value]` array Racc requires per token.
|
|
16
|
+
Selected by per-line allocation profiling (`scan_one`'s tuple was the #1
|
|
17
|
+
parse allocation site).
|
|
18
|
+
- **Deferred**: the coordinate-only-board + piece-location-index work was
|
|
19
|
+
profiled and deferred to a single coherent board-representation rewrite
|
|
20
|
+
(see TODO) — coordinate-board alone measured only ~1.25× replay at medium
|
|
21
|
+
risk and would likely be superseded by the piece-index rewrite.
|
|
22
|
+
- Performance vs 1.1.0: parse-only allocations −42% objects (603537 → 347037 /
|
|
23
|
+
500 games); parse+replay −18% objects (1427586 → 1170586). Replay path
|
|
24
|
+
unchanged. All 182 specs pass; racc-sync OK.
|
|
25
|
+
|
|
26
|
+
## 1.1.0 (2026-08-13)
|
|
27
|
+
|
|
28
|
+
### Summary
|
|
29
|
+
|
|
30
|
+
Performance quick wins ("Approach A"): safe, behavior-compatible
|
|
31
|
+
micro-optimizations on top of the 1.0 parser. No public API changes; serialized
|
|
32
|
+
PGN/FEN output stays byte-identical. See
|
|
33
|
+
`docs/superpowers/specs/2026-08-13-pgn-performance-quick-wins-design.md` and
|
|
34
|
+
`bench/IMPROVEMENTS.md` for the design and per-step deltas.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
- **Lexer hot path**: `PGN::Lexer#next_token_pair` returns `[type, value]`
|
|
38
|
+
without allocating a `Token` Struct (or its `keyword_init` Hash), via a shared
|
|
39
|
+
scanning routine that preserves `game_starts` for verbatim `pgn` slicing.
|
|
40
|
+
`PgnParser#next_token` uses it.
|
|
41
|
+
- **`PGN::Game#moves=`**: reuses an existing `MoveText` when its comment is
|
|
42
|
+
already clean (halving `MoveText` allocations on the parse path); still
|
|
43
|
+
re-wraps to preserve the legacy double-`clean_text` for multi-line/nested
|
|
44
|
+
comments.
|
|
45
|
+
- **`PGN::Move#piece=`**: non-allocating castling guard (`start_with?('O')`
|
|
46
|
+
instead of `match('O-O')`), removing a `MatchData` from every `Move.new`.
|
|
47
|
+
- **`PGN::Position`**: `next_player` uses a ternary instead of
|
|
48
|
+
`(PLAYERS - [player])`; `Position#move` skips `castling - restrictions` when
|
|
49
|
+
there are no restrictions.
|
|
50
|
+
- **`PGN::MoveCalculator`**: memoized `destination_coords`, frozen
|
|
51
|
+
`ROOK_RESTRICTIONS` constant, empty short-circuit in `castling_restrictions`.
|
|
52
|
+
- **`PGN::Move#pawn?`**: non-allocating (`==` instead of `%w[P p].include?`).
|
|
53
|
+
- Performance vs 1.0: replay allocations −21% objects / −27% bytes; parse-only
|
|
54
|
+
allocations −3.6% objects / −28% bytes; parse + replay −15% objects / −28%
|
|
55
|
+
bytes. The full hand-rolled SAN parser was deferred (marginal payoff, high
|
|
56
|
+
risk) per the design's "measure first" guidance.
|
|
57
|
+
|
|
3
58
|
## 1.0.0 (2026-08-13)
|
|
4
59
|
|
|
5
60
|
### Summary
|
data/README.md
CHANGED
|
@@ -156,32 +156,37 @@ snapshots captured before any hot-path work — at that point pgn2's parser
|
|
|
156
156
|
were byte-for-byte the original gem's code. The "pgn2" figures are the
|
|
157
157
|
current committed baselines (stdlib `Racc` + `StringScanner` parser,
|
|
158
158
|
column-level copy-on-write `Board`, getbyte-arithmetic `at`). All numbers
|
|
159
|
-
are from the same machine (Ruby 4.0.5, x86_64-linux)
|
|
160
|
-
deterministic
|
|
161
|
-
|
|
159
|
+
are from the same machine (Ruby 4.0.5, x86_64-linux). **Allocation counts
|
|
160
|
+
are deterministic** and the headline signal. **Throughput is reported as the
|
|
161
|
+
median of N back-to-back wall-clock runs** (whittle-era commit `1360bfc` vs
|
|
162
|
+
current `main`): the `benchmark-ips` harness has ±30–60% per-run variance, so
|
|
163
|
+
its single-run `ms/i` is not reliable across versions — e.g. the Racc baseline
|
|
164
|
+
once recorded 371 ms/i (±37%) for parse+replay but the same commit measures
|
|
165
|
+
~1017 ms reproduced today. `bench/baseline_*.txt` remains the harness of
|
|
166
|
+
record for allocations.
|
|
162
167
|
|
|
163
168
|
Move pipeline — immortal game, 45 plies (`bench/profile_moves.rb`):
|
|
164
169
|
|
|
165
170
|
| Metric | original `pgn` | pgn2 | Δ |
|
|
166
171
|
|---|---:|---:|---:|
|
|
167
|
-
| Replay allocations (objects) | 5124 |
|
|
168
|
-
| Replay allocations (bytes) | 262608 |
|
|
169
|
-
| `Board#dup` x45 (objects) | 451 |
|
|
170
|
-
| `Board#dup` x45 (bytes) | 43096 |
|
|
172
|
+
| Replay allocations (objects) | 5124 | 1710 | -3414 (-66.7%) |
|
|
173
|
+
| Replay allocations (bytes) | 262608 | 103760 | -158848 (-60.5%) |
|
|
174
|
+
| `Board#dup` x45 (objects) | 451 | 136 | -315 (-69.8%) |
|
|
175
|
+
| `Board#dup` x45 (bytes) | 43096 | 10336 | -32760 (-76.0%) |
|
|
171
176
|
| `Board#at(str)` x1000 (objects) | 6000 | 0 | -6000 (-100%) |
|
|
172
177
|
| `Board#at(str)` x1000 (bytes) | 240000 | 0 | -240000 (-100%) |
|
|
173
|
-
| Replay throughput |
|
|
178
|
+
| Replay throughput | 841 µs/i | 766 µs/i | ~1.10x faster |
|
|
174
179
|
|
|
175
180
|
Parser — 500 immortal games (`bench/profile_parse.rb`):
|
|
176
181
|
|
|
177
182
|
| Metric | original `pgn` | pgn2 | Δ |
|
|
178
183
|
|---|---:|---:|---:|
|
|
179
|
-
| Parse-only allocations (objects) | 1248065 |
|
|
180
|
-
| Parse-only allocations (bytes) | 120370470 |
|
|
181
|
-
| Parse + replay allocations (objects) | 3778073 |
|
|
182
|
-
| Parse + replay allocations (bytes) | 249570048 |
|
|
183
|
-
| Parse-only throughput |
|
|
184
|
-
| Parse + replay throughput |
|
|
184
|
+
| Parse-only allocations (objects) | 1248065 | 347037 | -901028 (-72.2%) |
|
|
185
|
+
| Parse-only allocations (bytes) | 120370470 | 17977414 | -102393056 (-85.1%) |
|
|
186
|
+
| Parse + replay allocations (objects) | 3778073 | 1170586 | -2607487 (-69.0%) |
|
|
187
|
+
| Parse + replay allocations (bytes) | 249570048 | 67804136 | -181765912 (-72.8%) |
|
|
188
|
+
| Parse-only throughput | 1461 ms/i | 305 ms/i | ~4.8x faster |
|
|
189
|
+
| Parse + replay throughput | 1938 ms/i | 816 ms/i | ~2.4x faster |
|
|
185
190
|
|
|
186
191
|
What changed to get there:
|
|
187
192
|
|
|
@@ -193,9 +198,27 @@ What changed to get there:
|
|
|
193
198
|
6. Parser — `whittle` (≈80% of parse allocations) replaced by stdlib `Racc` +
|
|
194
199
|
`StringScanner`; `PGN::Game#pgn` sliced from per-game byte offsets (no O(n²)
|
|
195
200
|
`@@pgn +=` accumulation).
|
|
201
|
+
7. `PGN::Lexer#next_token_pair` — parser hot path returns `[type, value]`
|
|
202
|
+
without allocating a `Token` Struct (or its `keyword_init` Hash), via a
|
|
203
|
+
shared scanning routine that preserves `game_starts` for verbatim `pgn` slicing.
|
|
204
|
+
8. `PGN::Game#moves=` — reuse existing `MoveText` when its comment is already
|
|
205
|
+
clean; halve `MoveText` allocations on the parse path (still re-wraps to
|
|
206
|
+
preserve the legacy double-`clean_text` for multi-line/nested comments).
|
|
207
|
+
9. `PGN::Move#piece=` — non-allocating castling guard (`start_with?('O')`
|
|
208
|
+
instead of `match('O-O')`), removing a `MatchData` from every `Move.new`.
|
|
209
|
+
10. `PGN::Position#next_player` — ternary instead of `(PLAYERS - [player])`;
|
|
210
|
+
`Position#move` skips `castling - restrictions` when there are none.
|
|
211
|
+
11. `PGN::MoveCalculator` — memoized `destination_coords`, frozen
|
|
212
|
+
`ROOK_RESTRICTIONS`, empty short-circuit in `castling_restrictions`;
|
|
213
|
+
`Move#pawn?` non-allocating.
|
|
214
|
+
12. `PGN::Lexer#scan_one` — returns the matched string directly (stashing
|
|
215
|
+
type/discarded in ivars) instead of a 3-element `[type, m, discarded]`
|
|
216
|
+
tuple, so the parser hot path now allocates only the single `[type, value]`
|
|
217
|
+
array Racc requires per token. Cuts parse allocations ~42% (603537 → 347037
|
|
218
|
+
objects for 500 games).
|
|
196
219
|
|
|
197
220
|
Public output (FEN, PGN) is byte-identical to the original gem; the full
|
|
198
|
-
suite (
|
|
221
|
+
suite (182 examples) stays green. See `bench/IMPROVEMENTS.md` for the per-step
|
|
199
222
|
before/after deltas that produced these tables.
|
|
200
223
|
|
|
201
224
|
## Installation
|
data/TODO.md
CHANGED
|
@@ -10,3 +10,41 @@
|
|
|
10
10
|
|
|
11
11
|
- Support converting a game to pgn format
|
|
12
12
|
- Speed up parsing
|
|
13
|
+
- ✓ (done in 1.2.0) Removed `PGN::Lexer`'s per-token `Token` Struct
|
|
14
|
+
allocation on the parser hot path (`next_token_pair`), and collapsed
|
|
15
|
+
`scan_one`'s `[type, m, discarded]` tuple to a single returned string
|
|
16
|
+
(type/discarded stashed in ivars). The full `Token` is kept only for the
|
|
17
|
+
`#tokens` spec helper. Parse allocations −42% (603537 → 347037 / 500 games).
|
|
18
|
+
- Speed up replay via a board-representation rewrite (deferred "Approach B"):
|
|
19
|
+
per-line profiling shows the remaining replay allocations are architectural
|
|
20
|
+
— `MoveCalculator#first_piece` scan-return arrays (~5/ply, the #1 site) and
|
|
21
|
+
`Board#position_for` string joins (~3/ply). Do these as ONE coherent
|
|
22
|
+
rewrite (not separately, to avoid throwing away work):
|
|
23
|
+
(a) a piece-location index (piece → squares) so king/disambiguation/origin
|
|
24
|
+
lookups are O(1) instead of scanning 64 squares — kills `first_piece` scan
|
|
25
|
+
arrays AND the dominant replay compute (`valid_square?`/`at` ≈ 15/ply calls);
|
|
26
|
+
(b) a coordinate-only internal board (int square keys, no `"e4"` strings on
|
|
27
|
+
the hot path) — kills `position_for` strings + `changes` string keys.
|
|
28
|
+
Caveat: `change!`/`update`/`position_for`/`coordinates_for`/`squares` are
|
|
29
|
+
spec-tested public API, so the new representation must be additive (string
|
|
30
|
+
API kept). Realistic ceiling ~2× replay allocation + ~1.5–2× throughput;
|
|
31
|
+
medium-high risk (Board/MoveCalculator/Position/FEN). Only worth it given a
|
|
32
|
+
real hot-loop need (replay is already ~0.8 ms/ply).
|
|
33
|
+
- Replace the right-recursive `tag_section`/`variation_list` rules in
|
|
34
|
+
`pgn_parser.y` with ordinary left-recursion plus one explicit `.reverse`
|
|
35
|
+
at the point each list is consumed, so the legacy whittle-order
|
|
36
|
+
compatibility quirk is a single greppable line instead of implicit in
|
|
37
|
+
recursion direction.
|
|
38
|
+
- Make `MoveText#clean_text` idempotent (or run it exactly once, at
|
|
39
|
+
construction) so `Game#moves=`/`#standardize_castling` doesn't need to
|
|
40
|
+
sniff a comment for leftover `{`/`}` to decide whether a MoveText is safe
|
|
41
|
+
to reuse as-is. The brace check is a bandaid for `clean_text` not fully
|
|
42
|
+
normalizing multi-line/nested comments in one pass; fixing that at the
|
|
43
|
+
source would let `moves=` reuse unconditionally.
|
|
44
|
+
- `MoveCalculator#destination_coords` memoizes into `@dest_coords` based on
|
|
45
|
+
`board`/`move`/`origin` never changing after `#initialize` — true today,
|
|
46
|
+
but only by convention, since `board`, `move`, `origin` are all public
|
|
47
|
+
`attr_accessor`s with no cache invalidation tied to their setters. If a
|
|
48
|
+
future caller ever mutates and reuses a `MoveCalculator` instance, this
|
|
49
|
+
memo goes stale silently. Either drop the public setters or invalidate
|
|
50
|
+
`@dest_coords` when they're used.
|
data/bench/IMPROVEMENTS.md
CHANGED
|
@@ -73,3 +73,95 @@ inputs during the migration; 32 explicit parser specs now pin the behavior
|
|
|
73
73
|
permanently (`spec/parser_explicit_spec.rb`).
|
|
74
74
|
|
|
75
75
|
Full suite: 187 examples, 0 failures.
|
|
76
|
+
|
|
77
|
+
## Quick wins (Approach A) — 2026-08-13
|
|
78
|
+
|
|
79
|
+
Safe, behavior-compatible micro-optimizations on top of the Racc parser.
|
|
80
|
+
Spec: `docs/superpowers/specs/2026-08-13-pgn-performance-quick-wins-design.md`.
|
|
81
|
+
"BEFORE" = `bench/baseline_*.pre-quickwins.txt` (working tree immediately before
|
|
82
|
+
this change). "AFTER" = `bench/baseline_*.txt`.
|
|
83
|
+
|
|
84
|
+
### bench/profile_moves.rb (immortal game, 45 plies)
|
|
85
|
+
|
|
86
|
+
| Metric | BEFORE | AFTER | Δ |
|
|
87
|
+
|---|---|---|---|
|
|
88
|
+
| Replay allocations (objects) | 2177 | 1710 | -467 (-21.4%) |
|
|
89
|
+
| Replay allocations (bytes) | 141616 | 103760 | -37856 (-26.7%) |
|
|
90
|
+
| Replay throughput (µs/i) | 931.70 | 848.64 | -83.06 (-8.9%) |
|
|
91
|
+
|
|
92
|
+
### bench/profile_parse.rb (500 immortal games)
|
|
93
|
+
|
|
94
|
+
| Metric | BEFORE | AFTER | Δ |
|
|
95
|
+
|---|---|---|---|
|
|
96
|
+
| Parse-only allocations (objects) | 626037 | 603537 | -22500 (-3.6%) |
|
|
97
|
+
| Parse-only allocations (bytes) | 39417414 | 28257414 | -11160000 (-28.3%) |
|
|
98
|
+
| Parse-only throughput (ms/i) | 318.39 | 274.08 | -44.31 (-13.9%) |
|
|
99
|
+
| Parse + replay allocations (objects) | 1683087 | 1427586 | -255501 (-15.2%) |
|
|
100
|
+
| Parse + replay allocations (bytes) | 108184152 | 78104136 | -30080016 (-27.8%) |
|
|
101
|
+
| Parse + replay throughput (ms/i) | 795.32 | 715.05 | -80.27 (-10.1%) |
|
|
102
|
+
|
|
103
|
+
### Changes applied
|
|
104
|
+
|
|
105
|
+
1. `PGN::Lexer` — added `next_token_pair` (returns `[type, value]`, no `Token`
|
|
106
|
+
Struct) built on a shared private `scan_next` routine that preserves the
|
|
107
|
+
`note_token`/`advance_line`/`game_starts` side effects. `next_token`/`tokens`
|
|
108
|
+
unchanged. `PgnParser#next_token` (in `.y` and generated `.rb`) now uses
|
|
109
|
+
`next_token_pair`. Eliminates the per-token `Token` Struct + its
|
|
110
|
+
`keyword_init` Hash (the larger win in bytes).
|
|
111
|
+
2. `PGN::Game#moves=` — reuses an existing `MoveText` directly when its comment
|
|
112
|
+
is already fully cleaned (nil or brace-free); still re-wraps (preserving the
|
|
113
|
+
legacy double-`clean_text` for multi-line/nested comments) when the comment
|
|
114
|
+
carries braces. Halves `MoveText` allocations on the parse path for
|
|
115
|
+
comment-free corpora.
|
|
116
|
+
3. `PGN::Move#piece=` — replaced the per-`Move.new` `san.match('O-O')` guard
|
|
117
|
+
(allocated a `MatchData` on every move, castling or not) with a
|
|
118
|
+
non-allocating `san.start_with?('O')`. The full hand-rolled SAN parser was
|
|
119
|
+
**deferred** per the spec's "measure first; defer if marginal" guidance:
|
|
120
|
+
it would save only ~1 `MatchData`/ply (~2% of replay, ~1.3% of parse+replay)
|
|
121
|
+
at high risk to SAN edge cases.
|
|
122
|
+
4. `PGN::Position#next_player` — `(PLAYERS - [player]).first` →
|
|
123
|
+
`player == :white ? :black : :white` (removes 2 array allocations/ply).
|
|
124
|
+
`Position#move` — skips `castling - restrictions` when `restrictions` is
|
|
125
|
+
empty (returns the shared `castling` array; safe because castling arrays
|
|
126
|
+
are replaced, never mutated).
|
|
127
|
+
`PGN::MoveCalculator` — memoizes `destination_coords` (was recomputed 2–3
|
|
128
|
+
times/move, each allocating a 2-element array); frozen `ROOK_RESTRICTIONS`
|
|
129
|
+
constant replaces per-call hash literals in `castling_restrictions`; empty
|
|
130
|
+
short-circuit avoids `compact.uniq` on the common empty path.
|
|
131
|
+
`PGN::Move#pawn?` — `%w[P p].include?` → `piece == 'P' || piece == 'p'`.
|
|
132
|
+
`valid_square?` was left unchanged: its `(0..7)` are frozen range literals
|
|
133
|
+
cached by the VM, so inlining would only save method dispatch, not
|
|
134
|
+
allocations (the original rationale was unfounded).
|
|
135
|
+
|
|
136
|
+
### Behavior preservation
|
|
137
|
+
|
|
138
|
+
All 182 specs pass unmodified (`bundle exec rspec`). The lexer refactor keeps
|
|
139
|
+
`next_token`/`tokens` and the `game_starts`-driven verbatim `Game#pgn` slicing
|
|
140
|
+
byte-identical (covered by `spec/lexer_spec.rb`, `spec/parser_explicit_spec.rb`,
|
|
141
|
+
and the fixture round-trip in `spec/game_spec.rb`). Racc parser is in sync with
|
|
142
|
+
`pgn_parser.y` (CI racc-sync check passes). No public API or serialized-output
|
|
143
|
+
changes.
|
|
144
|
+
|
|
145
|
+
## Token-array collapse (2026-08-13, v1.2.0)
|
|
146
|
+
|
|
147
|
+
Collapse `PGN::Lexer#scan_one`'s per-token 3-element `[type, m, discarded]` tuple.
|
|
148
|
+
`scan_one` now returns the matched string directly and stashes its type /
|
|
149
|
+
discarded flag in `@scan_type` / `@scan_discarded` ivars, so the parser hot path
|
|
150
|
+
(`next_token_pair`) allocates only the single `[type, value]` array Racc requires.
|
|
151
|
+
Selected by per-line allocation profiling: `scan_one`'s tuple was the #1 parse
|
|
152
|
+
allocation site. The coordinate-only-board + piece-location-index work was
|
|
153
|
+
profiled and **deferred** — the board rewrite is a larger design (see TODO), and
|
|
154
|
+
coordinate-board alone measured only ~1.25× replay at medium risk and would
|
|
155
|
+
likely be superseded by the piece-index rewrite.
|
|
156
|
+
|
|
157
|
+
### bench/profile_parse.rb (500 immortal games)
|
|
158
|
+
|
|
159
|
+
| Metric | BEFORE | AFTER | Δ |
|
|
160
|
+
|---|---|---|---|
|
|
161
|
+
| Parse-only allocations (objects) | 603537 | 347037 | -256500 (-42.5%) |
|
|
162
|
+
| Parse-only allocations (bytes) | 28257414 | 17977414 | -10280000 (-36.4%) |
|
|
163
|
+
| Parse + replay allocations (objects) | 1427586 | 1170586 | -257000 (-18.0%) |
|
|
164
|
+
| Parse + replay allocations (bytes) | 78104136 | 67804136 | -10300000 (-13.2%) |
|
|
165
|
+
|
|
166
|
+
Replay (`profile_moves.rb`) is unchanged (1709 objects / 103720 bytes) — the
|
|
167
|
+
lexer edit does not touch the replay path. All 182 specs pass; racc-sync OK.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Workload: immortal game, 45 plies
|
|
2
|
+
|
|
3
|
+
=== 1. Replay allocations (45 plies, no parse) ===
|
|
4
|
+
total_allocated objects: 2177
|
|
5
|
+
total_allocated bytes: 141616
|
|
6
|
+
|
|
7
|
+
=== 2. Board#dup x45 (target of flat-board COW) ===
|
|
8
|
+
total_allocated objects: 136
|
|
9
|
+
total_allocated bytes: 10336
|
|
10
|
+
|
|
11
|
+
=== 3. Board#at(str) x1000 (target of coord-arithmetic at) ===
|
|
12
|
+
total_allocated objects: 0
|
|
13
|
+
total_allocated bytes: 0
|
|
14
|
+
|
|
15
|
+
=== 4. Replay throughput (ips, excluding parse) ===
|
|
16
|
+
ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
|
|
17
|
+
Warming up --------------------------------------
|
|
18
|
+
replay immortal 83.000 i/100ms
|
|
19
|
+
Calculating -------------------------------------
|
|
20
|
+
replay immortal 1.073k (± 3.2%) i/s (931.70 μs/i) - 5.395k in 5.026523s
|
|
21
|
+
|
|
22
|
+
Done. Snapshot of the working tree immediately before the quick-wins (Approach A) changes.
|
|
23
|
+
Compare bench/baseline_moves.txt (after) against this file.
|
data/bench/baseline_moves.txt
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Workload: immortal game, 45 plies
|
|
2
2
|
|
|
3
3
|
=== 1. Replay allocations (45 plies, no parse) ===
|
|
4
|
-
total_allocated objects:
|
|
5
|
-
total_allocated bytes:
|
|
4
|
+
total_allocated objects: 1710
|
|
5
|
+
total_allocated bytes: 103760
|
|
6
6
|
|
|
7
7
|
=== 2. Board#dup x45 (target of flat-board COW) ===
|
|
8
|
-
total_allocated objects:
|
|
9
|
-
total_allocated bytes:
|
|
8
|
+
total_allocated objects: 136
|
|
9
|
+
total_allocated bytes: 10336
|
|
10
10
|
|
|
11
11
|
=== 3. Board#at(str) x1000 (target of coord-arithmetic at) ===
|
|
12
12
|
total_allocated objects: 0
|
|
@@ -15,8 +15,8 @@ total_allocated bytes: 0
|
|
|
15
15
|
=== 4. Replay throughput (ips, excluding parse) ===
|
|
16
16
|
ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
|
|
17
17
|
Warming up --------------------------------------
|
|
18
|
-
replay immortal
|
|
18
|
+
replay immortal 112.000 i/100ms
|
|
19
19
|
Calculating -------------------------------------
|
|
20
|
-
replay immortal 1.
|
|
20
|
+
replay immortal 1.178k (± 4.6%) i/s (848.64 μs/i) - 5.936k in 5.037516s
|
|
21
21
|
|
|
22
22
|
Done. Compare this file against bench/baseline_moves.txt after optimizations.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Corpus: 500 copies of the immortal game
|
|
2
|
+
|
|
3
|
+
=== 1. Parse-only allocations (500 games) ===
|
|
4
|
+
total_allocated objects: 626037
|
|
5
|
+
total_allocated bytes: 39417414
|
|
6
|
+
|
|
7
|
+
=== 2. Parse + replay allocations (500 games) ===
|
|
8
|
+
total_allocated objects: 1683087
|
|
9
|
+
total_allocated bytes: 108184152
|
|
10
|
+
|
|
11
|
+
=== 3. Parse-only throughput (ips) ===
|
|
12
|
+
ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
|
|
13
|
+
Warming up --------------------------------------
|
|
14
|
+
parse 500 games 1.000 i/100ms
|
|
15
|
+
Calculating -------------------------------------
|
|
16
|
+
parse 500 games 3.141 (± 0.0%) i/s (318.39 ms/i) - 16.000 in 5.094185s
|
|
17
|
+
|
|
18
|
+
=== 4. Parse + replay throughput (ips) ===
|
|
19
|
+
ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
|
|
20
|
+
Warming up --------------------------------------
|
|
21
|
+
parse+replay 500 games 1.000 i/100ms
|
|
22
|
+
Calculating -------------------------------------
|
|
23
|
+
parse+replay 500 games 1.257 (± 0.0%) i/s (795.32 ms/i) - 7.000 in 5.567259s
|
|
24
|
+
|
|
25
|
+
Done. Snapshot of the working tree immediately before the quick-wins (Approach A) changes.
|
|
26
|
+
Compare bench/baseline_parse.txt (after) against this file.
|
data/bench/baseline_parse.txt
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
Corpus: 500 copies of the immortal game
|
|
2
2
|
|
|
3
3
|
=== 1. Parse-only allocations (500 games) ===
|
|
4
|
-
total_allocated objects:
|
|
5
|
-
total_allocated bytes:
|
|
4
|
+
total_allocated objects: 347037
|
|
5
|
+
total_allocated bytes: 17977414
|
|
6
6
|
|
|
7
7
|
=== 2. Parse + replay allocations (500 games) ===
|
|
8
|
-
total_allocated objects:
|
|
9
|
-
total_allocated bytes:
|
|
8
|
+
total_allocated objects: 1170586
|
|
9
|
+
total_allocated bytes: 67804136
|
|
10
10
|
|
|
11
11
|
=== 3. Parse-only throughput (ips) ===
|
|
12
12
|
ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
|
|
13
13
|
Warming up --------------------------------------
|
|
14
14
|
parse 500 games 1.000 i/100ms
|
|
15
15
|
Calculating -------------------------------------
|
|
16
|
-
parse 500 games
|
|
16
|
+
parse 500 games 4.086 (± 0.0%) i/s (244.74 ms/i) - 21.000 in 5.139596s
|
|
17
17
|
|
|
18
18
|
=== 4. Parse + replay throughput (ips) ===
|
|
19
19
|
ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
|
|
20
20
|
Warming up --------------------------------------
|
|
21
21
|
parse+replay 500 games 1.000 i/100ms
|
|
22
22
|
Calculating -------------------------------------
|
|
23
|
-
parse+replay 500 games
|
|
23
|
+
parse+replay 500 games 1.472 (± 0.0%) i/s (679.56 ms/i) - 8.000 in 5.436470s
|
|
24
24
|
|
|
25
25
|
Done. Compare this file against bench/baseline_parse.txt after optimizations.
|