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 13 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.
There was a problem hiding this comment.
I'm not sure if the trade-off of doing a checking with re.search first is worth the impact on cases that do need escaping, since those cases do an re.search in addition to re.sub. Also when benchmarking this did you include overhead of the if-statement too? I don't think it's accurate to compare re.search and re.sub in isolation.
| 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.
There was a problem hiding this comment.
Thanks for working on this. I commented some suggestions below. I also created a branch that incorporates most of your optimisations, slightly improves on them and adds further optimisations.
It seems to improve performance compared to your initial changes. The branch is user/zosman/MT-7273/optimise-katcp-codec. For the test script and configuration we're using and on my devbox, I saw a ~42% improvement to the CBF+ proxy overhead latency for the "gain" command using your branch, and ~54% using the branch I shared.
| @classmethod | ||
| def _from_parsed(cls, mtype, name, arguments, mid): | ||
| """Construct a Message from pre-validated parts, bypassing __init__.""" | ||
| msg = cls.__new__(cls) | ||
| msg.mtype = mtype | ||
| msg.name = name | ||
| msg.mid = mid | ||
| msg.arguments = arguments | ||
| return msg | ||
|
|
There was a problem hiding this comment.
Nice optimisation, makes sense to use in MessageParser.parse() and Message.copy() since the validation in Message.__init__() is redundant in those two places.
| escape_match = self._escape_match | ||
| escaped_args = [] | ||
| for x in self.arguments: | ||
| if search(x): |
There was a problem hiding this comment.
I'm not sure if the trade-off of doing a checking with re.search first is worth the impact on cases that do need escaping, since those cases do an re.search in addition to re.sub. Also when benchmarking this did you include overhead of the if-statement too? I don't think it's accurate to compare re.search and re.sub in isolation.
| arguments = [self._parse_arg(x) for x in parts[1:]] | ||
| if b"\\" not in line: | ||
| match = self.SPECIAL_NO_WHITESPACE_RE.search(line, len(parts[0])) | ||
| if match: | ||
| raise KatcpSyntaxError( | ||
| "Un-escaped special %r." % (match.group(),)) | ||
| arguments = parts[1:] | ||
| else: | ||
| arguments = [self._parse_arg(x) for x in parts[1:]] |
There was a problem hiding this comment.
If you move the unescaped special character check across the entire line to an outer conditional I think you can remove the same check in _parse_arg():
match = self.SPECIAL_NO_WHITESPACE_RE.search(line, len(parts[0]))
if match:
raise KatcpSyntaxError("Un-escaped special %r." % (match.group(),))
if b"\\" not in line:
arguments = parts[1:]
else:
arguments = [self._parse_arg(x) for x in parts[1:]] # _parse_arg() doesn't need to do the unescaped check hereThere was a problem hiding this comment.
I'm not sure if the trade-off of doing a checking with
re.searchfirst is worth the impact on cases that do need escaping, since those cases do anre.searchin addition tore.sub. Also when benchmarking this did you include overhead of the if-statement too? I don't think it's accurate to comparere.searchandre.subin isolation.
Fair challenge, so I benchmarked it end-to-end on __bytes__ (including branch and call overhead, not just search vs sub in isolation) across Python 2.7 and Python 3.10:
| Workload (4096 args) | old |
Unconditional sub |
search pre-check |
|---|---|---|---|
| Typical traffic (no escaping needed) | 1.81 ms | 1.45 ms (-20%) | 0.88 ms (-52%) |
| Worst case (all args escaped) | 3.71 ms | 3.47 ms (-6%) | 4.72 ms (+27%) |
The extra search call does cost ~0.2 µs per escaped argument when escaping is needed due to Python call overhead. However, the reason the search pre-check makes sense here:
- Most frequent traffic path (
wide-gain/ MT-7273): Messages have zero escaped args. The pre-check yields a -52% time reduction (0.88 ms vs 1.45 ms for unconditionalsub). - Realistic Escaped Messages (Sensor Informs): Typically carry ~5 args with a single large escaped value. A single early-exit
searchoverhead is sub-microsecond noise compared to thesubthat follows. - Worst-case scenario: A message with thousands of individually escaped arguments doesn't exist on this system's traffic profiles.
An unconditional sub approach guarantees a single pass and avoids performance degradation in the worst case, but it surrenders half the speedup on typical traffic (-20% vs -52%).
So, given our typical traffic profile, the search pre-check provides the biggest performance win. I am happy to switch to unconditional sub if we prefer prioritizing worst-case performance over common-case speed.
There was a problem hiding this comment.
If you move the unescaped special character check across the entire line to an outer conditional I think you can remove the same check in
_parse_arg():
match = self.SPECIAL_NO_WHITESPACE_RE.search(line, len(parts[0]))
if match:
raise KatcpSyntaxError("Un-escaped special %r." % (match.group(),))
if b"\\" not in line:
arguments = parts[1:]
else:
arguments = [self._parse_arg(x) for x in parts[1:]] # _parse_arg() doesn't need to do the unescaped check hereGood idea, and it almost works. After the initial whitespace split, the arguments genuinely cannot contain space/tab, so the check inside _parse_arg() is indeed redundant from parse()'s perspective.
The blocker is error precedence on doubly-malformed input: moving the check upfront changes which exception wins when a line contains multiple defects.
| Malformed line | master & current branch raise |
Outer-conditional structure raises |
|---|---|---|
?foo \x <ESC> |
Invalid escape character 'x'. |
Un-escaped special '\x1b'. |
?foo a\ <NUL>b |
Escape slash at end of argument. |
Un-escaped special '\x00'. |
The current structure (and master) reports whichever error is encountered first while processing arguments left to right, whereas checking the entire line upfront always reports unescaped special characters first.
Since this PR relies on strict comparison testing against master (verifying zero mismatches in exception types and messages across 600k generated inputs), this restructuring would fail that test suite.
I would prefer to keep this PR strictly backward-compatible to keep our tests passing. If we want to adopt the new error-ordering behavior (which is arguably cleaner), we can do that in a follow-up PR where updating the expected test baselines is the explicit goal.
There was a problem hiding this comment.
Most frequent traffic path (wide-gain / MT-7273): Messages have zero escaped args. The pre-check yields a -52% time reduction (0.88 ms vs 1.45 ms for unconditional sub).
Why do you consider this the most frequent traffic path? The benchmark scenario we've been using is not necessarily representative of all traffic that this library is used for.
There was a problem hiding this comment.
Since this PR relies on strict comparison testing against master (verifying zero mismatches in exception types and messages across 600k generated inputs), this restructuring would fail that test suite.
I would prefer to keep this PR strictly backward-compatible to keep our tests passing. If we want to adopt the new error-ordering behavior (which is arguably cleaner), we can do that in a follow-up PR where updating the expected test baselines is the explicit goal.
Which tests would fail? Existing tests in the repo or new tests you've added in this PR?
|
The benchmarking methodology used here is too specific to the ticket and targets the gain request. The most significant change in this PR is changing the parser to skip reinitialisation of message objects to skip revalidation of message strings that are already parsed. A simple benchmark that tests the parsing behaviour under different message types could probably be more meaningful than trying to emulate the gain requests in katcp servers/clients, since it is the parsing behaviour thats really under test. In my opinion the targeted benchmarks that emulate gain request flow should probably be removed from the PR. |
| if b"\\" not in arg: | ||
| return arg | ||
| if b"\\\\" not in arg: | ||
| # If there are no escaped backslashes (\\), escape sequences don't overlap. | ||
| # We can unescape using simple string replacement, which is much faster | ||
| # than running a regex with Python callback functions. | ||
| unescaped = arg | ||
| for sequence, char in self.ESCAPE_SEQUENCES: | ||
| unescaped = unescaped.replace(sequence, char) | ||
| if b"\\" not in unescaped: | ||
| return unescaped | ||
|
|
||
| # If a backslash remains, the escape sequence was invalid or incomplete. | ||
| # Fall back to regex parsing so we raise the expected error message. |
There was a problem hiding this comment.
I think this is optimising too much for very specific scenarios.
| # Request names for which the outgoing encode is timed, taken from the | ||
| # KATCP_LATENCY_LOG_REQUESTS environment variable as a comma-separated list | ||
| # (e.g. "wide-gain"). Empty, the default, disables the instrumentation. | ||
| # | ||
| # This complements the proxying intervals logged by katcore's DeviceHandler. | ||
| # Those cannot see this cost: callback_request() is @make_threadsafe, so off | ||
| # the ioloop thread it only enqueues and returns, leaving the encode to run | ||
| # here afterwards -- inside what that instrumentation reports as the device | ||
| # round trip. | ||
| LATENCY_LOG_REQUESTS = frozenset( | ||
| name.strip() | ||
| for name in os.environ.get("KATCP_LATENCY_LOG_REQUESTS", "").split(",") | ||
| if name.strip() | ||
| ) | ||
|
|
||
|
|
There was a problem hiding this comment.
This should be removed since it seems to be left over code from your benchmarking runs.
We don't typically configure environment variables in CAM environments so this has no place in this repo.
| """ | ||
| assert get_thread_ident() == self.ioloop_thread_id | ||
| data = bytes(msg) + b"\n" | ||
| if msg.name in LATENCY_LOG_REQUESTS: |
| import logging | ||
| import signal | ||
|
|
||
| import aiokatcp |
There was a problem hiding this comment.
I don't think this script belongs in this repo because it uses aiokatcp which is python3. This repo is py2/py3. Furthermore, it is a targeted benchmark for gains requests and not testing generic message parsing functionality of katcp-python. Should be stored in another repo.
|
@sipho-sarao would be good to include in your PR how you came to find out the parsing at the katcp level seems to be the bottleneck for gain requests to give a bit more context. It doesn't have to be detailed. Since there are many layers for gain request in the real system (katcorelib?->katproxy->katcore->katcp) I am curious to see how you managed to isolate the issue and the test methodology that was used, and it will provide future readers with context. |
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: