Skip to content

Reduce heap escapes in subject tree matching - #8526

Merged
neilalexander merged 1 commit into
mainfrom
neil/matchparts
Aug 26, 2026
Merged

Reduce heap escapes in subject tree matching#8526
neilalexander merged 1 commit into
mainfrom
neil/matchparts

Conversation

@neilalexander

@neilalexander neilalexander commented Aug 26, 2026

Copy link
Copy Markdown
Member

This stops the pre-allocated raw array from heap-escaping as it is no longer passed through an interface boundary unnecessarily.

Before:

stree_test.go:874: Match took 69.979084ms for 5000000 entries with 2 allocations per traversal

After:

stree_test.go:874: Match took 65.055ms for 5000000 entries with 1 allocations per traversal

This stops the pre-allocated `raw` array from heap-escaping as it is no
longer passed through an interface boundary unnecessary.

Signed-off-by: Neil Twigg <neil@nats.io>
@neilalexander
neilalexander requested a review from a team as a code owner August 26, 2026 15:44
@synadia-claude-reviewer

synadia-claude-reviewer Bot commented Aug 26, 2026

Copy link
Copy Markdown

Claude finished @neilalexander's task in 2m 40s —— View job


Review

  • Gather context and read PR diff
  • Examine changed source files
  • Analyze correctness, performance, and concurrency safety
  • Post review findings

Correctness: The refactor is semantically equivalent. Both meta.matchParts and leaf.matchParts previously just delegated to the package-level matchParts(parts, n.prefix/n.suffix), and n.path() returns exactly those same strings (n.prefix for internal nodes, n.suffix for leaves). The logic in stree.go:match() is unchanged.

Why this works: Calling n.matchParts(parts) passed a [][]byte across an interface dispatch boundary. Since interface dispatch is indirect (vtable), Go's escape analysis conservatively assumes the slice (backed by the stack-allocated raw array) may escape to the heap. The new call matchParts(parts, n.path()) is a direct concrete-function call, so the compiler can fully trace that raw's backing array doesn't escape, keeping it on the stack.

raw [16][]byte[32][]byte: This is necessary to preserve the zero-allocation copy optimization in matchParts (lines 132–136 of parts.go):

lp := len(parts)
parts = append(parts[lp:], parts[:lp]...)  // reuses extra capacity at tail of raw

For this not to heap-allocate, cap(parts) - len(parts) >= len(parts), i.e. cap >= 2 * len. With the original [16][]byte and up to 16 parts possible, that tail capacity would often be zero. Doubling to 32 gives sufficient room for typical NATS filter subjects. The remaining 1 allocation (per the PR benchmark) is only triggered if genParts produces more than 16 parts, which is unusual in practice.

Stack size trade-off: Each []byte header is 24 bytes on 64-bit, so raw [32][]byte costs 768 bytes of stack instead of 384. Acceptable for a hot path.

No concurrency concerns: All state (raw, parts, _pre) is local to each call and not shared.

No security concerns: Pure algorithmic refactor with no trust boundary changes.

The change is clean and well-reasoned. No issues.

@MauriceVanVeen MauriceVanVeen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@neilalexander
neilalexander merged commit b1ac5e0 into main Aug 26, 2026
6 of 8 checks passed
@neilalexander
neilalexander deleted the neil/matchparts branch August 26, 2026 16:33
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.

2 participants