Skip to content

Stop doing work the output doesn't depend on - #84

Merged
psobot merged 1 commit into
masterfrom
psobot/perf-yaml-and-ls
Aug 8, 2026
Merged

psobot merged 1 commit into
masterfrom
psobot/perf-yaml-and-ls

Conversation

@psobot

@psobot psobot commented Aug 8, 2026 •

Copy link
Copy Markdown
Owner

Did some profiling with Claude, and found:

phase unpack pack
Protobuf decode 10 ms (1.5%) yaml.load 662 ms (76%)
MessageToDict 103 ms (18%) from_dict 208 ms (24%)
yaml.dump 445 ms (80%) to_buffer + Snappy 3 ms (0.4%)

Most of the time is spent in Python after YAML ser/de.

To speed things up:

1. ls no longer decodes anything. It prints names it already has from the zip directory, but process_file parsed every archive first and handed the result to a sink that ignores it. 6.3 ms → 0.4 ms (15.8×), and it scaled with document size for no benefit. Directory listings still report .iwa, not .iwa.yaml.

2. We no longer roundtrip through the IWAFile class when not necessary. With replacements, the flow was to_dict → from_dict → to_dict, rebuilding an IWAFile purely so the sink could take it apart again.

3. Scalar tag resolution is now cached. Resolver.resolve() is a pure function of (value, implicit) for a fixed resolver table, and PyYAML calls it 259,176 times per document. Cache is bounded at 100k entries.

Profiling unpack of a 320 KB .key: YAML is 80% of the time, MessageToDict 18%,
Protobuf decoding 1.5% and Snappy 0.4%. Packing is the same shape, with
yaml.load at 76%. libyaml is installed and used, but CDumper/CLoader only
replace the emitter and lexer - the Representer, Resolver and Constructor above
them stay pure Python, and a single document builds 118,618 ScalarNode objects.
The driver is volume: the YAML form of a document is ~30x the size of the .iwa
it came from.

Three changes, none of which alter a byte of output:

 - `ls` no longer decodes anything. It prints names taken from the zip
   directory, but process_file parsed every archive first and handed the result
   to a sink that ignored it. 6.3ms -> 0.4ms on table.key, and it scaled with
   document size for no benefit. Directory listings still report .iwa rather
   than .iwa.yaml.

 - A YAML sink is now handed the dict a replacement already produced, instead
   of rebuilding an IWAFile from it only for the sink to call to_dict() again.
   Worth ~33% of the per-archive cost, though only archives containing text
   take that branch, so the real saving scales with how text-heavy a deck is.
   Binary sinks still get a real IWAFile - they need it to serialize.

 - Scalar tag resolution is memoized. Resolver.resolve() is a pure function of
   (value, implicit) for a fixed resolver table, and PyYAML calls it 259,176
   times for one document. The cache is bounded.

Verified byte-identical output against master for ls, cat, unpack, pack,
unpack --replacements and replace, by hashing every produced file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0179M4xvAKPGrgKpy4AeCsM7
@psobot
psobot merged commit 194d853 into master Aug 8, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant