bufio: add fast path to WriteString for strings that fit in the buffer
WriteString sets up its io.StringWriter fallback before checking
whether the string simply fits in the remaining buffer space, which
is the common case. The setup is not free: the interface value is
zeroed and state is spilled on every call, including the calls that
never use the fallback, making WriteString measurably slower than
Write for an identical payload that fits.
Handle the fits-in-buffer case first. The fallback loop then only
runs when the string does not fit, so its condition is known true on
entry: test it at the bottom of the loop instead so it is not
evaluated twice. The fallback path gets slightly faster as well.
goos: darwin
goarch: arm64
pkg: bufio
cpu: Apple M2 Pro
│ old │ new │
│ sec/op │ sec/op vs base │
WriterCopyOptimal-10 51.54n ± 0% 51.67n ± 0% ~ (p=0.233 n=25)
WriterCopyUnoptimal-10 52.42n ± 1% 52.42n ± 1% ~ (p=0.528 n=25)
WriterCopyNoReadFrom-10 2.329µ ± 1% 2.325µ ± 1% ~ (p=0.690 n=25)
WriterEmpty-10 483.1n ± 1% 480.6n ± 1% ~ (p=0.567 n=25)
WriterFlush-10 5.262n ± 0% 5.025n ± 1% -4.50% (p=0.000 n=25)
WriteString/fit-10 4.873n ± 1% 3.783n ± 0% -22.37% (p=0.000 n=25)
WriteString/overflow-10 5.993n ± 0% 5.697n ± 0% -4.94% (p=0.000 n=25)
geomean 46.46n 44.17n -4.94%
B/op and allocs/op are unchanged.
Fixes #80692
diff --git a/src/bufio/bufio.go b/src/bufio/bufio.go
index f9b762b..5f0f9bb 100644
--- a/src/bufio/bufio.go
+++ b/src/bufio/bufio.go
@@ -745,11 +745,22 @@
// If the count is less than len(s), it also returns an error explaining
// why the write is short.
func (b *Writer) WriteString(s string) (int, error) {
+ if b.err == nil && len(s) <= b.Available() {
+ // Fast path: the whole string fits in the buffer.
+ n := copy(b.buf[b.n:], s)
+ b.n += n
+ return n, nil
+ }
+
+ if b.err != nil {
+ return 0, b.err
+ }
+
var sw io.StringWriter
tryStringWriter := true
nn := 0
- for len(s) > b.Available() && b.err == nil {
+ for {
var n int
if b.Buffered() == 0 && sw == nil && tryStringWriter {
// Check at most once whether b.wr is a StringWriter.
@@ -767,6 +778,9 @@
}
nn += n
s = s[n:]
+ if len(s) <= b.Available() || b.err != nil {
+ break
+ }
}
if b.err != nil {
return nn, b.err
diff --git a/src/bufio/bufio_test.go b/src/bufio/bufio_test.go
index 742e195..4fb3d9e 100644
--- a/src/bufio/bufio_test.go
+++ b/src/bufio/bufio_test.go
@@ -1997,3 +1997,20 @@
bw.Flush()
}
}
+
+func BenchmarkWriteString(b *testing.B) {
+ fit := strings.Repeat("x", 50)
+ overflow := strings.Repeat("x", 8<<10)
+ for _, tc := range []struct{ name, s string }{
+ {"fit", fit},
+ {"overflow", overflow},
+ } {
+ b.Run(tc.name, func(b *testing.B) {
+ b.ReportAllocs()
+ bw := NewWriter(io.Discard)
+ for i := 0; i < b.N; i++ {
+ bw.WriteString(tc.s)
+ }
+ })
+ }
+}
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
if len(s) <= b.Available() || b.err != nil {pushed the conditional down here bc re-evaluating at the top had a benchmark regression of ~5%
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
This change makes the function longer and harder to understand, to avoid compiler issues. I think it would be better to tweak the compiler.
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
| Code-Review | +2 |
| Commit-Queue | +1 |
Thanks
if b.err != nil {
return 0, b.err
}Shouldn't that come first ?
If the buffer has a pending error it's a bit weird to accept a write.
func BenchmarkWriteString(b *testing.B) {
fit := strings.Repeat("x", 50)
overflow := strings.Repeat("x", 8<<10)
for _, tc := range []struct{ name, s string }{
{"fit", fit},
{"overflow", overflow},```suggestion
func BenchmarkWriteString(b *testing.B) {
for _, tc := range []struct{ name, s string }{
{"small", strings.Repeat("x", 50)},
{"huge", strings.Repeat("x", 8<<10)},
```
Given you don't create a new buffer on each iteration, the small string doesn't always fit.I think it's clearer to name it small and huge.
for i := 0; i < b.N; i++ {```suggestion
for range b.N {
```
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
if b.err != nil {
return 0, b.err
}Shouldn't that come first ?
If the buffer has a pending error it's a bit weird to accept a write.
good call swapped to `b.err` comparison first
func BenchmarkWriteString(b *testing.B) {
fit := strings.Repeat("x", 50)
overflow := strings.Repeat("x", 8<<10)
for _, tc := range []struct{ name, s string }{
{"fit", fit},
{"overflow", overflow},```suggestion
func BenchmarkWriteString(b *testing.B) {
for _, tc := range []struct{ name, s string }{
{"small", strings.Repeat("x", 50)},
{"huge", strings.Repeat("x", 8<<10)},
```
Given you don't create a new buffer on each iteration, the small string doesn't always fit.I think it's clearer to name it small and huge.
updated to small/huge
for i := 0; i < b.N; i++ {David Teather```suggestion
for range b.N {
```
swapped to `b.Loop()` looks like that's current best practice https://go.dev/blog/testing-b-loop
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
| Auto-Submit | +1 |
| Code-Review | +2 |
| Commit-Queue | +1 |
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
Thanks for contributing this optimization.
// Fast path: the whole string fits in the buffer.
n := copy(b.buf[b.n:], s)
b.n += n
return n, nilOnce we've applied the change at L783, can we also factor this common tail with L788-... using something like this form?
```
func (b *Writer) WriteString(s string) (int, error) {
if b.err != nil {
return 0, b.err
}nn := 0
if len(s) > b.Available() {
if b.Buffered() == 0 {
if sw, ok := b.wr.(io.StringWriter); ok {
// Large write, empty buffer, and the underlying writer supports
// WriteString: forward the write to the underlying StringWriter.
// This avoids an extra copy.
var n int
n, b.err = sw.WriteString(s)
return n, b.err
}
}
// Buffer and flush each complete chunk.
for {
n := copy(b.buf[b.n:], s)
b.n += n
b.Flush() // ignore error
if len(s) <= b.Available() || b.err != nil {
break
}
nn += n
s = s[n:]
}
if b.err != nil {
return nn, b.err
}
}// The whole string (or final chunk) fits in the buffer.
n := copy(b.buf[b.n:], s)
b.n += n
nn += n
return nn, nil
}
```
for {Now that we have the precondition `len(s) > b.Available()`, we know the loop will execute at least once, so we can simplify by doing the one-time check for StringWriter eagerly, which may improve the non-fast case too.
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
Thanks for the review! Updated it and tried to refactor closer to the structure you provided but it had some perf regressions, but took some shapes from that which made it faster
// Fast path: the whole string fits in the buffer.Happy to make the change, but held off for now as it costs the fast path.
This is what I benched, the suggested shape on top of the current logic
```go
func (b *Writer) WriteString(s string) (int, error) {
if b.err != nil {
return 0, b.err
}
nn := 0
if len(s) > b.Available() {
sw, ok := b.wr.(io.StringWriter)
if ok && b.Buffered() == 0 {
// Large write, empty buffer, and the underlying writer supports
// WriteString: forward the write to the underlying StringWriter.
// This avoids an extra copy.
var n int
n, b.err = sw.WriteString(s)
if n == len(s) || b.err != nil {
return n, b.err
}
nn = n
s = s[n:]
}// Buffer and flush each complete chunk.
for {
var n int
if b.Buffered() == 0 && ok {
n, b.err = sw.WriteString(s)
} else {
n = copy(b.buf[b.n:], s)
b.n += n
b.Flush()
}
nn += n
s = s[n:]
if len(s) <= b.Available() || b.err != nil {
break
}
}
if b.err != nil {
return nn, b.err
}
}
n := copy(b.buf[b.n:], s)
b.n += n
nn += n
return nn, nil
}
```
`go test -run='^$' -bench=WriteString bufio`, n=30, that against the patchset:
```
│ CL │ refactor │
│ sec/op │ sec/op vs base │
WriteString/small-10 3.696n ± 1% 5.129n ± 2% +38.75% (p=0.000 n=30)
WriteString/huge-10 3.914n ± 1% 3.607n ± 0% -7.83% (p=0.000 n=30)
geomean 3.803n 4.301n +13.09%
```
It's mixed, but the absolute deltas are +1.43ns and -0.31ns. I'm not entirely sure why the effect is that large, I assume it's layout. Maybe the fast path stops being a straight-line return off the prologue.
Two things from your sketch I did take, and one I didn't.
I took the early return for the empty-buffer case. It needs the `n == len(s) || b.err != nil` guard. I tried it without, and hit cases where a short-writing StringWriter made WriteString return (n, nil) with n < len(s), dropping the rest of the string and breaking the doc's "if the count is less than len(s), it also returns an error". Write absorbs that by looping. Guarded as above it matches master exactly, and it's worth ~ -28% on `huge`.
I kept the StringWriter test inside the loop. Checking only on entry means a Writer with buffered data never reaches sw.WriteString at all: 242 -> 96 calls on the underlying writer across my matrix, and writing 4 bytes then 8 KiB regressed 81%. Write tests b.Buffered() == 0 inside its loop for the same reason.
Let me know what you think, or if I'm missing something on the refactor that might be hurting performance. Thanks!
Now that we have the precondition `len(s) > b.Available()`, we know the loop will execute at least once, so we can simplify by doing the one-time check for StringWriter eagerly, which may improve the non-fast case too.
Done. Hoisted the check above the loop. I also confirmed your guess about the non-fast case WriteString/huge got faster by ~5% more
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |