pgn2 0.4.0 → 1.1.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/ci.yml +50 -0
- data/.github/workflows/publish.yml +75 -0
- data/.github/workflows/release.yml +103 -0
- data/.gitignore +2 -1
- data/.rubocop.yml +38 -0
- data/CHANGELOG.md +84 -0
- data/README.md +122 -4
- data/Rakefile +20 -0
- data/TODO.md +9 -0
- data/bench/.keep +0 -0
- data/bench/IMPROVEMENTS.md +143 -0
- data/bench/baseline_moves.pre-optimization.txt +22 -0
- data/bench/baseline_moves.pre-quickwins.txt +23 -0
- data/bench/baseline_moves.txt +22 -0
- data/bench/baseline_parse.pre-optimization.txt +25 -0
- data/bench/baseline_parse.pre-quickwins.txt +26 -0
- data/bench/baseline_parse.racc.txt +25 -0
- data/bench/baseline_parse.txt +25 -0
- data/bench/profile_moves.rb +53 -0
- data/bench/profile_parse.rb +44 -0
- data/docs/superpowers/plans/2026-08-12-efficiency-optimizations.md +573 -0
- data/docs/superpowers/plans/2026-08-12-efficiency-tests-and-profiling.md +1091 -0
- data/docs/superpowers/plans/2026-08-12-to-pgn-serialization.md +162 -0
- data/docs/superpowers/plans/2026-08-13-whittle-to-racc-migration.md +130 -0
- data/docs/superpowers/specs/2026-08-12-to-pgn-serialization-design.md +217 -0
- data/docs/superpowers/specs/2026-08-13-pgn-performance-quick-wins-design.md +227 -0
- data/lib/pgn/board.rb +33 -15
- data/lib/pgn/fen.rb +16 -8
- data/lib/pgn/game.rb +23 -3
- data/lib/pgn/lexer.rb +223 -0
- data/lib/pgn/move.rb +12 -5
- data/lib/pgn/move_calculator.rb +27 -21
- data/lib/pgn/parser.rb +13 -203
- data/lib/pgn/pgn_parser.rb +393 -0
- data/lib/pgn/pgn_parser.y +140 -0
- data/lib/pgn/position.rb +3 -2
- data/lib/pgn/serializer.rb +141 -0
- data/lib/pgn/version.rb +1 -1
- data/lib/pgn.rb +3 -0
- data/pgn2.gemspec +12 -2
- data/spec/board_spec.rb +111 -0
- data/spec/fen_spec.rb +25 -0
- data/spec/game_spec.rb +74 -0
- data/spec/lexer_spec.rb +153 -0
- data/spec/move_calculator_spec.rb +226 -0
- data/spec/move_spec.rb +136 -0
- data/spec/parser_explicit_spec.rb +210 -0
- data/spec/parser_spec.rb +6 -23
- data/spec/pgn_files/doublequotes.pgn +21 -0
- data/spec/pgn_files/specialcharacters.pgn +79 -0
- data/spec/position_spec.rb +73 -0
- data/spec/serializer_spec.rb +89 -0
- data/spec/spec_helper.rb +0 -1
- metadata +103 -15
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Efficiency improvements — before/after
|
|
2
|
+
|
|
3
|
+
Captured by `rake bench` on the same machine. "BEFORE" = `bench/*.pre-optimization.txt`
|
|
4
|
+
(pre-optimization snapshot). "AFTER" = `bench/baseline_*.txt`.
|
|
5
|
+
|
|
6
|
+
## bench/profile_moves.rb (immortal game, 45 plies)
|
|
7
|
+
|
|
8
|
+
| Metric | BEFORE | AFTER | Δ |
|
|
9
|
+
|---|---|---|---|
|
|
10
|
+
| Replay allocations (objects) | 5124 | 2565 | -2559 |
|
|
11
|
+
| Replay allocations (bytes) | 262608 | 155296 | -107312 |
|
|
12
|
+
| Board#dup x45 (objects) | 451 | 91 | -360 |
|
|
13
|
+
| Board#dup x45 (bytes) | 43096 | 6736 | -36360 |
|
|
14
|
+
| Board#at(str) x1000 (objects) | 6000 | 0 | -6000 |
|
|
15
|
+
| Board#at(str) x1000 (bytes) | 240000 | 0 | -240000 |
|
|
16
|
+
|
|
17
|
+
## bench/profile_parse.rb (500 immortal games)
|
|
18
|
+
|
|
19
|
+
| Metric | BEFORE | AFTER | Δ |
|
|
20
|
+
|---|---|---|---|
|
|
21
|
+
| Parse-only allocations (objects) | 1248065 | 1248065 | 0 |
|
|
22
|
+
| Parse-only allocations (bytes) | 120370470 | 120370470 | 0 |
|
|
23
|
+
| Parse + replay allocations (objects) | 3778073 | 2498573 | -1279500 |
|
|
24
|
+
| Parse + replay allocations (bytes) | 249570048 | 195914048 | -53656000 |
|
|
25
|
+
|
|
26
|
+
## Changes applied
|
|
27
|
+
|
|
28
|
+
1. `Board#at(str)` / `coordinates_for` — getbyte arithmetic (zero-alloc string lookup).
|
|
29
|
+
2. `MoveCalculator#king_position` — early exit.
|
|
30
|
+
3. `Move#initialize` — explicit setters (no per-move `names` array).
|
|
31
|
+
4. `FEN#board_string` — single-pass serialization.
|
|
32
|
+
5. `Board` — column-level copy-on-write (`dup` shares columns, `update` clones one).
|
|
33
|
+
|
|
34
|
+
All existing characterization specs remain green; public output (FEN, PGN) is byte-identical.
|
|
35
|
+
|
|
36
|
+
## Parser migration: whittle -> Racc + StringScanner (2026-08-13)
|
|
37
|
+
|
|
38
|
+
The abandoned `whittle` gem (v0.0.8, 2011) was replaced by a stdlib `Racc` +
|
|
39
|
+
`StringScanner` parser (`lib/pgn/pgn_parser.y`, generated to
|
|
40
|
+
`lib/pgn/pgn_parser.rb`; lexer in `lib/pgn/lexer.rb`). whittle was responsible
|
|
41
|
+
for ~80% of parse allocations.
|
|
42
|
+
|
|
43
|
+
Corpus: 500 immortal games (`BENCH_N=500`). Baseline = `bench/baseline_parse.txt`
|
|
44
|
+
(whittle). New = `bench/baseline_parse.racc.txt`. Allocation counts are
|
|
45
|
+
deterministic; throughput is noisy over a 5 s window (use ms/i).
|
|
46
|
+
|
|
47
|
+
| Metric | whittle | racc | Δ |
|
|
48
|
+
|---|---|---|---|
|
|
49
|
+
| Parse-only allocations (objects) | 1248065 | 557035 | -691030 (-55.4%) |
|
|
50
|
+
| Parse-only allocations (bytes) | 120370470 | 36636902 | -83733568 (-69.6%) |
|
|
51
|
+
| Parse + replay allocations (objects) | 2498573 | 1614087 | -884486 (-35.4%) |
|
|
52
|
+
| Parse + replay allocations (bytes) | 195914048 | 105404152 | -90509896 (-46.2%) |
|
|
53
|
+
| Parse-only throughput (ms/i) | 741 | 212 | -529 (~3.5x faster) |
|
|
54
|
+
| Parse + replay throughput (ms/i) | 1114 | 371 | -743 (~3.0x faster) |
|
|
55
|
+
|
|
56
|
+
### What changed
|
|
57
|
+
- `lib/pgn/pgn_parser.y` / `pgn_parser.rb`: Racc grammar mirroring the whittle
|
|
58
|
+
rules, held as instance state (fixes the `@@pgn`/`@@game_comment` reentrancy
|
|
59
|
+
bug). `PGN::Game#pgn` is sliced from per-game byte offsets (no O(n^2)
|
|
60
|
+
`@@pgn +=` accumulation).
|
|
61
|
+
- `lib/pgn/lexer.rb`: StringScanner (C ext) lexer reusing whittle's exact
|
|
62
|
+
terminal regexes; records per-game content-start byte offsets.
|
|
63
|
+
- `lib/pgn/parser.rb`: thin facade delegating to `PGN::PgnParser`.
|
|
64
|
+
- whittle dependency dropped; `lib/pgn/whittle_parser.rb` deleted.
|
|
65
|
+
|
|
66
|
+
### Behavior preservation
|
|
67
|
+
The grammar deliberately replicates whittle's quirks so parsed-game
|
|
68
|
+
serialization stays byte-compatible: right-recursive `variation_list` (variation
|
|
69
|
+
order reverses) and right-recursive `tag_section` (reverse insertion order,
|
|
70
|
+
first-wins). `game.pgn` is verbatim raw text. A golden-equivalence spec (now
|
|
71
|
+
removed with whittle) confirmed identical output on all 14 fixtures + 11 inline
|
|
72
|
+
inputs during the migration; 32 explicit parser specs now pin the behavior
|
|
73
|
+
permanently (`spec/parser_explicit_spec.rb`).
|
|
74
|
+
|
|
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.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Workload: immortal game, 45 plies
|
|
2
|
+
|
|
3
|
+
=== 1. Replay allocations (45 plies, no parse) ===
|
|
4
|
+
total_allocated objects: 5124
|
|
5
|
+
total_allocated bytes: 262608
|
|
6
|
+
|
|
7
|
+
=== 2. Board#dup x45 (target of flat-board COW) ===
|
|
8
|
+
total_allocated objects: 451
|
|
9
|
+
total_allocated bytes: 43096
|
|
10
|
+
|
|
11
|
+
=== 3. Board#at(str) x1000 (target of coord-arithmetic at) ===
|
|
12
|
+
total_allocated objects: 6000
|
|
13
|
+
total_allocated bytes: 240000
|
|
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 75.000 i/100ms
|
|
19
|
+
Calculating -------------------------------------
|
|
20
|
+
replay immortal 1.132k (±46.9%) i/s (883.58 μs/i) - 5.700k in 5.036401s
|
|
21
|
+
|
|
22
|
+
Done. Compare this file against bench/baseline_moves.txt after optimizations.
|
|
@@ -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.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Workload: immortal game, 45 plies
|
|
2
|
+
|
|
3
|
+
=== 1. Replay allocations (45 plies, no parse) ===
|
|
4
|
+
total_allocated objects: 1710
|
|
5
|
+
total_allocated bytes: 103760
|
|
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 112.000 i/100ms
|
|
19
|
+
Calculating -------------------------------------
|
|
20
|
+
replay immortal 1.178k (± 4.6%) i/s (848.64 μs/i) - 5.936k in 5.037516s
|
|
21
|
+
|
|
22
|
+
Done. Compare this file against bench/baseline_moves.txt after optimizations.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Corpus: 500 copies of the immortal game
|
|
2
|
+
|
|
3
|
+
=== 1. Parse-only allocations (500 games) ===
|
|
4
|
+
total_allocated objects: 1248065
|
|
5
|
+
total_allocated bytes: 120370470
|
|
6
|
+
|
|
7
|
+
=== 2. Parse + replay allocations (500 games) ===
|
|
8
|
+
total_allocated objects: 3778073
|
|
9
|
+
total_allocated bytes: 249570048
|
|
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 1.513 (± 0.0%) i/s (661.01 ms/i) - 8.000 in 5.288062s
|
|
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 0.933 (± 0.0%) i/s (1.07 s/i) - 6.000 in 6.431545s
|
|
24
|
+
|
|
25
|
+
Done. Compare this file against bench/baseline_parse.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.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Corpus: 500 copies of the immortal game
|
|
2
|
+
|
|
3
|
+
=== 1. Parse-only allocations (500 games) ===
|
|
4
|
+
total_allocated objects: 557035
|
|
5
|
+
total_allocated bytes: 36636902
|
|
6
|
+
|
|
7
|
+
=== 2. Parse + replay allocations (500 games) ===
|
|
8
|
+
total_allocated objects: 1614087
|
|
9
|
+
total_allocated bytes: 105404152
|
|
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 4.711 (±63.7%) i/s (212.27 ms/i) - 24.000 in 5.094454s
|
|
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 2.698 (±37.1%) i/s (370.63 ms/i) - 14.000 in 5.188813s
|
|
24
|
+
|
|
25
|
+
Done. Compare this file against bench/baseline_parse.txt after optimizations.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Corpus: 500 copies of the immortal game
|
|
2
|
+
|
|
3
|
+
=== 1. Parse-only allocations (500 games) ===
|
|
4
|
+
total_allocated objects: 603537
|
|
5
|
+
total_allocated bytes: 28257414
|
|
6
|
+
|
|
7
|
+
=== 2. Parse + replay allocations (500 games) ===
|
|
8
|
+
total_allocated objects: 1427586
|
|
9
|
+
total_allocated bytes: 78104136
|
|
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.649 (±27.4%) i/s (274.08 ms/i) - 19.000 in 5.207546s
|
|
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.399 (± 0.0%) i/s (715.05 ms/i) - 7.000 in 5.005330s
|
|
24
|
+
|
|
25
|
+
Done. Compare this file against bench/baseline_parse.txt after optimizations.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# Measures per-move allocation and replay throughput for the move pipeline.
|
|
3
|
+
# Run with: bundle exec ruby bench/profile_moves.rb
|
|
4
|
+
# Captured baseline: bench/baseline_moves.txt (via rake bench:moves)
|
|
5
|
+
|
|
6
|
+
$LOAD_PATH.unshift(File.expand_path('lib', File.join(__dir__, '..')))
|
|
7
|
+
require 'pgn'
|
|
8
|
+
require 'memory_profiler'
|
|
9
|
+
require 'benchmark/ips'
|
|
10
|
+
|
|
11
|
+
EXAMPLES = File.join(__dir__, '..', 'examples')
|
|
12
|
+
IMMORTAL = File.read(File.join(EXAMPLES, 'immortal_game.pgn'))
|
|
13
|
+
GAME = PGN.parse(IMMORTAL).freeze
|
|
14
|
+
SAN = GAME.first.moves.map(&:notation).freeze
|
|
15
|
+
PLY = SAN.length
|
|
16
|
+
|
|
17
|
+
puts "Workload: immortal game, #{PLY} plies"
|
|
18
|
+
|
|
19
|
+
# --- 1. Replay allocations (move application only, no parse) -----------------
|
|
20
|
+
replay_report = MemoryProfiler.report do
|
|
21
|
+
pos = GAME.first.starting_position
|
|
22
|
+
SAN.each { |m| pos = pos.move(m) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
puts "\n=== 1. Replay allocations (#{PLY} plies, no parse) ==="
|
|
26
|
+
puts "total_allocated objects: #{replay_report.total_allocated}"
|
|
27
|
+
puts "total_allocated bytes: #{replay_report.total_allocated_memsize}"
|
|
28
|
+
|
|
29
|
+
# --- 2. Board#dup share (the flat-board optimization target) ------------------
|
|
30
|
+
dup_report = MemoryProfiler.report { PLY.times { GAME.first.starting_position.board.dup } }
|
|
31
|
+
|
|
32
|
+
puts "\n=== 2. Board#dup x#{PLY} (target of flat-board COW) ==="
|
|
33
|
+
puts "total_allocated objects: #{dup_report.total_allocated}"
|
|
34
|
+
puts "total_allocated bytes: #{dup_report.total_allocated_memsize}"
|
|
35
|
+
|
|
36
|
+
# --- 3. Board#at(str) share (the at(str) optimization target) ---------------
|
|
37
|
+
start_board = GAME.first.starting_position.board
|
|
38
|
+
at_report = MemoryProfiler.report { 1000.times { start_board.at('e4') } }
|
|
39
|
+
|
|
40
|
+
puts "\n=== 3. Board#at(str) x1000 (target of coord-arithmetic at) ==="
|
|
41
|
+
puts "total_allocated objects: #{at_report.total_allocated}"
|
|
42
|
+
puts "total_allocated bytes: #{at_report.total_allocated_memsize}"
|
|
43
|
+
|
|
44
|
+
# --- 4. Replay throughput (fresh game each iter to defeat memoization) ------
|
|
45
|
+
puts "\n=== 4. Replay throughput (ips, excluding parse) ==="
|
|
46
|
+
Benchmark.ips do |x|
|
|
47
|
+
x.config(time: 5, warmup: 1)
|
|
48
|
+
x.report('replay immortal') do
|
|
49
|
+
PGN::Game.new(SAN, GAME.first.tags, GAME.first.result).positions
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
puts "\nDone. Compare this file against bench/baseline_moves.txt after optimizations."
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# Measures parse and parse+replay throughput/allocations on a synthetic
|
|
3
|
+
# multi-game corpus. Run with: bundle exec ruby bench/profile_parse.rb
|
|
4
|
+
# Captured baseline: bench/baseline_parse.txt (via rake bench:parse)
|
|
5
|
+
|
|
6
|
+
$LOAD_PATH.unshift(File.expand_path('lib', File.join(__dir__, '..')))
|
|
7
|
+
require 'pgn'
|
|
8
|
+
require 'memory_profiler'
|
|
9
|
+
require 'benchmark/ips'
|
|
10
|
+
|
|
11
|
+
EXAMPLES = File.join(__dir__, '..', 'examples')
|
|
12
|
+
IMMORTAL = File.read(File.join(EXAMPLES, 'immortal_game.pgn')).strip
|
|
13
|
+
N = Integer(ENV.fetch('BENCH_N', '500'))
|
|
14
|
+
CORPUS = (IMMORTAL + "\n\n") * N
|
|
15
|
+
|
|
16
|
+
puts "Corpus: #{N} copies of the immortal game"
|
|
17
|
+
|
|
18
|
+
# --- 1. Parse-only allocations ------------------------------------------------
|
|
19
|
+
parse_report = MemoryProfiler.report { PGN.parse(CORPUS) }
|
|
20
|
+
puts "\n=== 1. Parse-only allocations (#{N} games) ==="
|
|
21
|
+
puts "total_allocated objects: #{parse_report.total_allocated}"
|
|
22
|
+
puts "total_allocated bytes: #{parse_report.total_allocated_memsize}"
|
|
23
|
+
|
|
24
|
+
# --- 2. Parse + replay allocations (real-world load) --------------------------
|
|
25
|
+
full_report = MemoryProfiler.report { PGN.parse(CORPUS).each(&:positions) }
|
|
26
|
+
puts "\n=== 2. Parse + replay allocations (#{N} games) ==="
|
|
27
|
+
puts "total_allocated objects: #{full_report.total_allocated}"
|
|
28
|
+
puts "total_allocated bytes: #{full_report.total_allocated_memsize}"
|
|
29
|
+
|
|
30
|
+
# --- 3. Parse-only throughput -------------------------------------------------
|
|
31
|
+
puts "\n=== 3. Parse-only throughput (ips) ==="
|
|
32
|
+
Benchmark.ips do |x|
|
|
33
|
+
x.config(time: 5, warmup: 1)
|
|
34
|
+
x.report("parse #{N} games") { PGN.parse(CORPUS) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# --- 4. Parse + replay throughput ---------------------------------------------
|
|
38
|
+
puts "\n=== 4. Parse + replay throughput (ips) ==="
|
|
39
|
+
Benchmark.ips do |x|
|
|
40
|
+
x.config(time: 5, warmup: 1)
|
|
41
|
+
x.report("parse+replay #{N} games") { PGN.parse(CORPUS).each(&:positions) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
puts "\nDone. Compare this file against bench/baseline_parse.txt after optimizations."
|