Skip to content

perf(sinks): cols is positional — measured, and rejected - #428

Closed
FBumann wants to merge 1 commit into
mainfrom
tier1/cols-in-label-order
Closed

FBumann wants to merge 1 commit into
mainfrom
tier1/cols-in-label-order

Conversation

@FBumann

@FBumann FBumann commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Opening this to close it. The idea is right, the implementation is not, and the numbers are worth keeping where the next person will find them.

The idea

cols has exactly one row per column, and labels are globally dense 0..n-1 (verified on all six bench cases: sorted(cols['col']) == range(n)). So if it arrives in label order, its col column is the frame's own row number — 0.32 GB of pure index at 40M columns, held for the model's whole lifetime.

Dropping it also collapses work at both sinks: lp_file stops sorting cols twice, and ModelTables.dense_columns stops scattering three of its four vectors, because they become the frame's own columns.

What it measured

bench.run --cases dispatch nodal transport profiled --sizes m l --repeat 3, back to back.

peak build wall
dispatch/l lp −16.4% +92.4% −5.2% (noise)
profiled/l lp −11.9% +13.8% +1.2% (noise)
transport/l lp −9.4% +26.8% +6.2%
nodal/l lp −8.3% +31.9% +17.6%
dispatch/l highs −2.9% +90.8% +16.5%

The memory thesis holds — 8–16% off peak on the write path, on every case. The price does not. Paying for label order with a sort in the executor costs 13–92% of the build, and the scatter it was supposed to buy back never showed.

Why my estimate was wrong

A micro-benchmark said the sort (439 ms at 40M) would be cheaper than the scatter it removes (1330 ms). End to end it wasn't, for two reasons I can see:

  • .to_numpy() on a polars column is not always the zero-copy view I assumed, so nan_to_num(copy=True) may have added a copy where the old path already had one.
  • The sort sits inside the streaming collect, where it costs more than on the materialised frame the micro-benchmark timed.

That is the harness doing its job. A +92% build would have been an easy thing to miss by quoting the micro-benchmark.

What survives

Two things, both already elsewhere:

  • The test. test(sinks): dense_columns must not edit the model it projects #427 — dense_columns mutates its vectors in place, which is safe only while they are freshly allocated. This change makes them views, and the moment they are views the projection rewrites the built model to suit whichever solver asked last. That trap is live on main today with highs and gurobi both shipping, and nothing in the suite would have caught it.
  • The direction. The invariant should be produced, not repaired. The counted label path already emits in exact label order because ROW_NUMBER imposes it, and the arithmetic and factored paths emit head-major cross joins that are close to it. An engine that guarantees the order instead of sorting at the end pays nothing for it, and the 8–16% stands on its own.

That is the next piece of work, and it belongs in the label paths on both engines rather than at the sink contract. Closing this so it isn't mistaken for something waiting to merge.

🤖 Generated with Claude Code

Measured and rejected; kept for the record. See the PR.
@FBumann FBumann added area:engine Lowering, IR, relational executor, the sink contract perf Wall time, memory, or I/O cost — not a wrong answer labels Jul 31, 2026
@FBumann

FBumann commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Closed as measured-and-rejected, not as abandoned. The peak win (8–16% on the write path, every case) is real and I want it; paying for it with a sort in the executor is not the way, and the table above is why.

Picking it up from the other end — having the label paths emit in label order rather than sorting afterwards — as its own measured change. The test this uncovered is #427.

@FBumann FBumann closed this Jul 31, 2026
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in: 43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 20bcea5e-b7c8-4262-9740-52d2d61a2c88

📥 Commits

Reviewing files that changed from the base of the PR and between 393a910 and a3946db.

📒 Files selected for processing (3)
  • src/lpspec/relational/engines/polars/executor.py
  • src/lpspec/relational/sinks/tables.py
  • src/lpspec/relational/sinks/writers/lp_file.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.

FBumann added a commit that referenced this pull request Jul 31, 2026
…an repaired (#433)

`cols` holds one row per column, and labels are globally dense 0..n-1 — so if
it arrives in label order its `col` column is the frame's own row number. 0.32
GB of index at 40M columns, held for as long as the model is, plus the work it
caused: `lp_file` sorted it twice and `dense_columns` scattered three of its
four vectors through it.

The first attempt bought that order with a sort in the executor and cost 13-92%
of the build (#428, closed with its numbers). This produces the order instead.

**polars' streaming engine walks a cross join right-major.** Folding the
coordinate product backwards therefore emits it in *declaration* row-major
order, which is label order, at no cost — it is the same joins in the other
sequence. The factored path's second cross join gets the same treatment, and
the bounds join carries `maintain_order='left'`, which is +12 ms on a 10M-row
join against the ~110 ms sort it replaces.

    on a quiet machine, minimum of three repeats
                        peak      build      wall
    dispatch/l   lp   -20.8%     +9.4%      -7.4%
    transport/l  lp   -14.5%     +1.0%      -0.8%
    profiled/l   lp   -10.9%     -9.2%      -8.2%
    nodal/l      lp   -10.2%     -4.7%      -5.8%
    dispatch/l   highs -5.6%    +10.0%     -13.1%

That order is a property of a *dependency*, not of this package, and a label is
arithmetic on ordinals rather than a position — so a change in polars would not
corrupt a label, it would silently stop the frame being sorted while `cols` is
still read positionally. `_in_label_order` therefore verifies rather than
trusts: a linear scan over a column the frame already holds, with a sort behind
it as the correctness floor.

The test uses two dims because one dim is a scan and cannot be out of order,
and distinct bounds per coordinate because a permuted frame still holds the
right multiset. Removing either guard fails it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:engine Lowering, IR, relational executor, the sink contract perf Wall time, memory, or I/O cost — not a wrong answer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant