Skip to content

perf: skip the margin width scan when there are no vertical margins - #722

Open
ZviBaratz wants to merge 1 commit into
charmbracelet:mainfrom
ZviBaratz:perf/skip-margin-width-scan
Open

perf: skip the margin width scan when there are no vertical margins#722
ZviBaratz wants to merge 1 commit into
charmbracelet:mainfrom
ZviBaratz:perf/skip-margin-width-scan

Conversation

@ZviBaratz

Copy link
Copy Markdown

Fixes #721.

applyMargins calls getLines unconditionally, so every non-inline Render pays a full grapheme-cluster width pass over the whole block to build a spaces string that is only read when a top or bottom margin is set. Most styles set neither.

	// Top/bottom margin
	if !inline {
		_, width := getLines(str)          // <- always runs
		spaces := strings.Repeat(" ", width)

		if topMargin > 0 { ... }
		if bottomMargin > 0 { ... }
	}

width has no other reader in the function and getLines is pure, so guarding it on the margins that consume it is behaviour-preserving.

Measured with this repo's own BenchmarkStyleRender, unmodified

go1.26, linux/amd64, Intel Core Ultra 7 258V, -benchtime 2s:

case before after allocs
simple-1-line 1683 ns/op 1469 8 → 7
simple-5-lines 6814 ns/op 5360 27 → 26
simple-5-lines-inline 1334 ns/op 1321 8 → 8
simple-10-lines-5-height-40-width 19989 ns/op 13461 181 → 180
simple-10-lines-width-maxwidth 27164 ns/op 21544 183 → 182
simple-10-lines-width-maxwidth-borders 47630 ns/op 39199 297 → 296
…-borders-padding-margins 76691 ns/op 75509 411 → 411

Every case that sets no vertical margin loses exactly one allocation and 21–33% of its time.

Two of those rows are controls rather than results:

  • …-borders-padding-margins does set margins. Its time is unchanged within run-to-run noise and its allocation count is identical on both sides (411), which is what says the guard did not skip work that was needed. Confirmed across three runs.
  • simple-5-lines-inline is unchanged, as expected — inline already excluded this block.

Why there is a test

Top/bottom margin rendering had no direct coverage — the existing margin tests are getters and setters (GetMarginTop, UnsetMarginBottom, …), and the only render-level margin test is in table/. So the new guard would have shipped unpinned.

TestVerticalMargins covers both sides of it. I mutation-tested it: dropping either half of the condition, or making it never fire, all fail it.

It also records one thing that surprised me, in case it saves someone a wrong "optimization" later: by the time applyMargins runs, horizontal alignment has already padded every line out to the widest, so reading the width off the first line passes the test too. That equality is incidental to the ordering, not a property of the block, so the test says so rather than implying it pins the walk.

One note on the fixture: the test uses NewStyle().Align(Left) rather than a bare NewStyle(), because with no properties at all Render returns at the s.props == 0 early exit and never reaches alignment or margins — a bare style would have made the no-margin case assert nothing about this path.

Context

Found while profiling a Bubble Tea app whose frame build turned out to be ~70% ansi.StringWidth. In a CPU profile of one full frame, applyMargins was 11.2% of samples — 13.5% of the frame build — and 94% of that was this one call. That app sets exactly one margin in its entire tree.

go test ./... passes (lipgloss, list, table, tree). The gofmt/go vet findings in table/table_test.go are pre-existing on master and untouched here.

applyMargins called getLines unconditionally, so every non-inline Render paid a
full grapheme-cluster width pass over the whole block to compute a `spaces`
string that is only used when a top or bottom margin is set. Most styles set
neither, so most Render calls did the walk and threw the result away.

getLines is guarded on the margins that consume it now. Behaviour is unchanged:
`width` has no other reader in the function, and getLines is pure.

Measured with the repo's own BenchmarkStyleRender, unmodified (go1.26,
linux/amd64, -benchtime 2s):

    case                                          before      after
    simple-1-line                               1683 ns/op  1469 ns/op
    simple-5-lines                              6814 ns/op  5360 ns/op
    simple-5-lines-inline                       1334 ns/op  1321 ns/op
    simple-10-lines-5-height-40-width          19989 ns/op 13461 ns/op
    simple-10-lines-width-maxwidth             27164 ns/op 21544 ns/op
    simple-10-lines-width-maxwidth-borders     47630 ns/op 39199 ns/op
    ...-borders-padding-margins                76691 ns/op 75509 ns/op

Each case that sets no vertical margin loses exactly one allocation and 21-33%
of its time. The last row is the control: it does set margins, and neither its
time nor its allocation count (411 both sides) moves. The inline row is
untouched, as it was already excluded.

TestVerticalMargins covers both sides of the new guard. Top/bottom margin
rendering had no direct test before this — the existing margin tests are
getters and setters — so the guard would otherwise have been unpinned.

Fixes charmbracelet#721.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Style.Render measures the whole block to apply margins that are zero (applyMargins calls getLines unconditionally)

1 participant