Skip to content

Bound memory by writing ciphertext and plaintext in chunks - #22

Merged
ESultanik merged 1 commit into
masterfrom
streaming-io
Aug 7, 2026
Merged

Bound memory by writing ciphertext and plaintext in chunks#22
ESultanik merged 1 commit into
masterfrom
streaming-io

Conversation

@ESultanik

Copy link
Copy Markdown
Owner

__main__ built the entire ciphertext with bytes(encrypter(...)) and the entire plaintext with bytes(decrypt(...)) before writing either, so peak memory scaled with the file rather than staying constant. Encrypting two 4 MiB plaintexts into an 11 MiB ciphertext:

master chunked
encrypt peak RSS 75.4 MB 42.1 MB −44%
decrypt peak RSS 36.5 MB 29.9 MB −18%

The saving grows with input size, since what is no longer held is proportional to the ciphertext.

API

Encrypter.blocks() yields whole blocks — the natural unit here, one per encoded nibble-gram — and chunks() batches those to roughly 64 KiB. decrypt_chunks() does the same coming back.

__iter__ still flattens to individual ints and bytes(encrypter) still works, so the existing library API is unchanged. The one visible difference: encrypter headers now yield bytes rather than ints, which affects anything calling get_header() directly.

A real if modest speedup

pack_grams was called twice per emitted block — once to test membership, again to look up the value. consume_grams now packs once per position and passes the key down, so can_encode and encode_block take the packed key instead of the gram tuple.

encrypt 1 MiB    7.03s -> 6.22s
encrypt 256 KiB  1.83s -> 1.52s

What this is not

Worth being clear: profiling showed the per-byte yield overhead I expected to find is not where encryption spends its time. At 256 KiB of plaintext the cost is the nibble walk itself — 3.9M peek_nibbles calls, 1.2M _grams_at calls — and that is irreducibly per-nibble Python. Encryption remains around 6 seconds per MiB.

This PR buys bounded memory and removes duplicated work. It does not make the walk fast. If encryption throughput matters later, that walk is the thing to attack, and it would be a much more invasive change.

Verification

11 new tests check that chunked output reassembles byte-for-byte into what the whole-buffer paths produce, across chunk sizes from 1 byte to 1 MiB, and that chunk sizes track the requested granularity. 234 passed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01367hFob9sd4xpDmVT4uoFy

`__main__` built the entire ciphertext with `bytes(encrypter(...))` and the
entire plaintext with `bytes(decrypt(...))` before writing either, so peak memory
scaled with the file rather than staying constant. Encrypting two 4 MiB
plaintexts into an 11 MiB ciphertext:

                    master      chunked
    encrypt peak    75.4 MB     42.1 MB     -44%
    decrypt peak    36.5 MB     29.9 MB     -18%

The saving grows with input size, since what is no longer held is proportional to
the ciphertext.

`Encrypter.blocks()` yields whole blocks -- the natural unit here, one per encoded
nibble-gram -- and `chunks()` batches those to roughly 64 KiB. `decrypt_chunks()`
does the same coming back. `__iter__` still flattens to individual ints and
`bytes(encrypter)` still works, so the existing library API is unchanged; the
encrypter headers now yield `bytes` rather than ints, which is the one visible
difference for anything calling `get_header()` directly.

Separately, a real if modest speedup: `pack_grams` was called twice per emitted
block, once to test membership and again to look up the value. `consume_grams`
now packs once per position and passes the key down, so `can_encode` and
`encode_block` take the packed key instead of the gram tuple. Encrypting 1 MiB
goes from 7.03s to 6.22s, and 256 KiB from 1.83s to 1.52s.

Worth being clear about what this is *not*: profiling showed the per-byte `yield`
overhead I expected to find is not where encryption spends its time. At 256 KiB
of plaintext the cost is the nibble walk itself -- 3.9M `peek_nibbles` calls, 1.2M
`_grams_at` calls -- and that is irreducibly per-nibble Python. Encryption remains
around 6 seconds per MiB. This commit buys bounded memory and removes duplicated
work; it does not make the walk fast.

11 new tests check that chunked output reassembles byte-for-byte into what the
whole-buffer paths produce, across chunk sizes from 1 byte to 1 MiB, and that
chunk sizes track the requested granularity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01367hFob9sd4xpDmVT4uoFy
@ESultanik
ESultanik merged commit 2005741 into master Aug 7, 2026
11 checks passed
@ESultanik
ESultanik deleted the streaming-io branch August 7, 2026 20:24
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