Skip to content

Measure what the lineage ids cost, and fix the tool that measures - #959

Merged
d-chambers merged 2 commits into
devfrom
processors-5
Aug 21, 2026
Merged

Measure what the lineage ids cost, and fix the tool that measures#959
d-chambers merged 2 commits into
devfrom
processors-5

Conversation

@d-chambers

@d-chambers d-chambers commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Description

PR 5 of the processors plan: the harness, the benchmarks, and the knob decision the 4a timing table was supposed to make. No behaviour changes — this is the measuring step, plus one live bug in the measuring tool.

The differential harness was blind to itself

scripts/differential_check.py proves a refactor changed no values by digesting every patch and comparing against a pre-change reference. Its digest excluded history and coords but not patch_id, which is minted per patch for anything not read from a file. Since the harness dumps in two processes, two identical patches digested differently:

identical example patches differ in: ['attrs']
  attr keys differing: ['patch_id']

Every patch would have reported a difference, which means the check would have said nothing at all — for exactly the PRs (0, 3, 4a, 5) the plan says to run it against. patch_id joins history in the exclusion.

processing_id deliberately does not. It is a digest of the route rather than a minted value, and it is identical in both processes (verified), so keeping it lets the harness catch a call which stopped being stamped or started canonicalizing its arguments differently — regressions the data hash alone would miss. 775 calls compared against origin/dev; all identical.

Benchmarks

TestIdentityOverhead in benchmarks/test_patch_benchmarks.py, on CodSpeed:

  • test_identity_overhead_tiny_patch — the charge on a call which does nothing else.
  • test_identity_overhead_uncached — the same call with the memo cleared, which is what a loop over varying arguments pays.
  • test_identity_overhead_array_argumentwhere with a big mask, the one place the charge is not flat, because an array parameter is hashed.
  • test_identity_overhead_real_workpass_filter, where the charge should not be findable.
  • test_processor_fingerprint — building an operation and asking what it is.

Each of the four overhead benchmarks has a _disabled twin. The control turns the ids off in a fixture, not inside the timed body: entering config_context builds and validates a whole config, which is not what a control should be measuring.

The plan also asked for test_provenance_graph_overhead. There is no graph — it was cut on 2026-08-20 — so that one simply does not exist.

The knob decision

Measured on this branch, best-of-7, ids on vs patch_provenance="disabled", with the config context outside the timed region:

call off on delta
tiny.transpose() (memo hit) 34.8 µs 88.3 µs +153%
tiny.transpose() (memo missed) 35.1 µs 112.1 µs +219%
tiny.abs() 55.2 µs 88.6 µs +61%
Patch(...) construction 15.4 µs 27.1 µs +76%
patch.abs() 244 µs 286 µs +17%
patch.where(big mask) 2250 µs 2991 µs +33%
patch.normalize("time") 3237 µs 3291 µs +2%
patch.pass_filter(time) 11.6 ms 11.7 ms +1%
patch.select(time=…) 86 µs 85 µs
spool.chunk(time=None) 32.8 ms 35.4 ms +8%

The charge is roughly 53 µs to canonicalize a call and look up its digest, and 77 µs when the digest actually has to be computed — fingerprint_call memoizes, so repeating one call is the cheap case and a loop over varying arguments is not. Either way it is invisible next to a pass filter (+1%) and more than doubles a transpose on a two-by-two patch. where is the exception to flatness: its array argument is hashed, so the charge scales with the mask and the memo cannot help.

So patch_provenance stays. The plan said to remove whichever knobs the table made unnecessary; the table says none of them are. select costing nothing is the no-op short-circuit from #943 handing the patch straight back.

Patch.new / update policy

The plan asked for a paragraph, and writing it turned up a footgun worth stating plainly: new, update and update_attrs carry both ids through unless you name one and set it yourself — they are how a patch function assembles its own result, so stamping there would count every operation twice. The consequence is that changing data through new yourself leaves the ids claiming the data did not change:

doubled = patch.new(data=patch.data * 2)
assert doubled.attrs.patch_id == patch.attrs.patch_id          # same data, it says
assert doubled.attrs.processing_id == patch.attrs.processing_id  # same route, it says

Documented in the patch tutorial under "What builds a patch, and what operates on one", and pinned by tests, because it is a policy rather than an accident. Building from arrays rather than from another patch is the honest case and mints a new patch_id.

A note on the red checks

This branch is merged up to dev, and dev is currently red for reasons unrelated to this PR. Verified by running origin/dev on its own in a detached worktree — identical counts and names:

  • tests/test_viz/test_inventory_viz.py — 61 errors
  • tests/test_viz/test_lanes.py::TestLayout::test_default_labels and TestColors::test_boolean_lane — 2 failures
  • the dascore/viz/_lanes.py::plot_lanes doctest

It looks like a crossed merge: #950 made label membership mean no value and now refuses value=True, while the lanes and inventory-viz work still constructs labels that way. Both were green on their own.

Excluding that one file, this branch is 12,247 passed. Nothing here touches viz.

Changelog

  • fixed: scripts/differential_check.py no longer reports a difference for every patch; patch_id is excluded from its digest, as history already was.
  • added: benchmarks for what maintaining the lineage ids costs, and for fingerprinting an operation.
  • added: the patch tutorial says what Patch.new and Patch.update do to the lineage ids, and what that means if you change data through them.

Checklist

I have:

  • filled in the Changelog section above (see docs/contributing/general_guidelines.qmd).

I have (if applicable):

  • referenced the GitHub issue this PR closes.
  • documented the new feature with docstrings and/or appropriate doc page.
  • included tests. See testing guidelines.
  • added the "ready_for_review" tag once the PR is ready to be reviewed.

The differential check proves a refactor changed no values by digesting
every patch and comparing against a reference. Its digest left out
history but not the two lineage ids, and `patch_id` is minted per patch
for anything not read from a file -- so every patch differed, and a check
which always reports a difference reports nothing. The ids join history,
for the same reason and a sharper one: the comparison is against a patch
built by other code, and these name where a patch came from rather than
what the answer is. 775 calls now compare identical against dev.

Benchmarks for the charge itself, at both ends: a tiny patch where it is
all there is, an array argument where it is not flat because the array is
hashed, and real filtering where it should not be findable.

The charge is flat, about 35-56 us to canonicalize a call and digest it.
That is invisible next to a pass filter (+3%) and doubles a transpose on
a two-by-two patch (+156%), so `patch_provenance` earns its keep and no
knob comes out.

Writing the promised `new`/`update` paragraph turned up a footgun worth
saying out loud: they carry both ids through untouched, because they are
how a patch function builds its own result and stamping there would count
every operation twice -- so changing data through `new` yourself leaves
the ids saying it did not change.
@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@d-chambers, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 29 minutes

Limit details: You’ve used all 2 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 79153878-1b9c-410e-9d6c-26a752daa856

📥 Commits

Reviewing files that changed from the base of the PR and between 747cad6 and c1a1e5d.

📒 Files selected for processing (4)
  • benchmarks/test_patch_benchmarks.py
  • docs/tutorial/patch.qmd
  • scripts/differential_check.py
  • tests/test_workflow/test_identity.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@d-chambers d-chambers added ready_for_review PR is ready for review documentation Improvements or additions to documentation labels Aug 21, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b53157201f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread benchmarks/test_patch_benchmarks.py Outdated
Comment on lines +467 to +468
with config_context(patch_provenance="disabled"):
tiny_patch.transpose()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude config setup from the disabled benchmark

When the enabled and disabled timings are compared, only this control includes constructing a new configuration and entering/resetting a ContextVar; CodSpeed times that work along with transpose(). The resulting delta therefore understates, and could even obscure, the provenance overhead the pair is intended to measure. Enter the configuration in fixture setup or otherwise outside the timed benchmark body.

Useful? React with 👍 / 👎.

Comment on lines +470 to +473
@pytest.mark.benchmark
def test_identity_overhead_array_argument(self, example_patch, big_mask):
"""An array parameter is hashed, which is the one real cost."""
example_patch.where(big_mask)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add a provenance-disabled control for the array case

For the large-mask scenario, this benchmark records only the total cost of where; without an otherwise identical patch_provenance="disabled" measurement, the result cannot distinguish the array fingerprinting cost from the underlying mask operation. Consequently it cannot measure or track the identity overhead this test was added for, so this case needs a paired disabled control.

Useful? React with 👍 / 👎.

Comment on lines +459 to +462
@pytest.mark.benchmark
def test_identity_overhead_tiny_patch(self, tiny_patch):
"""The flat charge, with nothing else in the way."""
tiny_patch.transpose()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stabilize the fingerprint cache before timing

When this benchmark runs after any code that has already made the same argument-free transpose() call, fingerprint_call uses the process-wide _FINGERPRINTS cache, whereas a standalone or differently ordered run may pay the initial binding and digest cost. Because that state change is comparable to the small overhead being measured, the recorded result can alternate between cold- and warm-cache behavior based on test order; warm the exact call or reset the cache in fixture setup so the intended state is explicit and outside the timed body.

Useful? React with 👍 / 👎.

Comment thread docs/tutorial/patch.qmd
Comment on lines +341 to +342
built = dc.Patch(data=np.asarray(patch.data), coords=patch.coords, dims=patch.dims)
assert built.attrs.patch_id != patch.attrs.patch_id

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve metadata in the fresh-patch example

When a user follows this example as the suggested alternative to patch.new(data=...), omitting attrs makes the constructor create default PatchAttrs, silently discarding the source patch's data_units, acquisition key, tag, and any custom metadata. The example should copy the non-lineage attributes while clearing the lineage fields before construction, or explicitly show that relevant metadata must be reapplied.

Useful? React with 👍 / 👎.

The control was measuring the wrong thing: entering `config_context`
builds and validates a whole config, and it was doing so inside the timed
body. It moves to a fixture. `fingerprint_call` also memoizes, so
repeating one call timed a lookup rather than a digest -- 53 us against
77 us on a two-by-two transpose, and a real loop varies its arguments and
pays the second. Both are timed now, and the array case gains the control
it was missing.

`processing_id` goes back into the differential digest. It is a digest of
the route rather than a minted value and is the same in both processes,
so keeping it catches a call which stopped being stamped or started
canonicalizing its arguments differently -- which the data hash cannot
see. Only `patch_id` is excluded. 775 calls still compare identical.

The policy test started from an unprocessed patch, so preserving the
route was satisfied by dropping it; it starts from an operated one. And
the prose said the ids are carried untouched, which is not true of
someone who names one and sets it.
@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (747cad6) to head (c1a1e5d).

Additional details and impacted files
@@            Coverage Diff            @@
##               dev      #959   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          195       195           
  Lines        25268     25268           
=========================================
  Hits         25268     25268           
Flag Coverage Δ
network 44.26% <ø> (ø)
unittests 100.00% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

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

@d-chambers d-chambers removed the documentation Improvements or additions to documentation label Aug 21, 2026
@d-chambers
d-chambers merged commit 003d56d into dev Aug 21, 2026
29 of 30 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready_for_review PR is ready for review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant