Performance pass over the reader before final release #56
Closed
opened 2026-08-09 09:18:47 -04:00 by jared
·
3 comments
No Branch/Tag Specified
main
ci/mirror-canonical-tags
chore/release-47-badges-mirror-pr
docs/readme-perf-remeasure-56
feat/pagination-search-balances-57
feat/duckdb-threads-60
feat/cohort-predicates-58
fix/windows-backslash-paths
ci/mirror-to-github
ci/github-actions-matrix
feat/public-release-0.3.0
chore/fixture-sb203
ci/apt-https
fix/pushdown-pagination
feat/all-categories-37
fix/partial-coverage-signposting-9
fix/schema-v7
fix/cog-categories-balance-subtype
feat/cog-balances-25
feat/revenue-concepts-12
feat/expenditure-concepts-11
feat/coverage-disclosure-13
feat/complete-argument-18
fix/kodor-batch-14-15-16
fix/all-scoped-series-breaks-19
fix/regen-fixture-corpus-18
test/walkthrough-findings
feat/expenditure-concept
fix/3-url-trailing-slash
feat/phase-r3-signposting
fix/fixture-option-b-aggregates
feat/phase-r2-harmonization
feat/phase-r1-forward
feat/cog-gov-search-basket-mode
v0.4.0
Labels
Clear labels
kodor
kodor/feature-proposal
kodor/fix
kodor/needs-review
kodor/triaged
madison-walkthrough
severity/high
severity/low
severity/medium
south-guide
verdict/defect
verdict/definitional
kodor
kodor/feature-proposal
kodor/fix
kodor/needs-review
kodor/triaged
Kodor should process this issue
Kodor has written a feature proposal
Kodor should implement a fix (assigned to Kodor)
Kodor's work or failure needs Jared's review
Kodor has already triaged this issue (skip)
Surfaced while building the client-facing Southern API guide
needs
human
Cannot move without a person -- a decision, a check an agent cannot make, something outside the repo
origin
client
Came from a client ask
origin
obligation
Created by a change elsewhere
origin
review
Came from human review
origin
roborev
Promoted from a roborev finding
type
chore
Maintenance with no behaviour change
type
debt
Owed work -- docs, tests, cleanup a change obligated
type
decision
Needs a decision before work can proceed
type
defect
Something is wrong
type
feature
New capability
ws
api
Query verbs and results
ws
corpus
Corpus, mirror, provenance
ws
docs
Vignettes and guides
Assign a task to kodor
Kodor thinks this needs a feature.
Kodor should fix this
Kodor thinks the user is ready to review this.
Kodor is done with this issue.
No labels
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: Civilytics/uscogdata#56
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Gate on the final release. Tagging and r-universe registration (#47) wait for this.
Why now
The package is correct — 972 tests,
R CMD checkclean, 4/4 platforms green — but nothing has ever been profiled. The first public release sets expectations, and a reader that feels slow on a first query is the impression people keep.What is actually measured
Against the live corpus from a workstation on a good connection, 2026-08-08:
cog_spending(), 1 government × 1 yearcog_spending(), 1 government × 23 yearscog_revenue(), 1 government × 23 yearscog_spending(), per-capita + real dollars, 23 yearsThe gap between a raw scan and a verb is the target: roughly 2–3 s of overhead above the data access itself, on every call. The variance is also unexplained — revenue over 23 years is faster than spending over 1 year, which suggests the cost is not dominated by I/O.
These numbers are in the README's "two ways to read the corpus" table, so anything that moves materially means updating that table.
Candidate hot spots, unverified
Guesses to be confirmed or killed by profiling, not treated as a work list:
cog_open()fetches the manifest and registers 23 SQL views on first use. Paid once per session, but it lands on the user's first query.longview's SQL. Correct, but it is a large string and DuckDB re-plans it per query. Worth checking whether a year predicate prunes before the file list is opened, or after.canonical_fips_xwalkandsummary_categoriesjoin on every verb.Approach
profvisagainst both a local mirror and the remote corpus — the split matters, because remote latency and local compute need different fixes.Related
cog-api), since it adds an HTTP layer and its own caching over these same verbsEvidence from the cog-api side: the overhead is remote I/O, not compute
While doing a performance pass on
cog-api(which is a thin wrapper over these verbs), Imeasured the same call shapes against a local corpus mirror. The comparison answers
the "profile against both a local mirror and the remote corpus — the split matters"
question in the Approach section above, and I think it reframes this issue.
Measured 2026-08-09 on
efron(16 cores), full production corpus (schema v7, 56partitions, 46.1M rows) bind-mounted locally. These are end-to-end HTTP requests
through cog-api — so each number includes plumber dispatch, parameter validation, the
verb call, and JSON envelope construction. The verb itself is therefore faster than
what is shown:
cog_spending(), 1 government × 1 yearcog_spending(), 1 government × 5 yearscog_spending(), 1 government × all 56 yearsRoughly 50–60x faster on a local mirror, and the shape of the remaining cost is
clean: about 50 ms fixed plus ~5 ms per year partition scanned.
Conclusion: the 2–3 s of "overhead above data access" is dominated by remote HTTP
round-trips, not by the verb pipeline. Session setup, provenance assembly, crosswalk
joins and the suggestion machinery are all real costs, but on a local corpus they sum to
tens of milliseconds, not seconds. I would not spend the release gate on micro-optimising
the R pipeline; the leverage is in how many HTTP range requests a query makes against the
remote corpus.
That suggests reframing the candidate list toward:
longview, howmany separate HTTP range requests does a single-year query actually issue, and does the
year predicate prune before the file list is opened? On a local FS this is invisible;
over HTTPS it is the whole cost.
cog_mirror()already exists. If a local mirroris 50x faster, the README's "two ways to read the corpus" table arguably should say so
outright — that is a bigger, cheaper win for users than anything in the R code.
R/cache.Ris still a stub deferring this. On the remotepath it is likely the single highest-leverage change.
A separate finding: sort
canonical_govidwithin each year partitionNot an
uscogdatachange — this is a corpus-layout change for whichever pipeline writesthe parquet — but it showed up clearly in the same measurements and is worth recording
where the performance discussion lives.
A single-government query has to scan every year partition, because nothing tells DuckDB
where that govid lives. If rows were sorted by
canonical_govidwithin each yearpartition, parquet row-group statistics (min/max per row group) would let DuckDB skip
almost every row group for a single-govid predicate. The per-partition cost would fall
from "decompress and filter the partition" to "read the footer, open one or two row
groups."
That is the largest remaining structural win I can identify for
/profileandper-government
/spending, which are the slowest routes in the API and the ones that donot benefit from adding server replicas (measured: 1.27x from a second replica, versus
1.90x for bounded-work endpoints — they are resource-bound on the scan, not
concurrency-bound).
Context
Full write-up, method and raw numbers:
docs/benchmarks/inCivilytics/cog-api(2026-08-baseline.md,2026-08-phase3-replicas.md,2026-08-final.md), reproducible withscripts/bench.shin that repo.
Closing. Against this issue's own four-step Approach.
1. Profile against both a local mirror and the remote corpus
Done, and it overturned this issue's framing. The "2–3 s of overhead above the data
access itself" is not the verb pipeline — it is remote HTTP round-trips. The same
cog_spending()call measures 66 ms against a local mirror and 3.9 s remote, ~50–60x.Session setup, provenance assembly, crosswalk joins and the suggestion machinery are all
real costs, and on a local corpus they sum to tens of milliseconds.
The candidate list in this issue was therefore mostly a list of things not worth fixing.
That is a good outcome for a profiling pass — it is what stopped the release gate being
spent on micro-optimising R.
Re-measured again 2026-08-10 against the current corpus (
pipeline_commit 3d28ddd), freshsession per arm:
2. Separate per-session from per-query cost
Done, and it produced the finding this issue guessed at but understated. Session open is
the single largest remote cost (~7.5 s) — larger than any individual query, and it lands
on the user's first query rather than on
library(). The old README table accounted for itnowhere, so every per-query number it printed was quietly missing it.
This issue anticipated that "if most of the 3.9 s is session warm-up, the fix is
documentation and a warm-up helper." It is a large share, but no warm-up helper was
built, deliberately:
cog_open()stays unexported, because a helper would only let auser move the 7.5 s earlier, not remove it. Mirroring removes it. Documentation was the
right half of that prediction; the helper was not. Recording the reasoning so it reads as
decided rather than forgotten.
3. Fix what profiling actually implicates
.coverage_table()7 ms against 1442 ms insidecog_spending(). Pagination cannot help either —GROUP BYcompletes beforeLIMIT, and both coverage computations need the full result.cog_revenue(category = "Corrections")offers expenditure recipes — which profiling leaves untouched. Retitled so it is not re-triaged as perf and dropped..build_suggestions()cog_gov_search()'sORDER BY, which was not a total order and made a paged sweep unsound.getFromNamespace(".ensure_session", …)reach into package internals — a consumer depending on a private name, which should not be frozen at a public release.cog_pipeline#93and already published (manifestpipeline_commit 3d28ddd, built 2026-08-09T20:48Z). Worth noting the finding evolved: the corpus turned out to be already sorted bycanonical_govid, so the sort was a no-op and single-row-group partitions were the whole problem.4. Re-measure and update the README table
Done — PR #65. Three things the old table could not express: the mirror is 60–80x faster
(previously written as
local speed, no number); session open is the largest remote cost;and the remote cost is round-trips rather than scanning. Verified by running the arms in
both orders — a full-history query costs ~7 s whether it runs first or last, while a
one-year query drops from ~4 s to ~1.5 s once its partitions have been touched.
That last point is worth stating plainly:
cog_pipeline#93's 1.4–1.7x does not appearon the remote path. It was measured through cog-api against a local mount, where scan
time dominates; over HTTPS, network latency swamps it. The win is real and it is why the
mirrored column is now so fast — it just is not a remote win.
Corpus size corrected to ~201 MB (row-group chunking added ~3.4%, and
190.6wasambiguous between MB and MiB). Also documented
HTTP 429: a burst of remote queries getsrate-limited by the host, which I hit while taking these measurements.
Carried forward, not lost
#64 — partition-level caching. My comment above called
R/cache.R"likely the singlehighest-leverage change" on the remote path and then deferred it, and it existed nowhere
else. Now filed with the measurements attached, including the honest option that the right
answer may be "no new cache; make mirroring the documented default," which the README
change already half-does.
maxwell capacity. All benchmarks ran on
efron(16 cores). maxwell is 8 with 4budgeted for the API, so production numbers are lower —
scripts/bench.shshould be runthere before capacity is quoted publicly. Tracked on the cog-api side, not here.
The gate
This was the blocker on #47. It is cleared — with the note that #47's title said v0.3.0
while
mainis already 0.4.0; corrected there.Method and raw numbers:
docs/benchmarks/inCivilytics/cog-api(
2026-08-baseline.md,2026-08-phase3-replicas.md,2026-08-phase4-bulk.md,2026-08-final.md), reproducible viascripts/bench.sh; plus the 2026-08-10 re-measurerecorded in PR #65.
Closed. All four Approach steps done, everything merged.
mainis atd2caa6dwith all three PRs in:cog_open()honours a DuckDB thread and memory budgetlimit/offsetoncog_gov_search()andcog_balances()They stacked and collided on the
NEWS.md0.4.0 anchor. Resolved by rebasing each ontothe previous merge; the collision was more than textual — each branch had appended its own
## Fixesheading, so a naive merge would have shipped 0.4.0 with two of them. Folded intoone, and reordered the release's sections by user impact rather than merge order (cohorts,
pagination, DuckDB budget, docs, Fixes) — the headline 4.8x change had ended up fourth,
below a config knob. Content byte-identical, verified by diffing sorted non-blank lines.
Verified on merged
main: suite 1084 passed, 0 failed, 0 warnings (2 pre-existingtest-live-corpus.Rskips);R CMD check --as-cran0 errors, 0 warnings, 1 NOTE — thepre-existing one, whose r-universe 404 is #47 itself and clears on registration.
Final state of everything this issue touched
the defect is
cog_revenue()offering expenditure recipes, which profiling leavesuntouched. Retitled so it is not re-triaged as perf and dropped
remaining leverage and which existed nowhere else
pipeline_commit 3d28ddd)mainhad moved past 0.3.0The one thing worth carrying forward
Every benchmark ran on
efron(16 cores). maxwell is 8 with 4 budgeted for the API, soproduction numbers are lower — run
scripts/bench.shthere before quoting capacitypublicly. Not a blocker for the tag.
#47 is unblocked.