Skip to content

perf(hds_protocol): 62% fewer allocations in encode (one shared buffer) - #552

Open
dexhunter wants to merge 1 commit into
ikalchev:devfrom
dexhunter:perf/hds-encode-shared-buffer
Open

dexhunter wants to merge 1 commit into
ikalchev:devfrom
dexhunter:perf/hds-encode-shared-buffer

Conversation

@dexhunter

Copy link
Copy Markdown
Contributor

TL;DR

  • pyhap.hds_protocol.encode now writes every value into one shared bytearray.
  • Before, each value built its own small bytes objects, and each container joined them again.
  • Measured on the frames of one HKSV recording session: 22548.6 -> 8573.4 memory allocations, 62.0% fewer.
  • A 256 KB dataSend data frame: 422.74 µs -> 230.70 µs, 45% faster.
  • Tested with python3 -m pytest tests/test_hds_recording.py tests/test_hksv_transport.py tests/test_hksv_gating.py -q: 56 passed.

What & Why

Message.encode() runs for every HomeKit Data Stream frame the accessory sends. The old encode returned a new bytes object for each tag, number, string, key and container. Each list and dict then joined the parts of its items. A media chunk was copied one time for each container level around it.

The new private helper _encode_into(value, out) appends to one bytearray. _encode_int and _encode_length_prefixed append to the same buffer. struct.pack writes the tag together with the number. encode makes the buffer, fills it and returns bytes(out).

The tag values, the width limits, the 14-item inline rule and the TypeError text are the same. The public signature and the return type of encode do not change.

Situation (Message.encode() unless noted) Before After Change
control/hello response, empty body 54 allocations, 3.86 µs 36 allocations, 3.12 µs 19% faster
dataSend/open response, 1 key 60 allocations, 4.38 µs 40 allocations, 3.50 µs 20% faster
dataSend/data event, 1 packet of 4 KB 122 allocations, 10.19 µs 54 allocations, 7.11 µs 30% faster
dataSend/data event, 1 packet of 256 KB 122 allocations, 422.74 µs 54 allocations, 230.70 µs 45% faster
dataSend/data event, 20 packets of 1.5 KB 1267 allocations, 114.78 µs 355 allocations, 75.02 µs 35% faster
encode(True), one bare scalar 3 allocations, 0.10 µs 4 allocations, 0.17 µs 0.07 µs slower
encode(data), bare 256 KB bytes 9 allocations, 5.25 µs 8 allocations, 213.24 µs 208 µs slower

The last two rows get slower. A bare value outside a container now pays for the buffer and the final copy. For a bare 256 KB value, that is two large memory blocks for each call, not one. Every HDS frame is a header dict plus a message dict, so the library does not send bare values.

Why this is safe and easy to maintain

The change is in one file and touches three functions plus one new private helper. The branch order in the encoder is the same as before, so the diff reads line by line. The encoded bytes are identical for every input type. The existing tests in tests/test_hds_recording.py, tests/test_hksv_transport.py and tests/test_hksv_gating.py cover the encoder and all pass.

Validation

  • Native tests on the patched tree: 56 passed in 1.04 s.
  • ruff check and ruff format --check pass on pyhap/hds_protocol.py.
  • Output check: every frame of five seeded recording sessions matches the old encoder byte for byte. The check also covers all integer widths, Int64, int subclasses, floats, str/bytes/bytearray at each length limit, 14- and 15-item containers, and the error cases.
  • Allocation counts come from a hook on the CPython allocator (malloc, calloc, realloc) with the garbage collector off. Each count is for one call of the row, after five warm-up calls.
  • Each table row ran in a new process, with the default allocator settings, on CPython 3.12.13, Linux x86-64. Each time is the median of three such processes.

The script below holds the message body of every row. Save it as bench_rows.py and run PYTHONPATH=. python3 bench_rows.py data-256k on dev and on this branch. Use a new process for each row name.

import os, sys, timeit
from pyhap import hds_protocol as hp

def data_event(size, count=1):
    packets = [{"data": os.urandom(size),
                "metadata": {"dataType": "mediaFragment", "dataSequenceNumber": i + 2,
                             "dataChunkSequenceNumber": 1, "isLastDataChunk": True,
                             "dataTotalSize": size}}
               for i in range(count)]
    return hp.event("dataSend", "data", {"streamId": 1, "packets": packets}).encode

def bare(value):
    return lambda: hp.encode(value)

rows = {
    "hello": lambda: hp.response("control", "hello", {}, 1).encode,
    "open": lambda: hp.response("dataSend", "open", {"status": 0}, 2).encode,
    "data-4k": lambda: data_event(0x1000),
    "data-256k": lambda: data_event(0x40000),
    "data-20x1500": lambda: data_event(1500, 20),
    "true": lambda: bare(True),
    "bytes-256k": lambda: bare(os.urandom(0x40000)),
}
fn = rows[sys.argv[1]]()
print(min(timeit.repeat(fn, number=200, repeat=15)) / 200 * 1e6, "µs")

AI assistance

This PR was prepared with AI assistance from Aiden. Candidates were tracked with Weco Observe. The tests, the lint checks and the measurements above ran on the patched tree. There was no human review before submission.

Weco share link: https://dashboard.weco.ai/share/nuxeMBqIu4e-ZZj7maqy9kQufrWB5dHe

Build the encoded HDS payload in one shared bytearray and pack the tag
together with the number, instead of joining many small bytes objects.
Output bytes are unchanged.

Co-Authored-By: Aiden <aiden@weco.ai>
@codecov

codecov Bot commented Nov 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 68.42105% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.17%. Comparing base (6818241) to head (2bcc303).
⚠️ Report is 4 commits behind head on dev.

Files with missing lines Patch % Lines
pyhap/hds_protocol.py 68.42% 11 Missing and 7 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #552      +/-   ##
==========================================
+ Coverage   91.77%   93.17%   +1.39%     
==========================================
  Files          27       30       +3     
  Lines        4367     5628    +1261     
  Branches      558      707     +149     
==========================================
+ Hits         4008     5244    +1236     
- Misses        251      264      +13     
- Partials      108      120      +12     
Files with missing lines Coverage Δ
pyhap/hds_protocol.py 74.42% <68.42%> (+1.35%) ⬆️

... and 10 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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