perf(apply): retire per-pixel guards Begin() already establishes, and record what the review measured - #2307
Merged
Merged
Conversation
maxderhak
requested review from
ChrisCoxArt,
colourbill-ctrl,
dwtza and
xsscx
as code owners
August 25, 2026 18:50
…ter guards Three changes to CIccCalculatorFunc::ApplySequence, the tightest loop in the library: - The debugger hook is read once into a local. It is a file-static pointer, so the compiler otherwise had to reload it after every Exec() that might have stored to it -- twice per opcode per pixel. It cannot legitimately change mid-apply; SetDebugger is a setup call. - The "nOps > 0x80000000UL" guard is removed. Read() rejects m_nOps at or above MAX_CALC_ELEMENTS (65536) and sizes m_Op[] to the accepted count, and every recursive call passes a sub-range of that, so the bound was unreachable by four orders of magnitude. It also fired once per recursion rather than once per apply, so nested conditionals multiplied it. - The per-opcode program-counter increment goes from icCalcAddUInt32 to a plain add, for the same reason: os.idx is bounded by nOps. MEASURED: nothing. mpe-calc, the case dominated by this loop, read -1.4%; other cases scattered from -6.9% to +8.7% including ones with no Calc element at all. All nine checksums identical, 102/102 CTest. That result is worth more than the change. Attribution shows the first Mpe is 98% of the mpe-calc chain, and the profile's calculator has ZERO branch ops -- so the if/select offset re-validation that the audit called the highest-value target in tier B has nothing to act on here. Its sub-elements are CurveSets of FormulaSegments with exponents of 1.0 and 2.4, so CIccFormulaCurveSegment::Apply calls pow() per channel per pixel. At 0.14 Mpx/s the cost is irreducible transcendental math, not interpreter overhead, which is also why the CLUT clamp work in the previous branch moved mpe-calc 0.0%. Tier B's remaining items are therefore not pursued: B1 has no branch ops to validate, and B5's operand-stack traffic is noise beside pow(). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six of the ten tier-A findings. Each removed check either had its condition decided by Begin() or was unreachable; every removal carries a comment saying which, so the argument can be re-derived rather than taken on trust. A6, CIccTagCurve::Apply -- the hottest function in the library. The two degenerate table sizes are now tested before nIndex is computed rather than after, so a gamma or empty curve no longer pays a multiply and a cast it discards, and the single-entry gamma is decoded once in Begin() instead of being rebuilt from m_Curve[0] on every call before going to pow(). Begin() moves out of line to do that. A4, three sampled-curve Applys. The isfinite(pos) guard is unreachable: v is already clamped into the sampled range and each class's Begin() refuses a zero span, so pos cannot be non-finite. The range clamps stay -- they bound the index. A7, CIccToneMapFunc::Apply. Begin() returns false unless the function type is 0 and m_params is non-null with enough entries, so the per-channel re-test of both is redundant. A8, the two CAM elements. The channel-count check MOVES to CIccMpeCAM::Begin() rather than simply going away -- Begin() previously refused only a null m_pCAM, so deleting the Apply-side test without adding it there would have been wrong. A9, the two spectral emission matrices. Begin() returns false when the matrix could not be allocated, and CIccTagMultiProcessElement::Begin() propagates any element failure, so the null test and its zero-fill else branch were dead. A5, Interp2d's offset ceiling, now computed in CIccCLUT::Begin() from values fixed by then instead of two multiplies and two subtractions per pixel. A3, CIccXformNDLut. The Begin() bound tightens from 256 to 16 and gains an output check, which lets both per-pixel min-with-16 clamps go. This is also a correctness fix: Apply() copies into a fixed Pixel[16], so the old 256 bound admitted tags it could not apply and then silently truncated the transform. Rejecting them reports icCmmStatInvalidLut instead. Nothing in the corpus is affected -- the 11-, 17- and 18-channel profiles all resolve to Mpe xforms -- and CIccXformNDLut appears unreachable from the corpus entirely, so the clamp removal is unmeasured and the correctness change untested. A1 and A2 are NOT done. Both are bounds duplicated inside Interp3dTetra and InterpND that CIccCLUT::Begin() already asserts, but Begin() returns void and cannot refuse; widening it to bool means touching ten call sites of a public API. Tier C showed single comparisons against a member measure nothing, so that is a poor trade. Recorded in the results doc instead. MEASURED, two runs of best-of-8 interleaved A/B: monochrome +6.2% .. +16.6% single-entry gamma curve mono-lab +3.1% .. +9.9% same, plus a CLUT matrix-trc +3.6% .. +4.4% TRC curves others within noise The wins land exactly on the curve-dominated cases, which is what A6 predicts: those fixtures use curveType with one u8Fixed8 entry, so they took the gamma path that was re-decoding and discarding work on every call. All nine checksums identical; 102/102 CTest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CIccXform3DLut::Apply zero-filled its scratch pixel above channel 3 on every pixel, under a comment reading "just in case". It is only load-bearing when nothing downstream writes those channels -- and with a CLUT present, Interp3d and Interp3dTetra write all m_nOutput of them on every path, so for the common case it was dead stores. For a CMYK output profile that is four discarded writes per pixel. Begin() now decides once whether the fill is needed, from whether the tag has a CLUT at all, and Apply() tests one bool. The fill itself is unchanged for the case that still needs it, so a tag with curves and a matrix but no CLUT behaves exactly as before. Introduced by a3a833a ("Fix: Assignments & Initializations", #254). All nine checksums identical; 102/102 CTest. No separate measurement: it landed in the same batch as the other tier-A items and is not individually resolvable above the noise floor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This branch is the one that answers what the review set out to ask: did the stability and vulnerability work cost measurable throughput? Almost none of it did. The guards the hardening commits added to per-pixel code are individually a compare against a member or a null test, and on this hardware those do not show up at all. The wins on the branch come from two things that are not guards: a gamma decode being redone on every call to the library's hottest function, and a zero-fill that was dead whenever a CLUT was present. Measured, two runs of best-of-8 interleaved A/B: monochrome +6.2%/+16.6%, mono-lab +3.1%/+9.9%, matrix-trc +3.6%/+4.4%, everything else within noise. The wins land exactly on the curve-dominated cases, which is what the CIccTagCurve::Apply change predicts. Records why A1, A2, B1 and B5 were not done. A1/A2 need CIccCLUT::Begin() to return bool -- ten call sites of a public API -- to retire two comparisons that tier C already showed measure nothing. B1 has no branch ops to validate in the only Calc profile available, and attribution shows mpe-calc is pow-bound rather than interpreter-bound: the first Mpe is 98% of the chain, its sub-elements are FormulaSegments with exponents 1.0 and 2.4, and that also explains why the CLUT work in the previous branch moved it 0.0%. The audit called tier B the highest-value target in the review; it was wrong, for the measurable reason that it counted checked-arithmetic calls without asking what share of runtime they were. Also records a process failure worth not repeating: adding a member to CIccCLUT changes sizeof, and building only the benchmark target left sixty-odd regression binaries compiled against the old header but linked against the new DLL. That produced 16 failures, mostly SEGFAULTs plus one heap corruption, across unrelated tests, and a bisect that wrongly blamed A3. A full rebuild gave 102/102 on identical source. The failure mode invents failures and can equally hide real ones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two drafts of this section were wrong in opposite directions. The first called the per-pixel bounds in InterpND/Interp3dTetra "duplicated from Begin()"; the second called them "the only guard that works". Both were reasoning about reachability from the call chain rather than measuring it. Probing the library (in fix/clut-channel-count-validation) showed neither was right. m_nInput > 16 never reached a CLUT through any read path: both element readers follow the discarded Init() with a GetData(0) NULL check, Init() leaves m_pData NULL when it refuses, and GetData(0) is &m_pData[0]. Declared counts of 17, 255, 256 and 272 all made Read() return false. So the per-pixel guards were unreachable from a malformed profile -- neither duplicates nor load-bearing. The reachable defect was the narrowing cast's truncation rather than its overflow, and Init() had six of ten call sites dropping its return, not six of eight. Both are now fixed on that branch, along with the XML parser route the original analysis did not consider at all. With the invariant established once at Begin() on every construction path, the per-pixel guards are now genuinely retireable -- though tier C measured them at nothing, so there is no performance argument for doing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
xsscx
force-pushed
the
perf/hoist-hardening-guards-v2
branch
from
August 25, 2026 18:53
9195c2c to
77e0b14
Compare
xsscx
enabled auto-merge (squash)
August 25, 2026 18:53
xsscx
disabled auto-merge
August 25, 2026 19:01
xsscx
force-pushed
the
perf/hoist-hardening-guards-v2
branch
from
August 25, 2026 19:08
37aa234 to
74ab5f6
Compare
xsscx
enabled auto-merge (squash)
August 25, 2026 19:10
Contributor
There was a problem hiding this comment.
Pull request overview
This PR optimizes iccDEV’s apply-path hot loops by hoisting invariants into Begin() and removing per-pixel guards that are asserted to be unreachable once setup (Begin()) has succeeded. It also adds a results write-up documenting the measured throughput impact of the hardening-era checks and the performance changes made here.
Changes:
- Hoist per-call/per-pixel recomputation into
Begin()(e.g., curve gamma decode, CLUT 2D offset ceiling) and remove redundant guards in severalApply()paths. - Tighten
CIccXformNDLut::Begin()channel-count validation to match fixed scratch storage, removing per-pixel clamps and turning silent truncation into a setup-time failure. - Add a tier A/B performance results document; bump OpenSSL-related pinned package versions in the container.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| IccProfLib/IccTagLut.h | Adds CIccTagCurve::Begin() declaration and caches single-entry gamma; adds CIccCLUT cached ceiling for 2D interpolation. |
| IccProfLib/IccTagLut.cpp | Implements CIccTagCurve::Begin() gamma precompute; computes cached CLUT 2D ceiling during CIccCLUT::Begin(). |
| IccProfLib/IccMpeSpectral.cpp | Removes null guards in emission matrix Apply() based on Begin()-established invariants. |
| IccProfLib/IccMpeCalc.cpp | Reduces calculator interpreter overhead by caching debugger hook and removing unreachable checked arithmetic in the opcode loop. |
| IccProfLib/IccMpeBasic.cpp | Removes isfinite(pos) guards from sampled-curve applies based on assumptions about clamping and Begin() preconditions. |
| IccProfLib/IccCmm.h | Adds CIccXform3DLut flag to conditionally skip dead scratch zero-fill when CLUT is present. |
| IccProfLib/IccCmm.cpp | Gates scratch zero-fill in CIccXform3DLut::Apply(); tightens CIccXformNDLut::Begin() bounds to 16 and removes per-pixel clamps. |
| docs/superpowers/results/2026-08-20-tier-ab-results.md | New document recording tier A/B measurements and methodology/process notes. |
| Dockerfile | Bumps pinned libssl-dev and openssl-provider-legacy package versions. |
Suppressed comments (3)
IccProfLib/IccMpeBasic.cpp:1229
poscan still become NaN/Inf on malformed tags even ifvis clamped, becausem_range(derived from untrusted profile endpoints) is not validated as finite inBegin()(it only checksm_endPoint-m_startPoint == 0.0). With the!std::isfinite(pos)guard removed,posmay be non-finite and then gets cast toicUInt32Number, which is undefined behavior and can lead to out-of-bounds reads fromm_pSamples.
This issue also appears in the following locations of the same file:
- line 1849
- line 2495
// No isfinite guard: v is clamped into [m_startPoint, m_endPoint] above, and
// Begin() refuses a zero span (m_endPoint-m_startPoint == 0.0 returns false),
// so pos cannot be non-finite. The clamps stay -- they bound the index.
icFloatNumber pos = (v-m_startPoint)/m_range * m_last;
if (pos<0.0f)
pos=0.0f;
else if (pos>m_last)
pos=m_last;
icUInt32Number index = static_cast<icUInt32Number>(pos);
icFloatNumber remainder = pos - (icFloatNumber)index;
IccProfLib/IccMpeBasic.cpp:1858
posis cast toicUInt32Numbera few lines below; without the removed!std::isfinite(pos)guard, malformed curve parameters (e.g., non-finitem_firstEntry/m_lastEntryor NaNm_range) can produce NaN/Infposand trigger undefined behavior on the cast and subsequent sample indexing.
// No isfinite guard: v is inside [m_firstEntry, m_lastEntry] on this path, and
// Begin() refuses m_range == 0 and m_last == 0, so pos cannot be non-finite.
icFloatNumber pos = (v-m_firstEntry)/m_range * m_last;
if (pos<0.0f)
pos=0.0f;
else if (pos>m_last)
pos=m_last;
icUInt32Number index = static_cast<icUInt32Number>(pos);
icFloatNumber remainder = pos - (icFloatNumber)index;
IccProfLib/IccMpeBasic.cpp:2504
- Even though
vis forced finite,poscan still be non-finite ifm_rangeis NaN/Inf due to malformed endpoints (untrusted input). Removing the!std::isfinite(pos)guard meansposmay be NaN/Inf and then gets cast toicUInt32Number, which is undefined behavior and can lead to out-of-bounds reads fromm_pSamples.
// No isfinite guard: same argument as CIccSingleSampledCurve::Apply -- v is
// inside the sampled range here and Begin() refuses a zero range.
icFloatNumber pos = (v - m_firstEntry) / m_range * m_last;
if (pos<0.0f)
pos=0.0f;
else if (pos>m_last)
pos=m_last;
icUInt32Number index = static_cast<icUInt32Number>(pos);
icFloatNumber remainder = pos - (icFloatNumber)index;
Comment on lines
+2610
to
+2613
|
|
||
| // Ceiling for Interp2d's offset clamp; see there. Depends only on values | ||
| // fixed by this point, so Interp2d no longer recomputes it per pixel. | ||
| m_nMaxDataOffset2d = (int)NumPoints()*(int)m_nOutput - ((int)m_nOutput + (int)n011); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The tier-A/B half of the apply-path performance review, plus the results document that answers the question the review was set up to ask: did the stability and vulnerability hardening cost measurable throughput?
Almost none of it did. The guards the hardening commits added to per-pixel code are individually a compare against a member or a null test, and on this hardware they do not show up at all. The wins on this branch come from two things that are not guards — a gamma decode being redone on every call to the library's hottest function, and a zero-fill that was dead whenever a CLUT was present.
Measured
Two runs of best-of-8 interleaved A/B, re-verified after rebasing onto a master roughly forty commits newer:
monochromemono-labmatrix-trcThe re-measurement matters because
iccBenchApply's profile-root resolution was fixed in #2256 during the interval, which had made the benchmark vacuous on Windows — the platform all these numbers were taken on. It did not invalidate them: the same three cases move, in the same direction, by comparable amounts, and every checksum is identical to the master reference.The wins land exactly on the curve-dominated cases, which is what the
CIccTagCurve::Applychange predicts — those fixtures usecurveTypewith a single u8Fixed8 entry, so every call took the gamma path that was re-decodingm_Curve[0]and computing annIndexit then discarded.What changed
CIccTagCurve::Apply— the hottest function in the library. Degenerate table sizes are tested beforenIndexis computed rather than after, and the single-entry gamma is decoded once inBegin()instead of being rebuilt fromm_Curve[0]on every call before going topow().Applys: theisfinite(pos)guard is unreachable —vis already clamped into the sampled range and each class'sBegin()refuses a zero span. The range clamps stay; they bound the index.CIccToneMapFunc::Apply:Begin()already refuses unless the function type is 0 with enough params.CIccMpeCAM::Begin()rather than going away —Begin()previously refused only a nullm_pCAM.Begin()returns false when the matrix could not be allocated andCIccTagMultiProcessElement::Begin()propagates that, so the null test and its zero-fill else branch were dead.Interp2d's offset ceiling, computed inCIccCLUT::Begin()from values fixed by then instead of two multiplies and two subtractions per pixel.CIccXformNDLut: theBegin()bound tightens from 256 to 16 and gains an output check, which lets both per-pixel min-with-16 clamps go. Also a correctness fix —Apply()copies into a fixedPixel[16], so the old 256 bound admitted tags it could not apply and then silently truncated the transform.Exec), and two unreachable arithmetic guards are gone.Every removal carries a comment saying whether its condition was decided by
Begin()or was unreachable, so the argument can be re-derived rather than taken on trust.What was deliberately not done, and why
mpe-calcmoved −1.4% — nothing, on the case wholly dominated by that loop. Attribution explains it: the firstMpeis 98% of the chain, that calculator has zero branch ops (so B1 has nothing to act on), and its sub-elements are CurveSets of FormulaSegments with exponents 1.0 and 2.4 — sopow()per channel per pixel. At 0.14 Mpx/s the cost is irreducible transcendental math, not interpreter overhead. That also explains why the previous branch's CLUT work movedmpe-calc0.0%. The audit called tier B the highest-value target in the review. That was wrong, and wrong for a measurable reason: it counted checked-arithmetic call sites without asking what share of runtime they were.InterpNDandInterp3dTetra. These needCIccCLUT::Begin()to be able to refuse, and it returnedvoid. That prerequisite is supplied by fix: bound CLUT channel counts at every parser, and let Begin() refuse #2306, which also retires them — as a correctness change rather than a performance one, since tier C measured them at nothing and one of the two turned out to be a defect.The results document
docs/superpowers/results/2026-08-20-tier-ab-results.mdrecords the above, and also two things worth not repeating.A reasoning failure. Two drafts of the A1/A2 section were wrong in opposite directions — the bounds were called "duplicated from
Begin()", then "the only guard that works". Both were reasoning about reachability from the call chain rather than measuring it, and neither was true. Probing the library (during #2306) showedm_nInput > 16never reached a CLUT through any read path: the element readers turn a failedInit()into a failedRead()via theirGetData(0)NULL checks. The guards were unreachable, not load-bearing. The final commit here corrects that section with the measured evidence.A process failure. Adding a member to
CIccCLUTchangessizeof, and building only the benchmark target left sixty-odd regression binaries compiled against the old header but linked against the new DLL. That produced 16 failures — mostly SEGFAULTs plus one heap corruption — across unrelated tests, and a bisect that wrongly blamed A3. A full rebuild gave a clean suite on identical source. The failure mode invents failures and can equally hide real ones.Verification
ctest --label-exclude known-red: 129/129, from a clean worktree on current master. All nine benchmark checksums identical to the master reference.Merge order
Conflicts with #2306 in
IccTagLut.{cpp,h},IccCmm.cpp,IccMpeBasic.cppandIccMpeSpectral.cpp. #2306 should land first — it makesCIccCLUT::Begin()returnbool, which is the prerequisite this branch recorded as blocking A1/A2. Rebasing this branch afterwards resolves those hunks toward the version #2306 establishes, and lets the A1/A2 deferral be dropped rather than carried.🤖 Generated with Claude Code