Skip to content

Parser Performance ​

Runtime builds, dependencies, replay duration, patch, entity volume, and enabled extractors all affect parse time. This page records measured results and their limits; historical optimization measurements are preserved below.

Final v0.8.0 profile ​

The 8 September final profile records fresh public/core timing and RSS measurements, CPU and allocation diagnostics on the short and long fixtures, and the selected native field-decode/apply boundary for the next prototype. Its reproducible harness and measurement records are in the repository. The studies below retain their original dates and scopes; their remaining-work notes should be read in that historical context.

Public record layout study ​

Study date: 7 September 2026. Source commit: f79d4d9cdbd4199991d7b917350a9dc88a8dfaba.

Keep CombatLogEntry and PlayerStateSnapshot as ordinary dataclasses. This resolves #164 with a compatibility decision and measurements, not a shipped layout optimization. The temporary experiment adds slots=True to just those two dataclass decorators. It is incompatible with existing behavior and is not included in the release. Its savings do not establish what a future compatible design could save.

Compatibility boundary ​

Both records are mutable and public. CombatLogEntry is exported by gem.combat, delivered to callbacks, and retained in ParsedMatch.combat_log. PlayerStateSnapshot is exported by gem.extractors and gem.extractors.players; PlayerExtractor.snapshots exposes those objects. Ordinary public parsing uses snapshots during assembly and returns derived arrays instead of retaining the snapshot records themselves. Values and nested mappings can still be shared with those derived arrays; a collected snapshot record does not imply that all of its former field values were freed.

Existing combat aggregator tests use vars() on actual CombatLogEntry objects to construct an older-shaped callback input. The teamfight lookup tests also compare dictionaries, but their _lookup_snap helper returns SimpleNamespace doubles; those assertions do not establish the public snapshot contract. The new tests exercise actual PlayerStateSnapshot instances directly. Production JSON serialization uses dataclass fields and getattr; DataFrame construction uses asdict. Those paths working with slots does not make dictionary consumers compatible. Batch workers pickle ParsedMatch, including its combat entries, back to the parent process.

Audit sources: record definitions, snapshot definitions, JSON serialization, DataFrame conversion, and batch worker return.

BehaviorOrdinary recordsTemporary slots=True experiment
Positional/keyword construction and ordinary subclassesPreservedPreserved in focused checks
Mutable fields, independent snapshot default mappingsPreservedPreserved
Equality, repr, asdict, replacePreservedPreserved for declared fields
JSON, DataFrame conversion, ordinary pickle round tripsPreservedValidated separately
vars() containing declared fields; live dictionary writesPreservedNo instance dictionary
Dynamic attributes and their pickle round tripsPreservedAttribute assignment fails
Weak references to direct record instancesPreservedUnsupported

The new compatibility tests exercise these behaviors without weakening the existing assertions. External consumers were not exhaustively audited; absence of another repository caller is not evidence that an API can be removed. Pickle checks cover same-layout, same-interpreter round trips with protocol 4 and this interpreter's highest protocol. They do not establish historical pickle migration across layouts or Python versions.

Adding a dictionary alongside slots is not a drop-in repair: declared slots still live outside that dictionary, so vars(record) would not expose the same fields or support the same dictionary writes. See Python's __slots__ rules. Manta's combat callback registration, Clarity's S1/S2 combat event dispatch, and OpenDota's Entry record establish dataflow and output context; none establish Python object-layout compatibility. A future change needs a separate compatibility proposal. Internal replacement records and retention options are outside this study; the subsequent final profile records the native-code boundary decision for #158.

Measurement method ​

B is the unchanged source; E is the incompatible experiment. The source trees came from the same commit and differ only in the two decorators. Every child asserts its imported Gem path. No production dependencies or project settings were changed, and the preceding five-runtime study was not repeated.

Each replay uses three sequential fresh-process pairs in order B/E, E/B, B/E: 12 timed public parses total. The harness preloads public parse's lazy imports, leaves normal garbage collection enabled, and collects just before timing. A common one-call wrapper checks ReplayParser.parse_error; fixture size/hash checks against the committed manifest and match identity/count checks supplement that guard. Fixture hashing uses a streaming 1 MiB buffer. Elapsed time and user CPU exclude imports, hashing and serialization. Peak RSS is captured before JSON conversion and includes earlier process allocations; it is not retained-result memory. No tests or instrumented parses ran alongside accepted timed runs. Preliminary harness runs were discarded before restarting the complete measurement sequence with streaming fixture hashing.

Twelve additional fresh-process parses measure storage separately, three per variant/replay. At assembly entry they count unique combat entries and both dense and minute snapshots. Inspection retains only scalar summaries, not the records. After parsing, a full collection runs while the result stays alive; current RSS is captured before the final graph inspection. This RSS is instrumented: allocator effects from earlier assembly inspection remain possible. Only the uninstrumented timing runs supply production peak-RSS comparisons.

The graph walker counts each identity once across dataclasses, their instance dictionaries, containers and scalar values. It excludes class/module graphs, treats enum members as atomic shared values, rejects unhandled types, and removes synthetic collector-list storage from record-union measurements. Reachable bytes include shared values once; they are not exclusively owned bytes or an estimate of memory returned to the OS on deletion. Record shells, instance dictionaries, and snapshot mapping shells are reported separately. The post-collection snapshot count checks transient lifetime independently of result graph traversal.

Environment and controls ​

The existing project environment used CPython 3.10.4, built by Clang 14.0.3 on 12 September 2023, with pymalloc enabled. The executable was /Users/hanyuwu/Study/gem/.venv/bin/python, resolving to /Users/hanyuwu/.pyenv/versions/3.10.4/bin/python3.10. This is not the 3.10.21 build from the preceding runtime study; compare only within this experiment.

Host: Apple M2, 8 CPUs, 16 GiB RAM, macOS 26.6.2 arm64. The existing environment included NumPy 2.2.6, pandas 2.3.3, protobuf 7.34.0, python-snappy 0.7.3 and psutil 7.2.2. uv was 0.9.8. Full installed versions and interpreter build configuration are preserved in the reproduction notes. Validation uses uv run --no-sync to avoid changing that environment.

PYTHONHASHSEED=0 and PYTHON_JIT=0 were set for every measurement child; this CPython 3.10 build has no JIT. All timed start/end power readings showed AC attached at 80%, not charging. A separate environment check reported low-power mode disabled. Recorded 1-minute load averages ranged from 2.45 to 4.68. Host load does not establish exclusive CPU use; the reordered pairs and individual measurements expose variation without eliminating it.

Both replay hashes and byte lengths matched the committed fixture manifest:

ReplayBytesSHA-256
882252040698,983,3005f976ab73b2efb0e4eca9f7f14d5980f3aae5f63e7952e16a8497eeded57d39d
8856501050385,487,5550f7577525b995347ed9df0c793cc8661c2a9db4ee176bf35cb5dd940e5af17e2

Public parse results ​

Individual measurements; seconds and MiB. B = ordinary records, E = incompatible slots.

ReplayRunVariantElapsedUser CPUPeak RSS
88225204061B50.23149.877197.67
88225204061E49.21148.957197.05
88225204062E50.67849.891197.19
88225204062B49.64949.446209.97
88225204063B49.58149.191200.95
88225204063E49.45949.276196.98
88565010501B159.543158.915639.67
88565010501E157.596157.036557.89
88565010502E161.276159.660574.16
88565010502B160.347159.252622.25
88565010503B162.738161.171633.12
88565010503E160.876159.677557.55

Medians of three runs (elapsed range in parentheses):

ReplayVariantElapsed (range)User CPUPeak RSS
8822520406B49.649 (49.581–50.231)49.446200.95
8822520406E49.459 (49.211–50.678)49.276197.05
8856501050B160.347 (159.543–162.738)159.252633.12
8856501050E160.876 (157.596–161.276)159.660557.89

Record storage and retained results ​

Unique records at assembly entry; storage in MiB. Mapping shells are additional to record shells and instance dictionaries.

