perf: avoid per-node Selection allocation in winnow Not path

The winnow() "Not" path routed through grep(), which called
newSingleSelection() for every node, allocating a Selection and
a 1-element []*html.Node slice on the heap per node, only to
immediately unwrap it via s.Get(0) for m.Match().
This commit call m.Match(n) directly and pre-allocate the result slice.

It also adds BenchmarkNotMatcher to isolate the winnow path from
compileMatcher overhead.

Here are my local benchmark results, for BenchmarkNotMatcher with 373 <li>
nodes, and a pre-compiled matcher:

old ns/op   new ns/op   delta
22750       19350       ~-15%

old B/op    new B/op    delta
9384        3120        -66.7%

old allocs  new allocs  delta
11          2           -81.8%
This commit is contained in:
jvoisin
2026-05-23 22:02:15 +02:00
parent 33466f5bcd
commit 45d35d506a
2 changed files with 29 additions and 4 deletions
+21
View File
@@ -2,6 +2,8 @@ package goquery
import (
"testing"
"github.com/andybalholm/cascadia"
)
func BenchmarkFilter(b *testing.B) {
@@ -40,6 +42,25 @@ func BenchmarkNot(b *testing.B) {
}
}
func BenchmarkNotMatcher(b *testing.B) {
var n int
b.StopTimer()
sel := DocW().Find("li")
m := cascadia.MustCompile(".toclevel-2")
b.StartTimer()
for i := 0; i < b.N; i++ {
if n == 0 {
n = sel.NotMatcher(m).Length()
} else {
sel.NotMatcher(m)
}
}
if n != 371 {
b.Fatalf("want 371, got %d", n)
}
}
func BenchmarkFilterFunction(b *testing.B) {
var n int
+8 -4
View File
@@ -130,10 +130,14 @@ func winnow(sel *Selection, m Matcher, keep bool) []*html.Node {
if keep {
return m.Filter(sel.Nodes)
}
// Use grep
return grep(sel, func(i int, s *Selection) bool {
return !m.Match(s.Get(0))
})
// Not path: call Match directly on each node, no Selection wrapper needed
result := make([]*html.Node, 0, len(sel.Nodes))
for _, n := range sel.Nodes {
if !m.Match(n) {
result = append(result, n)
}
}
return result
}
// Filter based on an array of nodes, and the indicator to keep (Filter) or