replacer: evaluate format once and improve perf by ~3x (#3002)

* replacer: evaluate format once and improve perf by ~3x

This improves the performance of logging by almost 3x and reduces memory
usage by ~8x.

Benchmark results:

benchmark                                old ns/op     new ns/op     delta
BenchmarkReplacer-12                     644           324           -49.69%
BenchmarkReplacer_CommonLogFormat-12     4228          1471          -65.21%

benchmark                                old allocs     new allocs     delta
BenchmarkReplacer-12                     8              2              -75.00%
BenchmarkReplacer_CommonLogFormat-12     51             17             -66.67%

benchmark                                old bytes     new bytes     delta
BenchmarkReplacer-12                     240           48            -80.00%
BenchmarkReplacer_CommonLogFormat-12     3723          446           -88.02%

* replacer: code review comments

* bufPool: document why we use a pointer to a slice
* parseFormat: fix confusing comment
* TestParseFormat_Nodes: rename to TestParseFormatNodes

* replacer: use a value for bufPool instead of a pointer

* replacer: remove comment

* replacer: replace labels with knownLabels

The previous slice of label names is no longer needed.
This commit is contained in:
Charlie Vieth 2019-07-17 02:57:46 -04:00 committed by Miek Gieben
parent 21e9c6047b
commit a2af651ecb
2 changed files with 453 additions and 180 deletions

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/coredns/coredns/plugin/metadata" "github.com/coredns/coredns/plugin/metadata"
@ -14,195 +15,262 @@ import (
) )
// Replacer replaces labels for values in strings. // Replacer replaces labels for values in strings.
type Replacer struct { type Replacer struct{}
valueFunc func(request.Request, *dnstest.Recorder, string) string
labels []string
}
// labels are all supported labels that can be used in the default Replacer. // New makes a new replacer. This only needs to be called once in the setup and
var labels = []string{ // then call Replace for each incoming message. A replacer is safe for concurrent use.
"{type}",
"{name}",
"{class}",
"{proto}",
"{size}",
"{remote}",
"{port}",
"{local}",
// Header values.
headerReplacer + "id}",
headerReplacer + "opcode}",
headerReplacer + "do}",
headerReplacer + "bufsize}",
// Recorded replacements.
"{rcode}",
"{rsize}",
"{duration}",
headerReplacer + "rflags}",
}
// value returns the current value of label.
func value(state request.Request, rr *dnstest.Recorder, label string) string {
switch label {
case "{type}":
return state.Type()
case "{name}":
return state.Name()
case "{class}":
return state.Class()
case "{proto}":
return state.Proto()
case "{size}":
return strconv.Itoa(state.Req.Len())
case "{remote}":
return addrToRFC3986(state.IP())
case "{port}":
return state.Port()
case "{local}":
return addrToRFC3986(state.LocalIP())
// Header placeholders (case-insensitive).
case headerReplacer + "id}":
return strconv.Itoa(int(state.Req.Id))
case headerReplacer + "opcode}":
return strconv.Itoa(state.Req.Opcode)
case headerReplacer + "do}":
return boolToString(state.Do())
case headerReplacer + "bufsize}":
return strconv.Itoa(state.Size())
// Recorded replacements.
case "{rcode}":
if rr == nil {
return EmptyValue
}
rcode := dns.RcodeToString[rr.Rcode]
if rcode == "" {
rcode = strconv.Itoa(rr.Rcode)
}
return rcode
case "{rsize}":
if rr == nil {
return EmptyValue
}
return strconv.Itoa(rr.Len)
case "{duration}":
if rr == nil {
return EmptyValue
}
return strconv.FormatFloat(time.Since(rr.Start).Seconds(), 'f', -1, 64) + "s"
case headerReplacer + "rflags}":
if rr != nil && rr.Msg != nil {
return flagsToString(rr.Msg.MsgHdr)
}
return EmptyValue
}
return EmptyValue
}
// New makes a new replacer. This only needs to be called once in the setup and then call Replace for each incoming message.
// A replacer is safe for concurrent use.
func New() Replacer { func New() Replacer {
return Replacer{ return Replacer{}
valueFunc: value,
labels: labels,
}
} }
// Replace performs a replacement of values on s and returns the string with the replaced values. // Replace performs a replacement of values on s and returns the string with the replaced values.
func (r Replacer) Replace(ctx context.Context, state request.Request, rr *dnstest.Recorder, s string) string { func (r Replacer) Replace(ctx context.Context, state request.Request, rr *dnstest.Recorder, s string) string {
for _, placeholder := range r.labels { return loadFormat(s).Replace(ctx, state, rr)
if strings.Contains(s, placeholder) {
s = strings.Replace(s, placeholder, r.valueFunc(state, rr, placeholder), -1)
}
}
// Metadata label replacements. Scan for {/ and search for next }, replace that metadata label with
// any meta data that is available.
b := strings.Builder{}
for strings.Contains(s, labelReplacer) {
idxStart := strings.Index(s, labelReplacer)
endOffset := idxStart + len(labelReplacer)
idxEnd := strings.Index(s[endOffset:], "}")
if idxEnd > -1 {
label := s[idxStart+2 : endOffset+idxEnd]
fm := metadata.ValueFunc(ctx, label)
replacement := EmptyValue
if fm != nil {
replacement = fm()
}
b.WriteString(s[:idxStart])
b.WriteString(replacement)
s = s[endOffset+idxEnd+1:]
} else {
break
}
}
b.WriteString(s)
return b.String()
}
func boolToString(b bool) string {
if b {
return "true"
}
return "false"
}
// flagsToString checks all header flags and returns those
// that are set as a string separated with commas
func flagsToString(h dns.MsgHdr) string {
flags := make([]string, 7)
i := 0
if h.Response {
flags[i] = "qr"
i++
}
if h.Authoritative {
flags[i] = "aa"
i++
}
if h.Truncated {
flags[i] = "tc"
i++
}
if h.RecursionDesired {
flags[i] = "rd"
i++
}
if h.RecursionAvailable {
flags[i] = "ra"
i++
}
if h.Zero {
flags[i] = "z"
i++
}
if h.AuthenticatedData {
flags[i] = "ad"
i++
}
if h.CheckingDisabled {
flags[i] = "cd"
i++
}
return strings.Join(flags[:i], ",")
}
// addrToRFC3986 will add brackets to the address if it is an IPv6 address.
func addrToRFC3986(addr string) string {
if strings.Contains(addr, ":") {
return "[" + addr + "]"
}
return addr
} }
const ( const (
headerReplacer = "{>" headerReplacer = "{>"
labelReplacer = "{/"
// EmptyValue is the default empty value. // EmptyValue is the default empty value.
EmptyValue = "-" EmptyValue = "-"
) )
// labels are all supported labels that can be used in the default Replacer.
var labels = map[string]struct{}{
"{type}": {},
"{name}": {},
"{class}": {},
"{proto}": {},
"{size}": {},
"{remote}": {},
"{port}": {},
"{local}": {},
// Header values.
headerReplacer + "id}": {},
headerReplacer + "opcode}": {},
headerReplacer + "do}": {},
headerReplacer + "bufsize}": {},
// Recorded replacements.
"{rcode}": {},
"{rsize}": {},
"{duration}": {},
headerReplacer + "rflags}": {},
}
// appendValue appends the current value of label.
func appendValue(b []byte, state request.Request, rr *dnstest.Recorder, label string) []byte {
switch label {
case "{type}":
return append(b, state.Type()...)
case "{name}":
return append(b, state.Name()...)
case "{class}":
return append(b, state.Class()...)
case "{proto}":
return append(b, state.Proto()...)
case "{size}":
return strconv.AppendInt(b, int64(state.Req.Len()), 10)
case "{remote}":
return appendAddrToRFC3986(b, state.IP())
case "{port}":
return append(b, state.Port()...)
case "{local}":
return appendAddrToRFC3986(b, state.LocalIP())
// Header placeholders (case-insensitive).
case headerReplacer + "id}":
return strconv.AppendInt(b, int64(state.Req.Id), 10)
case headerReplacer + "opcode}":
return strconv.AppendInt(b, int64(state.Req.Opcode), 10)
case headerReplacer + "do}":
return strconv.AppendBool(b, state.Do())
case headerReplacer + "bufsize}":
return strconv.AppendInt(b, int64(state.Size()), 10)
// Recorded replacements.
case "{rcode}":
if rr == nil {
return append(b, EmptyValue...)
}
if rcode := dns.RcodeToString[rr.Rcode]; rcode != "" {
return append(b, rcode...)
}
return strconv.AppendInt(b, int64(rr.Rcode), 10)
case "{rsize}":
if rr == nil {
return append(b, EmptyValue...)
}
return strconv.AppendInt(b, int64(rr.Len), 10)
case "{duration}":
if rr == nil {
return append(b, EmptyValue...)
}
secs := time.Since(rr.Start).Seconds()
return append(strconv.AppendFloat(b, secs, 'f', -1, 64), 's')
case headerReplacer + "rflags}":
if rr != nil && rr.Msg != nil {
return appendFlags(b, rr.Msg.MsgHdr)
}
return append(b, EmptyValue...)
default:
return append(b, EmptyValue...)
}
}
// appendFlags checks all header flags and appends those
// that are set as a string separated with commas
func appendFlags(b []byte, h dns.MsgHdr) []byte {
origLen := len(b)
if h.Response {
b = append(b, "qr,"...)
}
if h.Authoritative {
b = append(b, "aa,"...)
}
if h.Truncated {
b = append(b, "tc,"...)
}
if h.RecursionDesired {
b = append(b, "rd,"...)
}
if h.RecursionAvailable {
b = append(b, "ra,"...)
}
if h.Zero {
b = append(b, "z,"...)
}
if h.AuthenticatedData {
b = append(b, "ad,"...)
}
if h.CheckingDisabled {
b = append(b, "cd,"...)
}
if n := len(b); n > origLen {
return b[:n-1] // trim trailing ','
}
return b
}
// appendAddrToRFC3986 will add brackets to the address if it is an IPv6 address.
func appendAddrToRFC3986(b []byte, addr string) []byte {
if strings.IndexByte(addr, ':') != -1 {
b = append(b, '[')
b = append(b, addr...)
b = append(b, ']')
} else {
b = append(b, addr...)
}
return b
}
type nodeType int
const (
typeLabel nodeType = iota // "{type}"
typeLiteral // "foo"
typeMetadata // "{/metadata}"
)
// A node represents a segment of a parsed format. For example: "A {type}"
// contains two nodes: "A " (literal); and "{type}" (label).
type node struct {
value string // Literal value, label or metadata label
typ nodeType
}
// A replacer is an ordered list of all the nodes in a format.
type replacer []node
func parseFormat(s string) replacer {
// Assume there is a literal between each label - its cheaper to over
// allocate once than allocate twice.
rep := make(replacer, 0, strings.Count(s, "{")*2)
for {
// We find the right bracket then backtrack to find the left bracket.
// This allows us to handle formats like: "{ {foo} }".
j := strings.IndexByte(s, '}')
if j < 0 {
break
}
i := strings.LastIndexByte(s[:j], '{')
if i < 0 {
// Handle: "A } {foo}" by treating "A }" as a literal
rep = append(rep, node{
value: s[:j+1],
typ: typeLiteral,
})
s = s[j+1:]
continue
}
val := s[i : j+1]
var typ nodeType
switch _, ok := labels[val]; {
case ok:
typ = typeLabel
case strings.HasPrefix(val, "{/"):
// Strip "{/}" from metadata labels
val = val[2 : len(val)-1]
typ = typeMetadata
default:
// Given: "A {X}" val is "{X}" expand it to the whole literal.
val = s[:j+1]
typ = typeLiteral
}
// Append any leading literal. Given "A {type}" the literal is "A "
if i != 0 && typ != typeLiteral {
rep = append(rep, node{
value: s[:i],
typ: typeLiteral,
})
}
rep = append(rep, node{
value: val,
typ: typ,
})
s = s[j+1:]
}
if len(s) != 0 {
rep = append(rep, node{
value: s,
typ: typeLiteral,
})
}
return rep
}
var replacerCache sync.Map // map[string]replacer
func loadFormat(s string) replacer {
if v, ok := replacerCache.Load(s); ok {
return v.(replacer)
}
v, _ := replacerCache.LoadOrStore(s, parseFormat(s))
return v.(replacer)
}
// bufPool stores pointers to scratch buffers.
var bufPool = sync.Pool{
New: func() interface{} {
return make([]byte, 0, 256)
},
}
func (r replacer) Replace(ctx context.Context, state request.Request, rr *dnstest.Recorder) string {
b := bufPool.Get().([]byte)
for _, s := range r {
switch s.typ {
case typeLabel:
b = appendValue(b, state, rr, s.value)
case typeLiteral:
b = append(b, s.value...)
case typeMetadata:
if fm := metadata.ValueFunc(ctx, s.value); fm != nil {
b = append(b, fm()...)
} else {
b = append(b, EmptyValue...)
}
}
}
s := string(b)
bufPool.Put(b[:0])
return s
}

View file

@ -2,6 +2,8 @@ package replacer
import ( import (
"context" "context"
"reflect"
"strings"
"testing" "testing"
"github.com/coredns/coredns/plugin/metadata" "github.com/coredns/coredns/plugin/metadata"
@ -12,6 +14,9 @@ import (
"github.com/miekg/dns" "github.com/miekg/dns"
) )
// This is the default format used by the log package
const CommonLogFormat = `{remote}:{port} - {>id} "{type} {class} {name} {proto} {size} {>do} {>bufsize}" {rcode} {>rflags} {rsize} {duration}`
func TestReplacer(t *testing.T) { func TestReplacer(t *testing.T) {
w := dnstest.NewRecorder(&test.ResponseWriter{}) w := dnstest.NewRecorder(&test.ResponseWriter{})
r := new(dns.Msg) r := new(dns.Msg)
@ -32,6 +37,178 @@ func TestReplacer(t *testing.T) {
} }
} }
func TestParseFormat(t *testing.T) {
type formatTest struct {
Format string
Expected replacer
}
tests := []formatTest{
{
Format: "",
Expected: replacer{},
},
{
Format: "A",
Expected: replacer{
{"A", typeLiteral},
},
},
{
Format: "A {A}",
Expected: replacer{
{"A {A}", typeLiteral},
},
},
{
Format: "{{remote}}",
Expected: replacer{
{"{", typeLiteral},
{"{remote}", typeLabel},
{"}", typeLiteral},
},
},
{
Format: "{ A {remote} A }",
Expected: replacer{
{"{ A ", typeLiteral},
{"{remote}", typeLabel},
{" A }", typeLiteral},
},
},
{
Format: "{remote}}",
Expected: replacer{
{"{remote}", typeLabel},
{"}", typeLiteral},
},
},
{
Format: "{{remote}",
Expected: replacer{
{"{", typeLiteral},
{"{remote}", typeLabel},
},
},
{
Format: `Foo } {remote}`,
Expected: replacer{
// we don't do any optimizations to join adjacent literals
{"Foo }", typeLiteral},
{" ", typeLiteral},
{"{remote}", typeLabel},
},
},
{
Format: `{ Foo`,
Expected: replacer{
{"{ Foo", typeLiteral},
},
},
{
Format: `} Foo`,
Expected: replacer{
{"}", typeLiteral},
{" Foo", typeLiteral},
},
},
{
Format: "A { {remote} {type} {/meta1} } B",
Expected: replacer{
{"A { ", typeLiteral},
{"{remote}", typeLabel},
{" ", typeLiteral},
{"{type}", typeLabel},
{" ", typeLiteral},
{"meta1", typeMetadata},
{" }", typeLiteral},
{" B", typeLiteral},
},
},
{
Format: `LOG {remote}:{port} - {>id} "{type} {class} {name} {proto} ` +
`{size} {>do} {>bufsize}" {rcode} {>rflags} {rsize} {/meta1}-{/meta2} ` +
`{duration} END OF LINE`,
Expected: replacer{
{"LOG ", typeLiteral},
{"{remote}", typeLabel},
{":", typeLiteral},
{"{port}", typeLabel},
{" - ", typeLiteral},
{"{>id}", typeLabel},
{` "`, typeLiteral},
{"{type}", typeLabel},
{" ", typeLiteral},
{"{class}", typeLabel},
{" ", typeLiteral},
{"{name}", typeLabel},
{" ", typeLiteral},
{"{proto}", typeLabel},
{" ", typeLiteral},
{"{size}", typeLabel},
{" ", typeLiteral},
{"{>do}", typeLabel},
{" ", typeLiteral},
{"{>bufsize}", typeLabel},
{`" `, typeLiteral},
{"{rcode}", typeLabel},
{" ", typeLiteral},
{"{>rflags}", typeLabel},
{" ", typeLiteral},
{"{rsize}", typeLabel},
{" ", typeLiteral},
{"meta1", typeMetadata},
{"-", typeLiteral},
{"meta2", typeMetadata},
{" ", typeLiteral},
{"{duration}", typeLabel},
{" END OF LINE", typeLiteral},
},
},
}
for i, x := range tests {
r := parseFormat(x.Format)
if !reflect.DeepEqual(r, x.Expected) {
t.Errorf("%d: Expected:\n\t%+v\nGot:\n\t%+v", i, x.Expected, r)
}
}
}
func TestParseFormatNodes(t *testing.T) {
// If we parse the format successfully the result of joining all the
// segments should match the original format.
formats := []string{
"",
"msg",
"{remote}",
"{remote}",
"{{remote}",
"{{remote}}",
"{{remote}} A",
CommonLogFormat,
CommonLogFormat + " FOO} {BAR}",
"A " + CommonLogFormat + " FOO} {BAR}",
"A " + CommonLogFormat + " {/meta}",
}
join := func(r replacer) string {
a := make([]string, len(r))
for i, n := range r {
if n.typ == typeMetadata {
a[i] = "{/" + n.value + "}"
} else {
a[i] = n.value
}
}
return strings.Join(a, "")
}
for _, format := range formats {
r := parseFormat(format)
s := join(r)
if s != format {
t.Errorf("Expected format to be: '%s' got: '%s'", format, s)
}
}
}
func TestLabels(t *testing.T) { func TestLabels(t *testing.T) {
w := dnstest.NewRecorder(&test.ResponseWriter{}) w := dnstest.NewRecorder(&test.ResponseWriter{})
r := new(dns.Msg) r := new(dns.Msg)
@ -68,7 +245,7 @@ func TestLabels(t *testing.T) {
t.Fatalf("Expect %d labels, got %d", len(expect), len(labels)) t.Fatalf("Expect %d labels, got %d", len(expect), len(labels))
} }
for _, lbl := range labels { for lbl := range labels {
repl := replacer.Replace(ctx, state, w, lbl) repl := replacer.Replace(ctx, state, w, lbl)
if lbl == "{duration}" { if lbl == "{duration}" {
if repl[len(repl)-1] != 's' { if repl[len(repl)-1] != 's' {
@ -98,6 +275,34 @@ func BenchmarkReplacer(b *testing.B) {
} }
} }
func BenchmarkReplacer_CommonLogFormat(b *testing.B) {
w := dnstest.NewRecorder(&test.ResponseWriter{})
r := new(dns.Msg)
r.SetQuestion("example.org.", dns.TypeHINFO)
r.Id = 1053
r.AuthenticatedData = true
r.CheckingDisabled = true
r.MsgHdr.AuthenticatedData = true
w.WriteMsg(r)
state := request.Request{W: w, Req: r}
replacer := New()
ctxt := context.TODO()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
replacer.Replace(ctxt, state, w, CommonLogFormat)
}
}
func BenchmarkParseFormat(b *testing.B) {
for i := 0; i < b.N; i++ {
parseFormat(CommonLogFormat)
}
}
type testProvider map[string]metadata.Func type testProvider map[string]metadata.Func
func (tp testProvider) Metadata(ctx context.Context, state request.Request) context.Context { func (tp testProvider) Metadata(ctx context.Context, state request.Request) context.Context {