ReplayVariantRecordCountShellsInstance dictionariesSnapshot mapping shells
8822520406Bcombat44,6862.04617.0460.000
8822520406Bsnapshots16,0400.7346.1199.719
8822520406Ecombat44,6869.5460.0000.000
8822520406Esnapshots16,0403.6710.0009.719
8856501050Bcombat272,40012.469103.9120.000
8856501050Bsnapshots59,2802.71422.61447.945
8856501050Ecombat272,40058.1910.0000.000
8856501050Esnapshots59,28013.5680.00047.945

Post-collection measurements with the public result alive; MiB. RSS is instrumented, not an exclusive result size.

ReplayVariantReachable graphCurrent RSS median (range)Live player snapshots
8822520406B32.871140.12 (132.22–140.66)0
8822520406E23.324119.16 (109.08–121.12)0
8856501050B182.337383.70 (379.72–384.02)0
8856501050E124.145312.81 (312.70–324.31)0

Reachable result graph breakdown (MiB); record shells include every result record type, and dictionaries include both instance dictionaries and payload mappings.

CategoryShort BShort ELong BLong E
Record shells2.2929.79213.12658.847
Dictionaries22.4835.437132.59828.686
Strings/bytes0.2950.2940.4270.426
Scalars/enums4.4194.41922.95122.951
Lists/tuples/sets3.3823.38213.23513.235

Interpretation and unchanged output ​

All three instrumented repetitions produced identical graph summaries and assembly counts within each variant/replay. The short replay had 15,800 dense and 240 minute snapshots; the long replay had 58,350 dense and 930 minute snapshots. Snapshot mapping shells occupied 9.719 and 47.945 MiB respectively in both layouts. They are separate from record-layout overhead.

In this CPython build, both ordinary record types used a 48-byte shell plus a 400-byte instance dictionary. Experimental combat records used 224-byte shells; snapshots used 240-byte shells, with their field pointers inside the slots. The combat shell/dictionary reduction was 9.546 MiB on the short replay and 58.191 MiB on the long replay. Snapshot shells/dictionaries reduced assembly storage by another 3.182 and 11.759 MiB. These are shallow record-storage measurements, not the issue's historical modeled totals, and they are not all retained by the returned result.

The returned graph was 9.547 MiB smaller on the short replay and 58.192 MiB smaller on the long replay. Neither variant retained any PlayerStateSnapshot objects after collection in any run. The small difference between graph reduction and combat shell/dictionary reduction also includes shared dictionary key strings. Graph bytes and instrumented current RSS describe different things; RSS additionally reflects native allocations, allocator reuse and inspection history.

Uninstrumented median peak RSS fell by 3.91 MiB (1.94%) on the short replay and 75.23 MiB (11.88%) on the long replay. All three pairs had lower experimental peak RSS, but individual baseline values varied. Timing does not show a useful speed improvement: median elapsed/user CPU changed by −0.38%/−0.35% on the short replay and +0.33%/+0.26% on the long replay. Directions differed between pairs, and the changes are small relative to run variation. No speedup claim or further timing campaign is needed to make this compatibility decision.

Every timed parse passed the completeness checks. All six serialized outputs per replay were compared byte-for-byte and had the same SHA-256. Serialization used json.dumps(gem.to_dict(match), sort_keys=True, separators=(',', ':')) with UTF-8 encoding and PYTHONHASHSEED=0; no floats were rounded or arrays reordered for comparison.

ReplayPlayersCombat entriesEnd tickTeamfightsOutput SHA-256
88225204061044,686102642243b0844312187a2856743092e991ab425878d64e101d91cf8f9c83bb2b3580427
885650105010272,400224720361e8d1f6f172d7bc39abd6b2a338539b231395780e599b9b8fbf44068128a9e5e

The public layout remains unchanged. The experiment demonstrates a memory tradeoff worth preserving as evidence, but fails the selected compatibility requirement. It does not justify silently dropping dictionaries, dynamic attributes or weak references. No claim is made that a compatible alternative would deliver these savings.

Validation and reproduction ​

The delivered change adds 24 fast compatibility cases and changes no production code. The focused delivered suite passed 612 tests, with one optional Parquet-engine skip and seven integration tests deselected. The experimental subset passed 385 tests and failed 22 as expected: 14 new compatibility cases and eight existing combat-entry vars() cases. Its one optional Parquet skip and seven deselections match the subset's selection. Extension-pickle cases fail when assigning the unsupported dynamic attribute, before pickling; plain record and match pickle round trips pass. Existing teamfight tests pass because their dictionary comparisons use doubles, as noted above.

  • Fast suite: 3,878 passed, 3 skipped, 62 deselected.
  • All offline tests: 3,939 passed, 3 skipped, 1 network test deselected.
  • Full offline OpenDota parity: 325, 326 and 331 passing fields for 8868259993, 8860187335 and 8856501050, with zero warnings or failures. Each fixture skips teamfights/total_count: the existing validator treats that count as informational because the event pipelines differ. Thresholds were unchanged; no required fixture or reference JSON was missing.
  • Ruff lint, format-check, mypy and documentation build passed. Built tables were visually reviewed. The build's chunk-size warning remains; generated reference-page changes were restored to keep this PR's scope.

The three suite skips are explicit: tests/test_bulk.py:250 needs optional pyarrow; tests/test_parquet_export.py:56 needs a Parquet engine; and tests/test_field_decoder.py:208 skips an existing range configuration whose flag is optimized away. No dependencies were installed or changed. Full-suite and parity validation ran concurrently only after all measurement processes finished; their durations are not performance evidence. No live download was needed.

Commands used from the repository root, with UV_CACHE_DIR pointed at the temporary study directory:

bash
uv run --no-sync pytest tests/test_record_compatibility.py tests/test_combatlog.py tests/test_combat_aggregator.py tests/test_players_extractor.py tests/test_teamfights.py tests/test_serialization.py tests/test_dataframes.py tests/test_bulk.py -ra
uv run --no-sync pytest -ra
uv run --no-sync pytest -m "not network" -ra --durations=25
uv run --no-sync ruff check src/ tests/
uv run --no-sync ruff format --check src/ tests/
uv run --no-sync mypy src/gem/
uv run --no-sync python /private/tmp/gem-164/parity.py

The documentation command was npm run docs:build from docs/, with the project environment first on PATH. The offline parity script supplies existing JSON directly and rejects network fetches. Benchmark bootstrap, complete scripts and two-decorator diff, full-precision timings and environment manifest, short-replay memory measurements, long-replay memory measurements and validation commands and skip details are preserved in PR #180. Temporary source trees, scripts and generated outputs are removed after publication and CI verification.

CPython runtime study ​

Study date: 7 September 2026.

This study measures source commit 3d1b734b60ba418b8c1324bc9cf1fb5c7688fbc8, after the optimizations through #178. It does not change Gem's Python 3.10+ requirement. The earlier measurements below remain historical; they are not the baseline for this comparison.

Environment and dependency control ​

The host was an Apple M2 Mac14,15 with 16 GiB RAM, running macOS 26.6.2 arm64 on AC power with Low Power Mode disabled. All interpreters were standard, GIL-enabled arm64 install_only builds from Astral's 20260901 release, built with Clang 22.1.3. No Rosetta, free-threaded, prerelease, or enabled JIT builds were included. PYTHONHASHSEED=0 and PYTHON_JIT=0 were set; build configuration and GIL/JIT introspection were checked where available.

LabelCPythonProduction dependencies
A3.10.21Common Python 3.10 lock selection
B3.11.16Same common versions as A
C3.12.14Same common versions as A
D3.13.15Same common versions as A
E3.14.7Native Python 3.14 lock selection

A–D use 43 identical production dependency versions, including NumPy 2.2.6, pandas 2.3.3, and IPython 8.38.0. These are marker-independent constraints materialized from the existing lock's Python 3.10 selection, including its compatibility backports. Matching versions still use interpreter-specific binary wheels. E uses 40 native dependencies, including NumPy 2.4.2, pandas 3.0.1, and IPython 9.11.0. E therefore compares a runtime and dependency environment; its differences cannot be attributed entirely to CPython.

