Skip to content

Commit 4d058fe

Browse files
igerberclaude
andcommitted
perf(linalg): opt-in solve_ols normal-equations Cholesky fast path (DIFF_DIFF_SOLVE_OLS_FASTPATH)
solve_ols is the universal OLS entry point; both backends run an equilibrated thin SVD (gelsd-parity) unconditionally. After #634's stage-0 certification removed the pivoted-QR cost, the SVD solve itself is the dominant stage on solver-bound fits (58%/54%/40% of SunAbraham county/firm and CS dr cov40). Setting DIFF_DIFF_SOLVE_OLS_FASTPATH=1 (positive integer; per-call resolver mirroring DIFF_DIFF_DEMEAN_CHUNK_COLS) routes certified-well-conditioned full-rank solves through an equilibrated normal-equations Cholesky: - numpy twin: reuses the stage-0 rank-certification artifacts (the Gram is already built to certify rank), gated by cho_factor + LAPACK dpocon at reciprocal condition > 1e-6 (the _IRLS_CHOL_RCOND_GUARD budget: forward error ~eps*cond <= 2e-10); self-builds and self-certifies on the skip_rank_check route (factorization success alone is not a certificate). - Rust kernel solve_ols_chol: SEPARATE self-certifying pyfunction (a stale extension degrades via the independent-import pattern instead of a call-time TypeError): faer Llt with the exact 1-norm rcond whose inverse byproduct doubles as the sandwich bread; ONE owned n x k buffer (the equilibrated copy, reused in place for the vcov scores - no thin-SVD U transient); faer-only heavy ops, so the accelerate/openblas/no-BLAS wheel variants behave identically; shared first-appearance cluster indexer with the SVD path. - Fallback contract: any certification decline falls through the UNCHANGED legacy chain (Rust SVD, then gelsd) on both dispatch routes, so knob-on-decline output equals knob-off exactly; solve_ols gains an optional diagnostics_out sink recording which branch ran. Measured (M4 Max, medians; committed A/B lane with a pristine-base-tree identity gate): SunAbraham 1.13->0.63 s and 16.77->9.89 s (1.7-1.8x), CallawaySantAnna dr 40-cov 4.40->3.57 s (1.23x), skip_rank_check micro 1.56x, weighted/survey twin 1.07x; estimate deltas <= 9e-15 ATT / 3e-12 rel SE; rust allocator high-water on the 2.4Mx130 clustered solve 7.27 -> 2.66 GB. Knob-off byte-identical to the base tree on every benchmark scenario. Also fixed (found while validating the non-finite-inference contract): the legacy Rust SVD vcov path silently returned all-Inf vcov on saturated full-rank designs (n == k) - the (n-k) adjustment divides by zero and the dispatcher fallback check keyed on NaN only. Both Rust vcov paths now return the documented all-NaN sentinel (matching the canonical numpy saturated guard, G >= 2 cluster error keeping precedence), and the dispatcher rejects any non-finite Rust vcov via a shared helper - on the skip_rank_check route only Inf reroutes (all-NaN remains the documented sentinel there; rerouting it to a full-rank-assuming numpy path could raise on a singular bread). CHANGELOG Fixed entry; sole qualified exception to the byte-identity claim. Tests: 57 cargo (LU oracles for beta/hc1/CR1, decline contracts, saturated NaN, G<2 precedence) + resolver/fast-path/dispatch/kernel/saturated pytest classes across both backends (property parity on ~20 designs, forced and natural-guard-trip byte-identity, BETWEEN fixture with self-check, default-path spy locks, WLS incl. zero weights, hc2/conley spot checks, 20-call clustered bit-determinism, stale-symbol degradation, Inf-injection reroutes on both routes, NaN-sentinel pass-through). Docs: CHANGELOG Added+Fixed, REGISTRY Note (thresholds, verbatim-fallback contract, tol-bounded-not-bit opt-in parity), performance-plan section with attribution/results/memory tables, doc-deps notes, TODO row swapped to the default-on-flip follow-up + cross-refs on the parked memory-floor and QR-reuse rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GPX5Rv8ozQXPdUV23QTfjr
1 parent fd3cc0d commit 4d058fe

14 files changed

Lines changed: 2387 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
222222
the same dispatch globals the fit consumes (`bootstrap_utils` / `linalg`), with a coherence
223223
assert between the two (defense in depth).
224224

225+
### Fixed
226+
- **Rust backend: saturated full-rank designs (`n == k`) no longer leak `Inf`
227+
variance-covariance silently.** `solve_ols`'s Rust HC1/CR1 path computed the
228+
`n/(n-k)` (clustered `(n-1)/(n-k)`) adjustment with zero residual degrees of
229+
freedom, producing all-`Inf` vcov that bypassed the dispatcher's Python-fallback
230+
check (which keyed on NaN only) and reached users without a warning. The Rust
231+
kernel now returns the documented non-finite-inference contract — an all-NaN
232+
vcov, matching the canonical numpy saturated guard — with the `G >= 2` cluster
233+
error keeping precedence, and the dispatcher's fallback check now rejects any
234+
non-finite vcov (NaN or Inf). Point estimates and residuals are unchanged;
235+
only the undefined-inference sentinel changes (`Inf` → `NaN` + warning).
236+
Found while building the opt-in Cholesky fast path below, which honors the
237+
same contract.
238+
225239
### Added
226240
- **`SyntheticControl` conformal extensions: one-sided alternatives + covariates in the
227241
proxy (Chernozhukov-Wüthrich-Zhu 2021).** `conformal_test` / `conformal_confidence_intervals`
@@ -236,6 +250,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
236250
T*-block structure. Locked by a hand-rolled signed-statistic permutation oracle, directional
237251
rejection/half-line/composition tests, a proxy-weight mechanism check, and a permutation-floor
238252
invariant; all 31 pre-existing conformal tests pass unchanged.
253+
- **Opt-in `DIFF_DIFF_SOLVE_OLS_FASTPATH` normal-equations Cholesky fast path for
254+
`solve_ols` (both backends).** Set the env var to `1` (a positive integer; boolean
255+
spellings like `true`/`on` are silently ignored per the resolver convention, mirroring
256+
`DIFF_DIFF_DEMEAN_CHUNK_COLS`) to route certified-well-conditioned full-rank OLS solves
257+
through an equilibrated normal-equations Cholesky instead of the default SVD/gelsd:
258+
the Python twin reuses the stage-0 rank-certification Gram and gates on LAPACK `dpocon`
259+
reciprocal condition > 1e-6; the Rust side is a new self-certifying faer `Llt` kernel
260+
(`solve_ols_chol`, faer-only heavy ops — identical behavior across the
261+
accelerate/openblas/no-BLAS wheels) whose exact 1-norm rcond inverse doubles as the
262+
sandwich bread. Default OFF = byte-identical legacy behavior on both backends (locked
263+
by dispatch spies, exact-equality tests, and a pristine-base-tree identity gate; sole
264+
exception: the saturated n == k vcov contract fix above); any
265+
certification decline falls back verbatim to the SVD path, so a knob-on decline equals
266+
knob-off exactly. Within the opt-in path, parity vs the default is tol-bounded (fitted
267+
~1e-8 abs / SE ~1e-6 rel; certified forward-error budget ~eps·cond ≤ 2e-10). Measured
268+
(M4 Max, medians): SunAbraham 1.13→0.63 s and 16.8→9.9 s (1.7-1.8x), CallawaySantAnna
269+
dr 40-covariate 4.40→3.57 s (1.23x), `skip_rank_check` micro-solve 1.56x; rust-side
270+
allocator high-water on a 2.4M×130 clustered solve 7.27→2.66 GB (no thin-SVD U
271+
transient). See docs/performance-plan.md § "Opt-in solve_ols normal-equations Cholesky
272+
fast path".
239273
- **Opt-in `df_convention="cluster"` inference-df knob (DiD / TWFE / MultiPeriodDiD +
240274
`LinearRegression`).** Clustered analytical fits historically compute t-statistics,
241275
p-values, and CIs at the fitted **residual df** (`n − K_full`), while `fixest`/Stata use

TODO.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ generic sparse-FE, QR+SVD rank-detection redundancy, `check_finite` bypass — m
4242
| Issue | Location | Origin | Effort | Priority |
4343
|-------|----------|--------|--------|----------|
4444
| `EfficientDiD` conditional path: the largest remaining O(n) stage is the sieve/nuisance construction outside the tiled pass (~9s at 10k). (The `_ridge_solve_weights` Python-prep shave landed 2026-07-07 — the `omega_stack[rest]` fancy-index copy and tail scatter are skipped when no row is zero-masked, byte-identical outputs; the `zero_mask` abs scan itself remains, needed for correctness.) | `efficient_did_covariates.py` | CS-scaling | Mid | Low |
45-
| Rust `solve_ols` always runs a full thin SVD (equilibrated, gelsd-parity); at 40 covariates it is the top per-cell solver item on a CS dr fit (1.04s of 3.1s at 2M rows, 95 calls). A Cholesky/QR fast path with SVD fallback — mirroring the Phase 3 Python-side pattern (certify well-conditioned, fall back verbatim) — is the natural lever, but `solve_ols` is the universal OLS entry point (every estimator), so the blast radius needs the full backend-parity treatment (`TestSolveOLSSkipRankCheckParity` posture: fitted-values parity, not beta). | `rust/src/linalg.rs::solve_ols` | CS-scaling | Heavy | Low |
45+
| Evaluate flipping `DIFF_DIFF_SOLVE_OLS_FASTPATH` default-ON after an opt-in soak (the 2026-07 certified normal-equations Cholesky fast path, both backends). A flip needs: golden/parity-suite recapture at the tol-bounded posture (fitted ~1e-8 abs / SE ~1e-6 rel — the default today is byte-pinned in several benchmark conventions), certification-rate telemetry across real workloads (any decline is silent-correct but forfeits the speedup), and the staged default-flip protocol used for `df_convention` (v4-class change). | `diff_diff/linalg.py::_resolve_solve_ols_fastpath`, `rust/src/linalg.rs::solve_ols_chol` | CS-scaling | Mid | Low |
4646

4747
### Testing / docs
4848

@@ -114,8 +114,8 @@ Doable in principle, but no current caller and/or explicitly out of paper scope.
114114
| CallawaySantAnna **unbalanced-panel R parity — LANDED** via `allow_unbalanced_panel=True` (matches R `did::att_gt(allow_unbalanced_panel=TRUE)` / `DRDID::reg_did_rc`: ATT bit-exact on cells AND dynamic aggregation via fixed unit-cohort-mass `pg` + a per-unit WIF; SE up to the documented CR1 `sqrt(G/(G-1))` factor). The earlier "weighting" framing was a mis-diagnosis — on unbalanced panels the dominant divergence from R is the *estimator* (within-cell differencing vs RC-on-pooled-obs), not only the weighting; both are resolved by the flag. The DEFAULT path keeps within-cell differencing as a documented design choice and now emits a `UserWarning` on unbalanced input (no-silent-failures). **Remaining deferred:** `survey_design=` × `allow_unbalanced_panel=` (per-obs vs per-unit weight resolution — currently fail-closed `NotImplementedError`); and covariate / ipw / dr × the flag R-parity verification (the RC path supports them; the committed golden covers `reg` no-cov). | `staggered.py`, `staggered_aggregation.py` | SE-audit D3 | Low |
115115
| CallawaySantAnna event-study bucket/weight construction is duplicated between the analytical aggregator (`staggered_aggregation.py::_aggregate_event_study`) and the multiplier bootstrap (`staggered_bootstrap.py`): both group (g,t) by `e = t - g`, apply the finite/NaN/reference masks, and read cohort weights. Both already consume the same source-materialized universal reference cells (so they agree), but the bucket logic is copy-pasted. Extract one shared helper returning per-event-time buckets (finite cells, NaN cells, reference flags, cohort weights, combined-IF inputs) used by both. Pure refactor; gate on byte-identical analytical + bootstrap output. | `staggered_aggregation.py`, `staggered_bootstrap.py` | SE-audit D3 | Low |
116116
| `StackedDiD` survey re-resolution intra-file dedup (raw-weight extraction ×3, compose-normalize ×3, resolve-on-stacked ×2). The cross-estimator ContinuousDiD/EfficientDiD panel-to-unit collapse consolidation LANDED (#226 shared helpers `ResolvedSurveyDesign.subset_to_units_by_row_idx` / `build_unit_first_row_index`); StackedDiD is deliberately NOT on that path (control units are duplicated across sub-experiments, so it re-resolves at stacked granularity rather than collapsing to one row per unit). The residual is stacked-specific, low value, and touches the numerically-sensitive composed-weight renormalization. Post-filter re-resolution / metadata-recompute unification across the three estimators was assessed and is not warranted — they use genuinely different mechanisms and already delegate to shared `_resolve_survey_for_fit` / `compute_survey_metadata`. | `stacked_did.py` | #226 | Low |
117-
| `solve_ols` residual memory floor is inherent to SVD-based lstsq after the PR-E marshalling slim (rust-side allocator high-water 15.32 -> 7.81 GB on the 2.4M x 130 clustered solve; remaining = one fused equilibrated input copy + thin-SVD U transient + vcov scores block + LAPACK gelsd internals on the python path). Further reduction requires the tall-skinny-QR algorithm change - same trade-off as the parked QR-reuse row below (library-wide last-digit perturbation + loses the second independent collinearity detector); the two land together if ever reopened. Diagnosis + measurements in docs/performance-plan.md. | `rust/src/linalg.rs::solve_ols`, `diff_diff/linalg.py` | PR-E | Low |
118-
| SunAbraham-family fits are SOLVER-bound after the v3.6.x demeaning speedups: on the county-class shape (3.1k units x 60 months, ~100 interaction columns) `solve_ols` = ~60% of fit time (faer SVD 1.24s + pivoted-QR rank detection 0.70s of 3.27s), and the share grows further under the Rust demean kernel; the pivoted QR alone is ~20% of the whole fit (measured attribution in docs/performance-plan.md). Candidate fix is QR-reuse (one factorization for rank detection + solve). **Parked per the 2026-07 correctness-first decision**: the change perturbs every coefficient in the library at the last digits (golden/parity/bit-identity recapture) and removes the SVD's independent singular-value truncation - a second, different near-collinearity detector that is deliberate defense-in-depth for the no-silent-failures contract (the FE-span guard episode showed borderline cases are real). Re-open only on practitioner demand for wide event studies; any implementation gates on fixest/R end-to-end parity, not just internal consistency. | `linalg.py::solve_ols`, `linalg.py::_detect_rank_deficiency` | PR-C attribution | Medium |
117+
| `solve_ols` residual memory floor is inherent to SVD-based lstsq after the PR-E marshalling slim (rust-side allocator high-water 15.32 -> 7.81 GB on the 2.4M x 130 clustered solve; remaining = one fused equilibrated input copy + thin-SVD U transient + vcov scores block + LAPACK gelsd internals on the python path). Further reduction requires the tall-skinny-QR algorithm change - same trade-off as the parked QR-reuse row below (library-wide last-digit perturbation + loses the second independent collinearity detector); the two land together if ever reopened. Diagnosis + measurements in docs/performance-plan.md. (The 2026-07 opt-in `DIFF_DIFF_SOLVE_OLS_FASTPATH` Cholesky path sidesteps the floor when enabled — no thin-SVD U transient, scores reuse the one equilibrated buffer; this row tracks the DEFAULT path only.) | `rust/src/linalg.rs::solve_ols`, `diff_diff/linalg.py` | PR-E | Low |
118+
| SunAbraham-family fits are SOLVER-bound after the v3.6.x demeaning speedups: on the county-class shape (3.1k units x 60 months, ~100 interaction columns) `solve_ols` = ~60% of fit time (faer SVD 1.24s + pivoted-QR rank detection 0.70s of 3.27s), and the share grows further under the Rust demean kernel; the pivoted QR alone is ~20% of the whole fit (measured attribution in docs/performance-plan.md). Candidate fix is QR-reuse (one factorization for rank detection + solve). **Parked per the 2026-07 correctness-first decision**: the change perturbs every coefficient in the library at the last digits (golden/parity/bit-identity recapture) and removes the SVD's independent singular-value truncation - a second, different near-collinearity detector that is deliberate defense-in-depth for the no-silent-failures contract (the FE-span guard episode showed borderline cases are real). Re-open only on practitioner demand for wide event studies; any implementation gates on fixest/R end-to-end parity, not just internal consistency. (2026-07 updates: the pivoted-QR share quoted above predates #634's stage-0 Gram certification — rank detection is now ~0.03s on the county shape — and the opt-in `DIFF_DIFF_SOLVE_OLS_FASTPATH` Cholesky path is the certification-gated alternative that captures the solver win WITHOUT the library-wide perturbation: the default stays byte-identical and keeps the SVD truncation as the second collinearity detector.) | `linalg.py::solve_ols`, `linalg.py::_detect_rank_deficiency` | PR-C attribution | Medium |
119119
| dCDH parity-test SE/CI assertions only cover pure-direction scenarios; mixed-direction SE comparison is structurally apples-to-oranges (cell-count vs obs-count weighting). | `test_chaisemartin_dhaultfoeuille_parity.py` | #294 | Low |
120120
| `HeterogeneousAdoptionDiD` joint cross-horizon covariance / sup-t bands: per-horizon SEs use independent sandwiches (paper-faithful pointwise CIs per Pierce-Schott Fig 2). Follow-ups (low demand): IF-based stacking for joint cross-horizon inference; analytical H×H covariance on the weighted ES path. (A sup-t band on the unweighted ES path shipped via the clustered band — `cluster=` fires the simultaneous band on the unweighted path.) | `had.py::_fit_event_study` | Phase 2b / 4.5 B | Low |
121121
| `HeterogeneousAdoptionDiD` event-study staggered timing beyond the last cohort: Phase 2b auto-filters to the last cohort (paper App B.2); earlier-cohort effects aren't HAD-identified (redirect to dCDH). Full staggered HAD needs a different identification path (out of paper scope). | `had.py::_validate_had_panel_event_study` | Phase 2b | Low |
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
{
2+
"metadata": {
3+
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
4+
"arms": [
5+
"off",
6+
"on"
7+
]
8+
},
9+
"county_policy": {
10+
"off": {
11+
"times": [
12+
1.127356459001021,
13+
1.1081956670022919,
14+
1.1512349999975413
15+
],
16+
"median": 1.127356459001021,
17+
"att": 0.28037854139903057,
18+
"se": 0.031244864610215983
19+
},
20+
"on": {
21+
"times": [
22+
0.6245858750044135,
23+
0.6251335830020253,
24+
0.6231642499988084
25+
],
26+
"median": 0.6245858750044135,
27+
"att": 0.28037854139903007,
28+
"se": 0.031244864610213023
29+
}
30+
},
31+
"firm_churn": {
32+
"off": {
33+
"times": [
34+
17.31286900000123,
35+
16.22713416699844
36+
],
37+
"median": 16.770001583499834,
38+
"att": 0.314432718449715,
39+
"se": 0.005837281701253922
40+
},
41+
"on": {
42+
"times": [
43+
9.93284683299862,
44+
9.850229166004283
45+
],
46+
"median": 9.891537999501452,
47+
"att": 0.3144327184497242,
48+
"se": 0.005837281701238088
49+
}
50+
},
51+
"scanner_twfe": {
52+
"off": {
53+
"times": [
54+
0.5774591250010417,
55+
0.5748795420004171,
56+
0.5841960840043612
57+
],
58+
"median": 0.5774591250010417,
59+
"att": 0.25116413149499167,
60+
"se": 0.002414938742160611
61+
},
62+
"on": {
63+
"times": [
64+
0.5765144159959164,
65+
0.5650162499950966,
66+
0.5698209999973187
67+
],
68+
"median": 0.5698209999973187,
69+
"att": 0.25116413149499867,
70+
"se": 0.0024149387421607276
71+
}
72+
},
73+
"cs_cov40": {
74+
"off": {
75+
"times": [
76+
4.3890298749975045,
77+
4.401376791000075,
78+
4.414817625001888
79+
],
80+
"median": 4.401376791000075,
81+
"att": 2.0008128010664223,
82+
"se": 0.005610568442727097
83+
},
84+
"on": {
85+
"times": [
86+
3.700359333997767,
87+
3.5647381669987226,
88+
3.5419092910015024
89+
],
90+
"median": 3.5647381669987226,
91+
"att": 2.0008128010664223,
92+
"se": 0.005610568442727097
93+
}
94+
},
95+
"survey_absorb": {
96+
"off": {
97+
"times": [
98+
2.7668485419999342,
99+
2.7689070839987835
100+
],
101+
"median": 2.767877812999359,
102+
"att": 0.24554568144130481,
103+
"se": 0.007662698712656388
104+
},
105+
"on": {
106+
"times": [
107+
2.5810511670060805,
108+
2.595715333998669
109+
],
110+
"median": 2.5883832505023747,
111+
"att": 0.24554568144130481,
112+
"se": 0.00766269871265628
113+
}
114+
},
115+
"skiprank_micro": {
116+
"off": {
117+
"times": [
118+
6.819884582997474,
119+
6.934963792002236
120+
],
121+
"median": 6.877424187499855,
122+
"att": 2.6266509653021792,
123+
"se": 0.0006455844619141583
124+
},
125+
"on": {
126+
"times": [
127+
4.4150471670000115,
128+
4.423518374998821
129+
],
130+
"median": 4.419282770999416,
131+
"att": 2.626650965303164,
132+
"se": 0.0006455844619141326
133+
}
134+
}
135+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
{
2+
"metadata": {
3+
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
4+
"arms": [
5+
"off"
6+
]
7+
},
8+
"county_policy": {
9+
"off": {
10+
"times": [
11+
1.0828709579946008,
12+
1.1249838750009076,
13+
1.0858678750009858
14+
],
15+
"median": 1.0858678750009858,
16+
"att": 0.28037854139903057,
17+
"se": 0.031244864610215983
18+
}
19+
},
20+
"firm_churn": {
21+
"off": {
22+
"times": [
23+
17.221773457997188,
24+
16.04917508300423
25+
],
26+
"median": 16.63547427050071,
27+
"att": 0.314432718449715,
28+
"se": 0.005837281701253922
29+
}
30+
},
31+
"scanner_twfe": {
32+
"off": {
33+
"times": [
34+
0.575249708002957,
35+
0.5752674159957678,
36+
0.5818307080044178
37+
],
38+
"median": 0.5752674159957678,
39+
"att": 0.25116413149499167,
40+
"se": 0.002414938742160611
41+
}
42+
},
43+
"cs_cov40": {
44+
"off": {
45+
"times": [
46+
4.368334792001406,
47+
4.373289832998125,
48+
4.364916250000533
49+
],
50+
"median": 4.368334792001406,
51+
"att": 2.0008128010664223,
52+
"se": 0.005610568442727097
53+
}
54+
},
55+
"survey_absorb": {
56+
"off": {
57+
"times": [
58+
2.8271069999973406,
59+
2.837882542000443
60+
],
61+
"median": 2.832494770998892,
62+
"att": 0.24554568144130481,
63+
"se": 0.007662698712656388
64+
}
65+
},
66+
"skiprank_micro": {
67+
"off": {
68+
"times": [
69+
7.0087420839990955,
70+
7.03886975000205
71+
],
72+
"median": 7.023805917000573,
73+
"att": 2.6266509653021792,
74+
"se": 0.0006455844619141583
75+
}
76+
}
77+
}

0 commit comments

Comments
 (0)