Build assertion messages only when the assertion fails - #121
Conversation
alex-ozdemir
left a comment
There was a problem hiding this comment.
Good find. It is too bad that Python doesn't have any call-by-name or laziness features, which could be used to eliminate this overhead.
I have just one request.
_assert takes an already-built message, so every caller that formats one pays for it whether or not the check passes. Two of those are on paths taken constantly: instance_check, reached from _sort and so from every sort(), and _higherorder_apply, reached from every function application. Formatting dominates both -- printing the operand costs far more than the comparison it decorates. Raise from the failing branch instead. Term building gets 2-4x faster: x.sort() 1.70 us -> 0.45 us f(x) 14.05 us -> 5.10 us x + y 10.08 us -> 3.33 us a[x] 7.39 us -> 3.61 us And(x==1, y==2) 26.90 us -> 13.54 us The messages and the exceptions raised are unchanged. Formatting the operand can also raise, which turns a passing assertion into an error: printing a term whose kind the printer has no case for fails, so the check reports the wrong problem, or invents one where there is none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
497cd01 to
d149479
Compare
|
Thanks — switched all four sites to I went with that over a One thing worth deciding separately: Re-verified after the change: the raised exception and message are byte-identical to Doctests, unit tests, black, and pyright (633, unchanged) all still pass. |
_asserttakes an already-built message, so every caller that formats one pays for it whether or not the check passes. Two of those sit on paths taken constantly:instance_check— reached from_sort, and so from everysort()call_higherorder_apply— reached from every function applicationIn both, building the message costs more than everything else the check does. Printing the operand is the expensive part:
"%s" % funcmeasures at ~3.3 µs, against ~0.2 µs for the comparison it decorates.Change
Move the formatting behind the failing branch, written the way the rest of the file already writes checks whose message is computed (
_assert(False, ...)under anif). Four sites in total; the other two are cold, but left inconsistent they invite the pattern back.The condition, the message text, and the exception type are all unchanged. I diffed the raised errors against
mainfor each of the four:Identical before and after.
Effect
Term building gets 2–4x faster. Three alternating runs of each, 20k iterations per measurement:
x.sort()f(x)x + yx == ya[x]And(x==1, y==2)(microseconds per call)
The two fixes compound: an application coerces each argument through
domain(i).cast(...), which goes through_sortand so throughinstance_checkas well.Correctness, not just speed
Formatting the operand can itself raise, which turns a passing assertion into an error. Printing a term whose kind the printer has no case for fails, so the check reports the wrong problem — or invents one where there is none. Building the message only on the failing path removes that class of bug from every one of these sites.
Testing
test_doc.py: 0 failures.test_unit.py: OK.black --check --required-version 24: clean.pyright: 633 errors, unchanged from main.mainfor all four sites, as above.🤖 Generated with Claude Code