All environments installed the same gem_dota-0.7.1-py3-none-any.whl, SHA-256 9070de91fb18289ce1461de0f919be9f912f040511ddfff7a795eebf38754bbd. The wheel also includes 553 pre-existing ignored report PNGs (14,120,517 uncompressed bytes). A clean archived-source rebuild has identical shared entries except RECORD, but omits those icons and therefore has a different wheel hash. Restoring the recorded assets and pinning uv_build==0.9.8 reproduced the measured wheel hash exactly. The asset manifest and restore script are preserved with the reproduction notes. Exact reconstruction requires those existing PNG bytes; the manifest alone cannot recreate them on a fresh checkout. A clean-source experiment can use a different wheel hash consistently across runtimes; the assets are report resources, not parser inputs. All five runtimes used the same measured wheel. Installation used uv 0.9.8, hash-checked binary production requirements, explicit interpreter paths, and isolated environments. Installed versions, wheel provenance, dependency consistency, and the installed Gem import path were verified. Test tools lived in separate environments and did not change production pins. The project environment, lockfile, and global tools were preserved.

Measurement protocol ​

Each scenario/replay used three fresh processes per runtime, with one parse per process: 60 timed parses in total. Sequential blocks were A/B/C/D/E, E/D/C/B/A, and C/A/E/B/D. No tests, profiling, or other benchmark workloads ran concurrently. Lazy imports were preloaded; normal garbage collection remained enabled, with a collection immediately before timing. Core parser construction was outside the timer. Public timing included extractor setup and result assembly. Startup, imports, serialization, and hashing were excluded.

Elapsed time, user CPU, and process peak RSS were captured before serialization. Peak RSS includes earlier process allocations and is not retained-result memory. Three observations expose variation but cannot establish a universal ranking across hardware, builds, replay patches, or dependency versions.

The short fixture 8822520406.dem is 98,983,300 bytes, SHA-256 5f976ab73b2efb0e4eca9f7f14d5980f3aae5f63e7952e16a8497eeded57d39d. The long TI2026 fixture 8856501050.dem is 385,487,555 bytes, SHA-256 0f7577525b995347ed9df0c793cc8661c2a9db4ee176bf35cb5dd940e5af17e2.

Every production dependency difference ​

PackageA–DE
exceptiongroup1.3.1Absent
ipython8.38.09.11.0
ipython-pygments-lexersAbsent1.1.1
numpy2.2.62.4.2
pandas2.3.33.0.1
pytz2026.1.post1Absent
typing-extensions4.15.0Absent
tzdata2025.3Absent

Primary measurements ​

Medians of three runs; times in seconds, peak RSS in MiB. The parenthesized elapsed range shows all three observations. Full-precision individual measurements and start/end load averages are preserved in the reproduction notes.

ScenarioReplayCPythonElapsed (range)User CPUPeak RSS
public88225204063.10.2145.714 (44.801–46.597)45.140196.33
public88225204063.11.1629.804 (29.764–30.514)29.680196.03
public88225204063.12.1430.554 (30.322–31.934)29.901193.00
public88225204063.13.1534.361 (34.105–34.524)33.953182.30
public88225204063.14.728.016 (28.016–28.594)27.866180.62
public88565010503.10.21147.273 (143.437–148.238)145.423624.12
public88565010503.11.1696.829 (96.604–98.373)95.875587.34
public88565010503.12.1496.414 (95.539–99.154)95.780576.64
public88565010503.13.15111.055 (109.191–113.694)110.586582.25
public88565010503.14.790.189 (89.728–91.367)89.187607.31
core88225204063.10.2135.889 (35.820–36.198)35.650166.06
core88225204063.11.1623.181 (22.997–23.468)23.063167.89
core88225204063.12.1423.924 (23.696–23.954)23.694167.84
core88225204063.13.1527.214 (26.566–27.960)27.126169.66
core88225204063.14.721.775 (21.598–21.803)21.654172.02
core88565010503.10.21124.749 (122.408–125.827)124.216425.72
core88565010503.11.1679.589 (79.279–82.361)78.939428.41
core88565010503.12.1480.972 (80.668–84.278)80.412429.81
core88565010503.13.1593.835 (92.417–95.300)92.933418.53
core88565010503.14.775.997 (75.623–77.230)75.689416.81

Observed 1-minute host load ranged from 2.48 to 5.34. User CPU range divided by its median stayed below 4.5% in every primary scenario/runtime. Host load is an observation, not proof of an idle machine; sequential reordered blocks reduce but cannot remove session effects.

Interpretation and runtime guidance ​

The provisional validation candidate was E (3.14.7), selected by the lowest geometric mean of the two public elapsed medians. Relative to A=1.000, scores were E=0.613, B=0.655, C=0.661, D=0.753. Public CPU, then newer minor version, were the declared tie-breakers; neither was needed. E is the only environment within 5% of the overall best score, but its changed dependencies and failed compatibility assertion prevent a recommendation.

Within the controlled A–D comparison, B and C form a close group: their public scores differ by about 1%, and their relative per-block directions vary. Both reduce median public elapsed and CPU time by approximately 33–35% versus A on each replay, with improvements in all three blocks. This meets the timing threshold of at least 5% improvement in both metrics on both replays and at least two improving blocks. It does not resolve a uniquely fastest runtime.

The original core runs showed consistently higher peak RSS for E on the short replay and for B/C on both replays. The predeclared anomaly checks covered mixed block directions, CPU range/median above 10%, median RSS increases above 10%, and smaller consistent increases. The same checks were extended from E to B/C because they were the strongest passing configurations under common pins. Five comparisons received one fixed three-pair follow-up each (30 additional parses), ordered candidate/A, A/candidate, candidate/A. No further timing rounds were taken.

Core follow-upReplayMedian candidate peak RSS MiBMedian paired A peak RSS MiBDifference of medians
E/A8822520406172.109165.984+3.69%
B/A8822520406168.062166.109+1.18%
B/A8856501050442.172428.781+3.12%
C/A8822520406167.734166.016+1.04%
C/A8856501050429.172425.750+0.80%

These follow-ups retain the small core memory increases. Higher pre-parse high-water marks are consistent with a runtime/dependency startup-footprint contribution, but do not prove it. Both readings use ru_maxrss; subtracting them does not measure retained memory or isolate parser allocations. The precise cause of the differences remains unresolved.

The final C/A long-core follow-up was unstable. C user CPU observations were 78.922, 166.522, and 165.390 seconds; A observations were 122.944, 158.052, and 250.125 seconds. Near the end, the host reported AC power but a 4% battery that was discharging; Low Power Mode remained off and thermal status was unavailable. This is evidence of session instability, not proof of a specific power or thermal cause. Those observations are preserved separately and do not replace the primary results or support a clean follow-up ranking.

The primary public timing benefits are clear, but the unresolved core memory tradeoffs and unstable final follow-up prevent an unconditional performance recommendation under this study's acceptance rules.

For practical installation decisions, the fast common-pin configurations are worth evaluating for a parser-heavy workload, but this study does not select an unconditional preferred runtime. Preserve Python 3.10+ compatibility and validate the actual dependency environment you intend to install. In particular, the passing common-pin results do not validate the repository's native pandas 3 environment on newer Python minors.

Compatibility validation ​

CPythonFast suiteAll offline testsFull OpenDota parity
3.10.213854 passed, 3 skipped, 62 deselected in 11.30s3915 passed, 3 skipped, 1 deselected in 258.39s (0:04:18)982 PASS; 0 WARN; 0 FAIL; 3 informational SKIP
3.11.163818 passed, 3 skipped, 62 deselected in 10.43s3879 passed, 3 skipped, 1 deselected in 563.45s (0:09:23)982 PASS; 0 WARN; 0 FAIL; 3 informational SKIP
3.12.143818 passed, 3 skipped, 62 deselected in 9.39s3879 passed, 3 skipped, 1 deselected in 558.58s (0:09:18)982 PASS; 0 WARN; 0 FAIL; 3 informational SKIP
3.13.153818 passed, 3 skipped, 62 deselected in 10.37sNot selectedNot selected
3.14.71 failed, 3817 passed, 3 skipped, 62 deselected in 29.21s1 failed, 3878 passed, 3 skipped, 1 deselected in 539.59s (0:08:59)982 PASS; 0 WARN; 0 FAIL; 3 informational SKIP

Python 3.14.7 with pandas 3.0.1 fails the existing tests/test_bulk.py::TestParseManyToDataframe::test_match_path_column_is_string assertion (dtype == object); pandas instead returns its default string dtype. No assertion or production dependency was changed to hide this failure. This blocks a compatibility recommendation for E, even though its parser JSON matches. Pandas documents this behavior in its 3.0 release notes. The current lock also selects pandas 3.0.1 on Python 3.11–3.13. Passing B/C/D checks with common pandas 2.3.3 pins do not validate those native-lock environments, and this failure is not established as a CPython 3.14 defect.

The 36 fewer generated tests on Python 3.11+ result from IntFlag iteration: list(EntityOp) yields five atomic members there, versus nine named members on 3.10. The named-member overlap parametrization therefore changes from 9×9 to 9×5 cases. The exhaustive 32×32 mask check still runs on every runtime; no tests were deleted.

Every pytest run has the same three skips: optional pyarrow coverage (tests/test_bulk.py:250), an optional Parquet engine (tests/test_parquet_export.py:56), and the existing optimized-away flag case (tests/test_field_decoder.py:208). Fast selection deselects 62 marked tests; offline selection deselects only the live network test. Full parity uses the existing references for 8868259993, 8860187335, and 8856501050 with unchanged thresholds (325, 326, and 331 passing fields per runtime). Each replay reports one informational teamfights/total_count skip for the expanded OpenDota pipeline's deaths≥3 convention. No required local fixtures were missing.

Validation timings are not parser performance measurements. Initial untimed metadata/fast checks overlapped, as did baseline offline/parity checks; all were stopped before the timing matrix began. Subsequent compatibility checks ran concurrently across separate runtime environments, only after all timed measurements finished. Their elapsed durations include contention and should not be compared as runtime benchmarks.

Public output equality ​

All 15 public results per replay were byte-identical across runtimes and repetitions, using json.dumps(gem.to_dict(result), sort_keys=True, separators=(",", ":")).encode() with PYTHONHASHSEED=0. Arrays were not reordered and numeric values were not rounded to manufacture agreement.

ReplayJSON bytesPlayersCombat eventsWardsTeamfights / OpenDota teamfightsDraft events
882252040630,401,2051044,6866024 / 324
8856501050187,363,26110272,40022136 / 1024

SHA-256:

  • 8822520406: 3b0844312187a2856743092e991ab425878d64e101d91cf8f9c83bb2b3580427
  • 8856501050: 1e8d1f6f172d7bc39abd6b2a338539b231395780e599b9b8fbf44068128a9e5e

Reproduction and provenance ​

The study PR preserves complete bootstrap and benchmark scripts, commands, common/native constraints, all installed package manifests, binary-wheel URLs and hashes, executable paths, build configuration, individual measurements, load observations, and compatibility/skip results in linked reproduction notes. The complete scripts, primary measurements, and fixed follow-ups are available directly. Temporary runtimes, environments, wheels, scripts, and outputs were removed only after publication was verified.

To reproduce the controlled configuration, use the exact source commit and Astral artifacts, build one wheel, then install it into an isolated environment with the published common constraints. Use the published bootstrap instead of assuming a plain uv sync on Python 3.11/3.12 produces the tested environment. Keep test dependencies in separate constrained environments. No dependency pins or Python-version requirements in Gem were changed by this study.

Runtime archive SHA-256 hashes
CPythonSHA-256
3.10.21cee232aabfb6790eec78f3cca935caeb7bd4eedca4dcb0a10dbcdb4302320b38
3.11.1650424fa409e8ae84b82a3052522f64695b47dff2158b70bb7358e0ebd6c085c9
3.12.143ee3ee547cedfeb7c2b16b2b7156039f7b470bb8f857e226fd3d2eb11db83c76
3.13.15b9054a9d3d54f4cb5573d44907fddb29874b08909bde73f29f2868cf872223ee
3.14.730daa970c7d223530120f1693cd3c6fa4c0c0d31ef158710b0dd77f286a5b23e

The subsequent final profile records hotspot profiling and the Rust boundary decision for #158. This runtime study makes no claims about Linux, x86, other CPython builds, or JIT/free-threaded configurations.

Historical optimization work (#143–#156) ​

Benchmark fixture and method ​

The common benchmark is tests/fixtures/opendota/8822520406.dem, a 98,983,300-byte DreamLeague Season 29 replay lasting 1,397 seconds. The original profile was collected on macOS arm64 with Python 3.10.4.

Measurements separate three kinds of work:

  1. gem.parse, including standard extractors and result assembly
  2. ReplayParser.parse without standard extractors, isolating core decoding
  3. instrumented profiles and counters, used for rankings and call volume

Elapsed time and peak resident memory come from fresh, uninstrumented processes. Instrumented profiles are not used as elapsed-time benchmarks because profiler overhead is substantial. Each optimization PR recorded the median of three full parses and checked a normalized ParsedMatch against its preceding baseline.

Baseline ​

The original profile at commit ed6d7a5 measured:

ScenarioElapsedPeak RSS
Public gem.parse92.976 s193.7 MiB
Core ReplayParser.parse58.280 s154.4 MiB

The dominant repeated work included 36.9 million built-in callback invocations, 26.6 million Entity.get calls, 15.2 million FieldPath constructions, 9.96 million FieldPath.copy calls, and approximately 13.9 million recursive decoder resolution calls. FieldState reads and writes also spent significant time in small helper methods invoked once per path component.

See issue #143 for the full historical function table and memory breakdown.

Optimization sequence ​

Five deliberately separate changes addressed the measured Python hot paths:

ChangeMain measured effect
Class-aware callback routingBuilt-in callback invocations fell from 36,890,700 to 5,381,400 (85.4%).
Per-entity loop cleanupSampling eligibility checks fell from 2,169,002 to 51,312; interval hero-name resolutions fell from 1,330,198 to 42; unused result tuples were removed from the parser path.
FieldState traversal cleanupProduction traversal eliminated 15,868,940 _has_slot, 15,198,552 _ensure, and 15,533,746 _is_child dispatches.
Shared compiled entity fieldsProfiled public Entity.get calls fell from 26.6 million to 41; serializer field caches retained about 604 KiB.
Compact paths and decoder cachingProduction FieldPath.copy calls fell from about 9.96 million to zero; recursive decoder resolution fell from about 13.9 million calls to about 80,000.

The final pass recorded a 64.15-second public median and a 41.40-second core median. Those are respectively 11.2% and 15.6% faster than its immediately preceding quiet baseline. Compared with the original #143 measurements, they are about 31% and 29% lower, but that longer-range comparison spans separate measurement sessions and should be treated as directional rather than a controlled benchmark.

Correctness gates ​

Each pass retained public callback and entity APIs and compared normalized output with the preceding implementation. After the final pass, the normalized 8822520406 output remained 33,045,771 bytes with SHA-256 88712b6b104fa937cee13c5589708327a389e02bf70a95a3716fde9b5c2775b2.

Focused tests cover callback ordering and lifecycle operations; sparse and nested FieldState traversal; simple, fixed-array, fixed-table, variable-array, and variable-table decoder models; serializer cache isolation; invalid paths; entity creation, updates, deletion, and recycled slots. Full replay tests add output and OpenDota parity coverage.

Memory and remaining work ​

The final pass recorded median peak RSS of 228.2 MB, about 1.9% above its immediately preceding public baseline. Its parse-scoped decoder caches were estimated at 5.6 MB. The optimization sequence therefore produced a clear CPU improvement, but did not establish a memory reduction.

The final profile still identified two measurable Python costs:

  • EntityManager.find_by_npc_name scans the entity-slot collection.
  • Some extractor paths still repeat indexed field lookup work.

These remain candidates, not committed follow-up work. A new optimization should start with a fresh profile and demonstrate enough end-to-end impact to justify the added indexes or extractor complexity.

The final PR recorded helper-call elimination for FieldState, but not a standalone post-change FieldState self-time or a complete allocation ranking. Do not infer either number from the elapsed-time improvement. Reprofile before making claims about the current top memory allocation sites.