Skip to content

Commit b0cec2c

Browse files
Reduce number of allocations for rendering elements and attributes (#265)
This change does some optimizations to reduce the number of memory heap allocations. Also: - Checks for `io.StringWriter` and uses that if available. - Pre-allocates byte slices for commonly used strings. - Fixed the benchmarks to not count `strings.Builder` allocations. These are the benchmark results from before the changes (with the benchmark fix): ``` go test -bench . -benchmem ./... goos: darwin goarch: arm64 pkg: maragu.dev/gomponents cpu: Apple M3 Max BenchmarkAttr/boolean_attributes-16 25371446 46.81 ns/op 40 B/op 3 allocs/op BenchmarkAttr/name-value_attributes-16 13534495 88.24 ns/op 72 B/op 4 allocs/op BenchmarkEl/normal_elements-16 14068998 86.28 ns/op 48 B/op 5 allocs/op PASS ok maragu.dev/gomponents 4.894s PASS ok maragu.dev/gomponents/components 0.163s goos: darwin goarch: arm64 pkg: maragu.dev/gomponents/html cpu: Apple M3 Max BenchmarkLargeHTMLDocument-16 526 2092062 ns/op 2950444 B/op 90031 allocs/op PASS ok maragu.dev/gomponents/html 1.463s PASS ok maragu.dev/gomponents/http 0.168s ? maragu.dev/gomponents/internal/assert [no test files] PASS ok maragu.dev/gomponents/internal/import 0.135s ``` After: ``` go test -bench . -benchmem ./... goos: darwin goarch: arm64 pkg: maragu.dev/gomponents cpu: Apple M3 Max BenchmarkAttr/boolean_attributes-16 51947022 19.75 ns/op 8 B/op 1 allocs/op BenchmarkAttr/name-value_attributes-16 18138727 64.87 ns/op 24 B/op 2 allocs/op BenchmarkEl/normal_elements-16 21048692 55.48 ns/op 24 B/op 2 allocs/op PASS ok maragu.dev/gomponents 3.687s PASS ok maragu.dev/gomponents/components 0.158s goos: darwin goarch: arm64 pkg: maragu.dev/gomponents/html cpu: Apple M3 Max BenchmarkLargeHTMLDocument-16 714 1535017 ns/op 2630426 B/op 40028 allocs/op PASS ok maragu.dev/gomponents/html 1.398s PASS ok maragu.dev/gomponents/http 0.171s ? maragu.dev/gomponents/internal/assert [no test files] PASS ok maragu.dev/gomponents/internal/import 0.137s ```
1 parent bf0db98 commit b0cec2c

File tree

5 files changed

+228
-77
lines changed

5 files changed

+228
-77
lines changed

.github/workflows/ci.yml

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,21 +54,33 @@ jobs:
5454
name: Benchmark
5555
runs-on: ubuntu-latest
5656

57+
strategy:
58+
matrix:
59+
go:
60+
- "1.18"
61+
- "1.19"
62+
- "1.20"
63+
- "1.21"
64+
- "1.22"
65+
- "1.23"
66+
- "1.24"
67+
- "1.25"
68+
5769
steps:
5870
- name: Checkout
5971
uses: actions/checkout@v5
6072

6173
- name: Setup Go
6274
uses: actions/setup-go@v5
6375
with:
64-
go-version-file: go.mod
76+
go-version: ${{ matrix.go }}
6577
check-latest: true
6678

6779
- name: Run Benchmarks
6880
run: |
69-
echo "# Benchmark Results" >> $GITHUB_STEP_SUMMARY
81+
echo "# Benchmark Results for Go ${{ matrix.go }}" >> $GITHUB_STEP_SUMMARY
7082
echo '```' >> $GITHUB_STEP_SUMMARY
71-
go test -bench=. ./... 2>&1 | tee -a $GITHUB_STEP_SUMMARY
83+
go test -bench . -benchmem ./... 2>&1 | tee -a $GITHUB_STEP_SUMMARY
7284
echo '```' >> $GITHUB_STEP_SUMMARY
7385
7486
lint:

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
.PHONY: benchmark
22
benchmark:
3-
go test -bench=. ./...
3+
go test -bench . -benchmem ./...
44

55
.PHONY: cover
66
cover:

gomponents.go

Lines changed: 154 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ func (n NodeFunc) Render(w io.Writer) error {
5555
return n(w)
5656
}
5757

58-
// Type satisfies nodeTypeDescriber.
58+
// Type satisfies [nodeTypeDescriber].
5959
func (n NodeFunc) Type() NodeType {
6060
return ElementType
6161
}
@@ -67,6 +67,12 @@ func (n NodeFunc) String() string {
6767
return b.String()
6868
}
6969

70+
var (
71+
lt = []byte("<")
72+
gt = []byte(">")
73+
ltSlash = []byte("</")
74+
)
75+
7076
// El creates an element DOM [Node] with a name and child Nodes.
7177
// See https://dev.w3.org/html5/spec-LC/syntax.html#elements-0 for how elements are rendered.
7278
// No tags are ever omitted from normal tags, even though it's allowed for elements given at
@@ -75,76 +81,99 @@ func (n NodeFunc) String() string {
7581
// Use this if no convenience creator exists in the html package.
7682
func El(name string, children ...Node) Node {
7783
return NodeFunc(func(w io.Writer) error {
78-
return render(w, &name, children...)
79-
})
80-
}
84+
var err error
85+
86+
sw, ok := w.(io.StringWriter)
8187

82-
func render(w2 io.Writer, name *string, children ...Node) error {
83-
w := &statefulWriter{w: w2}
88+
if _, err = w.Write(lt); err != nil {
89+
return err
90+
}
8491

85-
if name != nil {
86-
w.Write([]byte("<" + *name))
92+
if ok {
93+
if _, err = sw.WriteString(name); err != nil {
94+
return err
95+
}
96+
} else {
97+
if _, err = w.Write([]byte(name)); err != nil {
98+
return err
99+
}
100+
}
87101

88102
for _, c := range children {
89-
renderChild(w, c, AttributeType)
103+
if err = renderChild(w, c, AttributeType); err != nil {
104+
return err
105+
}
90106
}
91107

92-
w.Write([]byte(">"))
108+
if _, err = w.Write(gt); err != nil {
109+
return err
110+
}
93111

94-
if isVoidElement(*name) {
95-
return w.err
112+
if isVoidElement(name) {
113+
return nil
96114
}
97-
}
98115

99-
for _, c := range children {
100-
renderChild(w, c, ElementType)
101-
}
116+
for _, c := range children {
117+
if err = renderChild(w, c, ElementType); err != nil {
118+
return err
119+
}
120+
}
102121

103-
if name != nil {
104-
w.Write([]byte("</" + *name + ">"))
105-
}
122+
if _, err = w.Write(ltSlash); err != nil {
123+
return err
124+
}
125+
126+
if ok {
127+
if _, err = sw.WriteString(name); err != nil {
128+
return err
129+
}
130+
} else {
131+
if _, err = w.Write([]byte(name)); err != nil {
132+
return err
133+
}
134+
}
106135

107-
return w.err
136+
if _, err = w.Write(gt); err != nil {
137+
return err
138+
}
139+
140+
return nil
141+
})
108142
}
109143

110-
// renderChild c to the given writer w if the node type is t.
111-
func renderChild(w *statefulWriter, c Node, t NodeType) {
112-
if w.err != nil || c == nil {
113-
return
144+
// renderChild c to the given writer w if the node type is desiredType.
145+
func renderChild(w io.Writer, c Node, desiredType NodeType) error {
146+
if c == nil {
147+
return nil
114148
}
115149

116150
// Rendering groups like this is still important even though a group can render itself,
117151
// since otherwise attributes will sometimes be ignored.
118152
if g, ok := c.(Group); ok {
119153
for _, groupC := range g {
120-
renderChild(w, groupC, t)
154+
if err := renderChild(w, groupC, desiredType); err != nil {
155+
return err
156+
}
121157
}
122-
return
158+
return nil
123159
}
124160

125-
switch t {
161+
switch desiredType {
126162
case ElementType:
127-
if p, ok := c.(nodeTypeDescriber); !ok || p.Type() == ElementType {
128-
w.err = c.Render(w.w)
163+
if p, ok := c.(nodeTypeDescriber); !ok || p.Type() == desiredType {
164+
if err := c.Render(w); err != nil {
165+
return err
166+
}
129167
}
130168
case AttributeType:
131-
if p, ok := c.(nodeTypeDescriber); ok && p.Type() == AttributeType {
132-
w.err = c.Render(w.w)
169+
if p, ok := c.(nodeTypeDescriber); ok && p.Type() == desiredType {
170+
if err := c.Render(w); err != nil {
171+
return err
172+
}
133173
}
134174
}
135-
}
136175

137-
// statefulWriter only writes if no errors have occurred earlier in its lifetime.
138-
type statefulWriter struct {
139-
w io.Writer
140-
err error
141-
}
142-
143-
func (w *statefulWriter) Write(p []byte) {
144-
if w.err != nil {
145-
return
146-
}
147-
_, w.err = w.w.Write(p)
176+
return nil
148177
}
149178

150179
// voidElements don't have end tags and must be treated differently in the rendering.
@@ -173,44 +202,85 @@ func isVoidElement(name string) bool {
173202
return ok
174203
}
175204

205+
var (
206+
space = []byte(" ")
207+
equalQuote = []byte(`="`)
208+
quote = []byte(`"`)
209+
)
210+
176211
// Attr creates an attribute DOM [Node] with a name and optional value.
177212
// If only a name is passed, it's a name-only (boolean) attribute (like "required").
178213
// If a name and value are passed, it's a name-value attribute (like `class="header"`).
179214
// More than one value make [Attr] panic.
180215
// Use this if no convenience creator exists in the html package.
181216
func Attr(name string, value ...string) Node {
182-
switch len(value) {
183-
case 0:
184-
return &attr{name: name}
185-
case 1:
186-
return &attr{name: name, value: &value[0]}
187-
default:
217+
if len(value) > 1 {
188218
panic("attribute must be just name or name and value pair")
189219
}
190-
}
191220

192-
type attr struct {
193-
name string
194-
value *string
221+
return attrFunc(func(w io.Writer) error {
222+
var err error
223+
224+
sw, ok := w.(io.StringWriter)
225+
226+
if _, err = w.Write(space); err != nil {
227+
return err
228+
}
229+
230+
// Attribute name
231+
if ok {
232+
if _, err = sw.WriteString(name); err != nil {
233+
return err
234+
}
235+
} else {
236+
if _, err = w.Write([]byte(name)); err != nil {
237+
return err
238+
}
239+
}
240+
241+
if len(value) == 0 {
242+
return nil
243+
}
244+
245+
if _, err = w.Write(equalQuote); err != nil {
246+
return err
247+
}
248+
249+
// Attribute value
250+
if ok {
251+
if _, err = sw.WriteString(template.HTMLEscapeString(value[0])); err != nil {
252+
return err
253+
}
254+
} else {
255+
if _, err = w.Write([]byte(template.HTMLEscapeString(value[0]))); err != nil {
256+
return err
257+
}
258+
}
259+
260+
if _, err = w.Write(quote); err != nil {
261+
return err
262+
}
263+
264+
return nil
265+
})
195266
}
196267

268+
// attrFunc is a render function that is also a [Node] of [AttributeType].
269+
// It's basically the same as [NodeFunc], but for attributes.
270+
type attrFunc func(io.Writer) error
271+
197272
// Render satisfies [Node].
198-
func (a *attr) Render(w io.Writer) error {
199-
if a.value == nil {
200-
_, err := w.Write([]byte(" " + a.name))
201-
return err
202-
}
203-
_, err := w.Write([]byte(" " + a.name + `="` + template.HTMLEscapeString(*a.value) + `"`))
204-
return err
273+
func (a attrFunc) Render(w io.Writer) error {
274+
return a(w)
205275
}
206276

207277
// Type satisfies [nodeTypeDescriber].
208-
func (a *attr) Type() NodeType {
278+
func (a attrFunc) Type() NodeType {
209279
return AttributeType
210280
}
211281

212282
// String satisfies [fmt.Stringer].
213-
func (a *attr) String() string {
283+
func (a attrFunc) String() string {
214284
var b strings.Builder
215285
_ = a.Render(&b)
216286
return b.String()
@@ -219,6 +289,10 @@ func (a *attr) String() string {
219289
// Text creates a text DOM [Node] that Renders the escaped string t.
220290
func Text(t string) Node {
221291
return NodeFunc(func(w io.Writer) error {
292+
if w, ok := w.(io.StringWriter); ok {
293+
_, err := w.WriteString(template.HTMLEscapeString(t))
294+
return err
295+
}
222296
_, err := w.Write([]byte(template.HTMLEscapeString(t)))
223297
return err
224298
})
@@ -227,6 +301,10 @@ func Text(t string) Node {
227301
// Textf creates a text DOM [Node] that Renders the interpolated and escaped string format.
228302
func Textf(format string, a ...interface{}) Node {
229303
return NodeFunc(func(w io.Writer) error {
304+
if w, ok := w.(io.StringWriter); ok {
305+
_, err := w.WriteString(template.HTMLEscapeString(fmt.Sprintf(format, a...)))
306+
return err
307+
}
230308
_, err := w.Write([]byte(template.HTMLEscapeString(fmt.Sprintf(format, a...))))
231309
return err
232310
})
@@ -235,6 +313,10 @@ func Textf(format string, a ...interface{}) Node {
235313
// Raw creates a text DOM [Node] that just Renders the unescaped string t.
236314
func Raw(t string) Node {
237315
return NodeFunc(func(w io.Writer) error {
316+
if w, ok := w.(io.StringWriter); ok {
317+
_, err := w.WriteString(t)
318+
return err
319+
}
238320
_, err := w.Write([]byte(t))
239321
return err
240322
})
@@ -243,6 +325,10 @@ func Raw(t string) Node {
243325
// Rawf creates a text DOM [Node] that just Renders the interpolated and unescaped string format.
244326
func Rawf(format string, a ...interface{}) Node {
245327
return NodeFunc(func(w io.Writer) error {
328+
if w, ok := w.(io.StringWriter); ok {
329+
_, err := w.WriteString(fmt.Sprintf(format, a...))
330+
return err
331+
}
246332
_, err := fmt.Fprintf(w, format, a...)
247333
return err
248334
})
@@ -271,7 +357,12 @@ func (g Group) String() string {
271357

272358
// Render satisfies [Node].
273359
func (g Group) Render(w io.Writer) error {
274-
return render(w, nil, g...)
360+
for _, c := range g {
361+
if err := renderChild(w, c, ElementType); err != nil {
362+
return err
363+
}
364+
}
365+
return nil
275366
}
276367

277368
// If condition is true, return the given [Node]. Otherwise, return nil.

0 commit comments

Comments
 (0)