MT-7273: Optimize Message parsing, copying and encoding for large messages#268
MT-7273: Optimize Message parsing, copying and encoding for large messages#268sipho-sarao wants to merge 12 commits into
Conversation
|
Interesting! What is the latency after the fix? |
Great question, here is the latency broken down across three measurement contexts on my devbox environment: 1. Live proxy on my devbox (
So ~30 ms per gain-set end-to-end on a loaded devbox, and the long tail is effectively gone. The new p95 sits well below the old median. 2. Controlled proxy chain (client -> proxy -> zero-work device, isolating CAM stack cost with full 60 KiB complex-formatted payloads):
3. Codec CPU cost alone (the specific functions optimized in this PR, Py2.7 @ 4096 args):
What remains after the fix is mostly the product-controller round trip (~10 ms, as it re-parses the line on its side outside of Overall throughput for the ticket's 880-message scenario dropped from ~98 s down to ~30 s on my devbox. All numbers are reproducible using the |
ludwigschwardt
left a comment
There was a problem hiding this comment.
Just some generic comments for now...
| escape_match = self._escape_match | ||
| escaped_args = [] | ||
| for x in self.arguments: | ||
| if search(x): |
There was a problem hiding this comment.
What does the search check do that makes it better than before?
There was a problem hiding this comment.
In the vast majority of KATCP arguments and in all arguments of the bulk numeric payloads this PR targets (gains, weights, delays, numeric sensor values), there are no characters needing escaping. I stand to be corrected.
Previously, self.ESCAPE_RE.sub() was executed unconditionally on every single argument, including even when nothing matches (sub() then returns the original object, no new bytes are built), it still pays the substitution's setup overhead for the callable-replacement path. When measured on my devbox Py2.7 proxy runtime, per clean argument: search takes ~278 ns vs sub taking ~457 ns.
search(x) acts as a fast-path pre-check:
- Short-circuits clean arguments:
re.search()does a single C-level scan of the bytes and returnsNone, avoiding the heaviersub()call entirely, saving ~0.7 ms per 4096-argument message. - Local variable binding: Aliasing
search = self.ESCAPE_RE.searchoutside the loop avoids repeated attribute lookups on every iteration across thousands of arguments.
Together with folding the former empty-argument pass (x or b"\\@") into the same loop, this takes the encode step from 1.8 ms down to 0.9 ms per 4096-argument message.
The trade-off: escape-bearing arguments (mainly human-readable descriptions in #help, #sensor-list, or #log informs) take the sub() path as before, paying search + sub (+340 ns measured per escaped argument). Since the guard is per-argument, clean arguments within those same messages still benefit from the fast path.
| import sys | ||
| import unittest | ||
|
|
||
| from _repo import katcp_repo |
There was a problem hiding this comment.
Why would you need special logic to find the KATCP repo?
There was a problem hiding this comment.
Mainly to support the A/B differential scripts (ab_compare.py and fuzz_parse_branch.py) and flexible execution:
- Explicit checkout target vs site-packages: It guarantees the scripts test the specific source tree you intend (including the temporary
git worktreecheckout thatab_compare.pygenerates for the baseline side), preventing Python from silently importing an already-installedkatcpfrom site-packages. - Out-of-tree execution:
KATCP_REPO=/path/to/katcp-pythonlets you run the suite against an arbitrary local checkout without copying thebench/directory over. (Forab_compare.pyandfuzz_parse_branch.py, the target must be a git repo since they rungit worktree/git showagainst it to obtain the baseline). - Location independence: Discovery is anchored on the script's own
__file__, so invocation works from any working directory.git rev-parse --show-topleveladditionally resolves the true repo root rather than assumingbench/'s parent is it.
For a simple single-branch run, sys.path.insert(0, dirname(dirname(__file__))) would do, _repo.py just keeps the discovery consistent between local runs and the git-worktree differential scripts. Happy to drop the rev-parse call and use dirname as the default if preferred.
|
|
||
| if '--fast' in sys.argv: | ||
| sys.argv.remove('--fast') | ||
| import fastkatcp # noqa: F401 |
There was a problem hiding this comment.
Where does fastkatcp come from?
There was a problem hiding this comment.
Good catch, it doesn't come from anywhere in this repo. fastkatcp was my investigation-phase monkeypatch form of this fix: an import-time patch I used to hot-deploy the optimisations onto my devbox proxy for testing before this branch existed.
The --fast hooks were scaffolding from that phase and are vestigial here. Removed the flag and the import from all four scripts. Thanks for spotting that.
|
Thanks for getting your agent to clean up after itself 😅🙏🏼 It would be instructive to compare the performance of |
katcore's DeviceHandler instrumentation cannot see this cost. callback_request() is @make_threadsafe, so when called off the ioloop thread it only does ioloop.add_callback() and returns; serialising the forwarded message happens here afterwards, inside the interval that instrumentation reports as the device round trip. On a 4096-channel wide-gain request that encode is far from free -- 9.88 ms under Python 2.7 on unpatched katcp-python -- so attributing it to the device is misleading. Gated on KATCP_LATENCY_LOG_REQUESTS (comma-separated request names), empty by default, matching KATCORE_LATENCY_LOG_REQUESTS in katcore and emitting the same [LATENCY] line format. When disabled the cost is one frozenset membership test per outgoing message.
MiniProxy.request_wide_gain had no docstring, and DeviceServer's metaclass asserts that every request_* handler has one at class-creation time. The script therefore raised AssertionError at import and has never run since the commit that added it.
MT-7273: Optimize Message parsing, copying and encoding for wide messages
Problem
A 4096-channel CBF+
?gainrequest yields a ~60 KiB KATCP line with ~4100 arguments. Processing these large messages hits noticeable CPU bottlenecks:MessageParser.parse: ~7.5 msMessage.copy(): ~3.3 ms (re-runsformat_argumenton already-bytes args)Message.__bytes__: ~1.8 msFor the CBF+ proxy, this cost is incurred on every proxied gain message and dominates request latency investigated in this branch.
Changes (
katcp/core.py)MessageParser.parse- If a line contains no backslash, skip per-argumentUNESCAPE_RE.sub/SPECIAL_RE.searchcalls and validate via a single scan over the argument region (excluding space/tab delimiters). Construct the result via a newMessage._from_parsed()helper to bypass__init__'s re-validation loops.Message.copy- Use direct slot assignment +list()instead of re-formatting every argument. Preserves existing behavior (does not copymid).Message.__bytes__- Avoid regex substitution overhead on unescaped args by checkingESCAPE_RE.searchfirst, and combine empty-argument formatting into the loop pass..Non-functional changes in this PR:
tox.ini: Pinscoverage<5(CI fix - build node lacks_sqlite3needed by coverage ≥5; 4.x is the last major supporting Python 2.7).bench/: Added the verification and benchmark scripts described below. These are self-contained scripts to reproduce the performance and equivalence claims of the large-message optimisation, added for reviewers and future regression checks. They locate the repo automatically (or via the KATCP_REPO env var) and can be run on Python 2.7 and 3.Verification & Benchmarks
master(covering heavy escapes, bad names/mids, and whitespace variations). Produced identical outputs, exception types, and error messages.cbf_plus_dev_1, 880 serial 4096-channel set-gain messages):Note: Small messages also benefit slightly from bypassing constructor validation (1-arg parse drops from 9.2 µs to 4.1 µs). The escape-containing fallback path remains unchanged.
Testing Instructions
Verification tools are included under
bench/(seebench/README.md).Local repo tests: