Skip to content

perf(within-transform): drop the redundant full-frame copy in within_transform#567

Merged
igerber merged 1 commit into
mainfrom
perf/within-transform-copy-elision
Jun 28, 2026
Merged

perf(within-transform): drop the redundant full-frame copy in within_transform#567
igerber merged 1 commit into
mainfrom
perf/within-transform-copy-elision

Conversation

@igerber

@igerber igerber commented Jun 28, 2026

Copy link
Copy Markdown
Owner

Summary

  • within_transform drops its redundant defensive full-frame copy. The two-way fixed-effects demeaning helper — shared by TwoWayFixedEffects, SunAbraham, WooldridgeDiD, and BaconDecomposition — copied the entire input frame before pd.concat-ing the demeaned columns onto it. But the demean is read-only and concat does not mutate its inputs, so that copy was pure overhead. It now computes the demeaned columns first and attaches them as a single consolidated block via pd.concat with no pre-copy (under pandas copy-on-write the original columns are shared, not copied).
  • ~8% peak-RSS reduction for a wide TwoWayFixedEffects(vcov_type="hc1") fit (964 → 886 MB at 400k units × 6 covariates); the win scales with panel width.
  • inplace/suffix are decoupled (inplace = attach in place vs concat; suffix = column naming), matching demean_by_group. The non-inplace path drops a colliding target name before the concat so suffix="" / re-demeaning a frame that already carries the suffix overwrite cleanly (one label, no duplicate).
  • PR-C of the memory-scaling work (B1 perf(callaway-santanna): chunk multiplier-bootstrap weight generation to bound peak memory #561 + B2 perf(efficient-did, had): chunk multiplier-bootstrap weight generation to bound peak memory #563 merged). The separate ImputationDiD / TwoStageDiD _iterative_demean vectorization is deferred (TODO.md) — its np.bincount form is non-bit-identical on pandas 3.0 (~5.8e-11, the same order as the demean's tol=1e-10) and it is analytical-path-only.

Methodology references

  • Method name(s): two-way within (fixed-effects) transformation (FWL) underlying TWFE / SunAbraham / WooldridgeDiD; Goodman-Bacon decomposition cells.
  • Paper / source link(s): docs/methodology/REGISTRY.md TWFE within-transform (FWL) section. Frame-assembly-only change; the demeaning arithmetic and weighted alternating-projection warning path are untouched.
  • Any intentional deviations from the source (and why): None — bit-identical.

Validation

  • Tests added/updated: tests/test_within_transform.py (8 tests — the inplace/suffix modes, overwrite-without-duplicate on both the inplace and concat paths, weighted parity, and the no-PerformanceWarning many-column path).
  • Backtest / simulation / notebook evidence: N/A. Verified on both backends; bit-identity proven at atol=0 for TWFE (including the survey replicate-weight path), SunAbraham, Wooldridge, and Bacon via a pre/post estimate capture; full TWFE / SunAbraham / Wooldridge / Bacon / survey / Conley regression green (879 python + 383 rust). Peak RSS 964 → 886 MB on a wide TWFE fit.

Security / privacy

  • Confirm no secrets/PII in this PR: Yes

🤖 Generated with Claude Code

…transform

within_transform -- the two-way fixed-effects demeaning helper shared by
TwoWayFixedEffects, SunAbraham, WooldridgeDiD, and BaconDecomposition -- copied
the entire input frame defensively before pd.concat-ing the demeaned columns
onto it. But the demean is read-only and concat does not mutate its inputs, so
that copy is pure overhead.

Compute the demeaned columns first, then attach them as a single consolidated
block via pd.concat with no defensive pre-copy (under pandas copy-on-write the
original columns are shared, not copied). Peak RSS of a wide
TwoWayFixedEffects(vcov_type="hc1") fit drops ~8% (964 -> 886 MB at 400k units
x 6 covariates); the win scales with panel width.

Bit-identical: proven at atol=0 for TWFE (incl. the survey replicate-weight
path), SunAbraham, Wooldridge, and Bacon -- frame assembly only, the demean
arithmetic is unchanged. The inplace/suffix params are decoupled (inplace =
in-place attach vs concat; suffix = column naming), matching demean_by_group.
New unit tests lock the modes + the no-fragmentation many-column path.

PR-C of the memory-scaling work. The ImputationDiD/TwoStageDiD _iterative_demean
vectorization is deferred (TODO) -- it is non-bit-identical on pandas 3.0 and
analytical-path-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Overall Assessment

✅ Looks good — no unmitigated P0/P1 findings.

Executive Summary

  • Affected methods: TwoWayFixedEffects, SunAbraham, WooldridgeDiD OLS, and BaconDecomposition through shared within_transform.
  • Methodology registry requires FWL-consistent demeaning of outcome and regressors; the PR preserves the arithmetic and only changes result-frame assembly.
  • No new SE, variance, CI, p-value, or inline inference logic was added.
  • The deferred _iterative_demean optimization is tracked in TODO.md, so it is non-blocking.
  • I could not run pytest in this environment: pytest and numpy are not installed.

Methodology

No findings.

Severity: None
Impact: The demeaning formulas remain aligned with the registry: unweighted FWL demeaning and weighted alternating projections are unchanged in diff_diff/utils.py:L2716-L2760, with the weighted non-convergence warning preserved at diff_diff/utils.py:L2744-L2750. This matches the TWFE FWL requirement in docs/methodology/REGISTRY.md:L320-L333 and weighted within-transform note in docs/methodology/REGISTRY.md:L4165-L4172.
Concrete fix: None.

Code Quality

P3 informational: within_transform(inplace=True) now uses suffix and therefore adds suffixed columns by default instead of overwriting source columns unless suffix="" is passed. This is tested and documented in the docstring at diff_diff/utils.py:L2669-L2680, and no internal estimator call site uses inplace=True.
Impact: External utility users relying on the old overwrite behavior may be surprised.
Concrete fix: No blocker; consider a changelog compatibility sentence.

Performance

No findings.

Severity: None
Impact: The non-inplace path now builds a single new_block and appends it with pd.concat, avoiding per-column insertion and the removed defensive full-frame copy at diff_diff/utils.py:L2771-L2786.
Concrete fix: None.

Maintainability

No findings.

Severity: None
Impact: The refactor removes duplicated weighted/unweighted inplace vs non-inplace loops and centralizes target-column attachment.
Concrete fix: None.

Tech Debt

P3 informational: The related ImputationDiD._iterative_demean / TwoStageDiD._iterative_demean vectorization is explicitly tracked in TODO.md:L68 with the bit-identity caveat.
Impact: Deferred performance work is documented; not a correctness blocker for this PR.
Concrete fix: None required for approval.

Security

No findings.

Severity: None
Impact: No secrets, I/O surfaces, subprocess execution, or user-controlled execution paths are introduced.
Concrete fix: None.

Documentation/Tests

P3 informational: The new tests cover non-mutating default behavior, inplace/suffix interactions, collision overwrite semantics, weighted inplace/non-inplace parity, and fragmentation warnings in tests/test_within_transform.py:L40-L122. I could not execute them because the review environment lacks pytest and numpy.
Impact: Test coverage is well targeted, but execution was not verified here.
Concrete fix: None for the PR; run pytest -q tests/test_within_transform.py in CI/local dev.

@igerber igerber added the ready-for-ci Triggers CI test workflows label Jun 28, 2026
@igerber igerber merged commit c8c0e1c into main Jun 28, 2026
33 of 34 checks passed
@igerber igerber deleted the perf/within-transform-copy-elision branch June 28, 2026 14:02
igerber added a commit that referenced this pull request Jun 28, 2026
…ng work

Adds benchmarks/speed_review/bench_memory_scaling.py -- a subprocess-isolated
peak-RSS sweep (ru_maxrss, median of repeats) for the bootstrap-chunking and
within-transform memory wins (B1 #561 / B2 #563 / C #567) -- plus a "Memory
scaling" section in docs/performance-plan.md with the honest before/after table.

Measured pre-#561 (un-optimized) vs current main, 999 bootstrap reps:
- CallawaySantAnna bootstrap: 500k 12.9->2.1 GB (-84%), 1M 13.5->3.0 GB (-78%)
- EfficientDiD bootstrap 500k: 8.3->1.6 GB (-81%)
- HeterogeneousAdoptionDiD cband 500k: 7.7->1.2 GB (-84%)
- TWFE within-transform fit: 500k 1.0->0.93 GB (-8%), 1M 1.7->1.5 GB (-9%)

Closes the 3-PR memory-scaling initiative. The baseline is re-measured against
the pre-#561 tree (not a monkeypatch of current code, which would be contaminated
by the same-line optimizations).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
igerber added a commit that referenced this pull request Jun 28, 2026
…ng work

Adds benchmarks/speed_review/bench_memory_scaling.py -- a subprocess-isolated
peak-RSS sweep (ru_maxrss, median of repeats) for the bootstrap-chunking and
within-transform memory wins (B1 #561 / B2 #563 / C #567) -- plus a "Memory
scaling" section in docs/performance-plan.md with the honest before/after table.

Measured pre-#561 (un-optimized) vs current main, 999 bootstrap reps:
- CallawaySantAnna bootstrap: 500k 12.9->2.1 GB (-84%), 1M 13.5->3.0 GB (-78%)
- EfficientDiD bootstrap 500k: 8.3->1.6 GB (-81%)
- HeterogeneousAdoptionDiD cband 500k: 7.7->1.2 GB (-84%)
- TWFE within-transform fit: 500k 1.0->0.93 GB (-8%), 1M 1.7->1.5 GB (-9%)

Closes the 3-PR memory-scaling initiative. The baseline is re-measured against
the pre-#561 tree (not a monkeypatch of current code, which would be contaminated
by the same-line optimizations).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-ci Triggers CI test workflows

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant