diff --git a/errors.go b/errors.go index e9d120991..188cc79b3 100644 --- a/errors.go +++ b/errors.go @@ -18,6 +18,7 @@ package quickfix import ( "errors" "fmt" + "strconv" ) // ErrDoNotSend is a convenience error to indicate a DoNotSend in ToApp. @@ -124,8 +125,9 @@ func ValueIsIncorrect(tag Tag) MessageRejectError { } // ConditionallyRequiredFieldMissing indicates that the requested field could not be found in the FIX message. +// Uses strconv.Itoa instead of fmt.Sprintf to avoid format string parsing overhead. func ConditionallyRequiredFieldMissing(tag Tag) MessageRejectError { - return NewBusinessMessageRejectError(fmt.Sprintf("Conditionally Required Field Missing (%d)", tag), rejectReasonConditionallyRequiredFieldMissing, &tag) + return NewBusinessMessageRejectError("Conditionally Required Field Missing ("+strconv.Itoa(int(tag))+")", rejectReasonConditionallyRequiredFieldMissing, &tag) } // valueIsIncorrectNoTag returns an error indicating a field with value that is not valid. diff --git a/errors_test.go b/errors_test.go index c5b77d9b6..de9cee0f7 100644 --- a/errors_test.go +++ b/errors_test.go @@ -295,3 +295,23 @@ func TestInvalidTagNumber(t *testing.T) { t.Error("Expected IsBusinessReject to be false\n") } } + +func TestRejectLogon(t *testing.T) { + rej := RejectLogon{Text: "logon rejected"} + + if rej.Error() != "logon rejected" { + t.Errorf("expected 'logon rejected', got: %s", rej.Error()) + } + if rej.RefTagID() != nil { + t.Error("expected nil RefTagID") + } + if rej.RejectReason() != 0 { + t.Errorf("expected 0, got: %d", rej.RejectReason()) + } + if rej.BusinessRejectRefID() != "" { + t.Errorf("expected empty string, got: %s", rej.BusinessRejectRefID()) + } + if rej.IsBusinessReject() { + t.Error("expected false") + } +} diff --git a/field_map.go b/field_map.go index 4aac64b1d..401ae2e0f 100644 --- a/field_map.go +++ b/field_map.go @@ -115,20 +115,6 @@ func (m FieldMap) GetField(tag Tag, parser FieldValueReader) MessageRejectError return nil } -// GetField parses of a field with Tag tag. Returned reject may indicate the field is not present, or the field value is invalid. -func (m FieldMap) getFieldNoLock(tag Tag, parser FieldValueReader) MessageRejectError { - f, ok := m.tagLookup[tag] - if !ok { - return ConditionallyRequiredFieldMissing(tag) - } - - if err := parser.Read(f[0].value); err != nil { - return IncorrectDataFormatForValue(tag) - } - - return nil -} - // GetBytes is a zero-copy GetField wrapper for []bytes fields. func (m FieldMap) GetBytes(tag Tag) ([]byte, MessageRejectError) { m.rwLock.RLock() @@ -196,7 +182,7 @@ func (m FieldMap) GetTime(tag Tag) (t time.Time, err MessageRejectError) { m.rwLock.RLock() defer m.rwLock.RUnlock() - bytes, err := m.GetBytes(tag) + bytes, err := m.getBytesNoLock(tag) if err != nil { return } @@ -210,21 +196,25 @@ func (m FieldMap) GetTime(tag Tag) (t time.Time, err MessageRejectError) { } // GetString is a GetField wrapper for string fields. +// Optimized to directly access tagLookup and convert bytes to string, +// avoiding the intermediate FIXString allocation that GetField would create. func (m FieldMap) GetString(tag Tag) (string, MessageRejectError) { - var val FIXString - if err := m.GetField(tag, &val); err != nil { - return "", err + m.rwLock.RLock() + f, ok := m.tagLookup[tag] + m.rwLock.RUnlock() + if !ok { + return "", ConditionallyRequiredFieldMissing(tag) } - return string(val), nil + return string(f[0].value), nil } -// GetString is a GetField wrapper for string fields. +// getStringNoLock is a lock-free GetField wrapper for string fields. func (m FieldMap) getStringNoLock(tag Tag) (string, MessageRejectError) { - var val FIXString - if err := m.getFieldNoLock(tag, &val); err != nil { - return "", err + f, ok := m.tagLookup[tag] + if !ok { + return "", ConditionallyRequiredFieldMissing(tag) } - return string(val), nil + return string(f[0].value), nil } // GetGroup is a Get function specific to Group Fields. diff --git a/field_map_test.go b/field_map_test.go index 0e9078734..efa0bb334 100644 --- a/field_map_test.go +++ b/field_map_test.go @@ -202,3 +202,23 @@ func TestFieldMap_Remove(t *testing.T) { assert.False(t, fMap.Has(1)) assert.True(t, fMap.Has(2)) } + +func TestFieldMap_Tags(t *testing.T) { + var fMap FieldMap + fMap.init() + + fMap.SetField(1, FIXString("hello")) + fMap.SetField(2, FIXString("world")) + fMap.SetField(44, FIXString("price")) + + tags := fMap.Tags() + assert.Len(t, tags, 3) + + tagSet := make(map[Tag]bool) + for _, tag := range tags { + tagSet[tag] = true + } + assert.True(t, tagSet[Tag(1)]) + assert.True(t, tagSet[Tag(2)]) + assert.True(t, tagSet[Tag(44)]) +} diff --git a/message.go b/message.go index 35e2ff675..ec3d25e6f 100644 --- a/message.go +++ b/message.go @@ -19,11 +19,23 @@ import ( "bytes" "fmt" "math" + "sync" "time" "github.com/quickfixgo/quickfix/datadictionary" ) +// messagePool provides reusable Message objects to reduce allocations. +var messagePool = sync.Pool{ + New: func() interface{} { + m := &Message{} + m.Header.Init() + m.Body.Init() + m.Trailer.Init() + return m + }, +} + // Header is first section of a FIX Message. type Header struct{ FieldMap } @@ -139,6 +151,28 @@ func NewMessage() *Message { return m } +// AcquireMessage returns a Message from the pool, reducing allocations. +// The returned Message must be released with ReleaseMessage when no longer needed. +func AcquireMessage() *Message { + return messagePool.Get().(*Message) +} + +// ReleaseMessage returns a Message to the pool for reuse. +// The Message should not be used after calling this function. +func ReleaseMessage(m *Message) { + if m == nil { + return + } + m.Header.Clear() + m.Body.Clear() + m.Trailer.Clear() + m.rawMessage = nil + m.bodyBytes = nil + m.fields = m.fields[:0] + m.ReceiveTime = time.Time{} + messagePool.Put(m) +} + // CopyInto erases the dest messages and copies the currency message content // into it. func (m *Message) CopyInto(to *Message) { diff --git a/message_test.go b/message_test.go index b02508cd9..16fbe019d 100644 --- a/message_test.go +++ b/message_test.go @@ -515,3 +515,71 @@ func checkFieldString(s *MessageSuite, fields FieldMap, tag int, expected string s.NoError(err) s.Equal(expected, toCheck) } + +func BenchmarkParseMessageNew(b *testing.B) { + // SOH = 0x01 is the FIX field delimiter + rawMsgStr := "8=FIX.4.2\x019=104\x0135=D\x0134=2\x0149=TW\x0152=20140515-19:49:56.659\x0156=ISLD\x0111=100\x0121=1\x0140=1\x0154=1\x0155=TSLA\x0160=00010101-00:00:00.000\x0110=039\x01" + + for i := 0; i < b.N; i++ { + msg := NewMessage() + rawMsg := bytes.NewBufferString(rawMsgStr) + _ = ParseMessage(msg, rawMsg) + } +} + +func BenchmarkGetString(b *testing.B) { + // SOH = 0x01 is the FIX field delimiter + rawMsgStr := "8=FIX.4.2\x019=104\x0135=D\x0134=2\x0149=TW\x0152=20140515-19:49:56.659\x0156=ISLD\x0111=100\x0121=1\x0140=1\x0154=1\x0155=TSLA\x0160=00010101-00:00:00.000\x0110=039\x01" + msg := NewMessage() + rawMsg := bytes.NewBufferString(rawMsgStr) + if err := ParseMessage(msg, rawMsg); err != nil { + b.Fatalf("ParseMessage failed: %v", err) + } + + // Verify tags exist before benchmarking + if !msg.Header.Has(tagBeginString) { + b.Fatal("tagBeginString not found") + } + if !msg.Header.Has(tagSenderCompID) { + b.Fatal("tagSenderCompID not found") + } + if !msg.Header.Has(tagTargetCompID) { + b.Fatal("tagTargetCompID not found") + } + if !msg.Body.Has(Tag(55)) { + b.Fatal("tag 55 not found") + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = msg.Header.GetString(tagBeginString) + _, _ = msg.Header.GetString(tagSenderCompID) + _, _ = msg.Header.GetString(tagTargetCompID) + _, _ = msg.Body.GetString(Tag(55)) + } +} + +func BenchmarkRepeatingGroupRead(b *testing.B) { + // Message with repeating group, SOH = 0x01 is the FIX field delimiter + rawMsgStr := "8=FIX.4.4\x019=165\x0135=D\x0134=2\x0149=01001\x0150=01001a\x0152=20231231-20:19:41\x0156=TEST\x011=acct1\x0111=13976\x0121=1\x0138=1\x0140=2\x0144=12\x0154=1\x0155=SYMABC\x0159=0\x0160=20231231-20:19:41\x01453=1\x01448=4501\x01447=D\x01452=28\x0110=026\x01" + + dict, _ := datadictionary.Parse("spec/FIX44.xml") + msg := NewMessage() + + for i := 0; i < b.N; i++ { + rawMsg := bytes.NewBufferString(rawMsgStr) + _ = ParseMessageWithDataDictionary(msg, rawMsg, dict, dict) + } +} + +func BenchmarkParseMessagePool(b *testing.B) { + // SOH = 0x01 is the FIX field delimiter + rawMsgStr := "8=FIX.4.2\x019=104\x0135=D\x0134=2\x0149=TW\x0152=20140515-19:49:56.659\x0156=ISLD\x0111=100\x0121=1\x0140=1\x0154=1\x0155=TSLA\x0160=00010101-00:00:00.000\x0110=039\x01" + + for i := 0; i < b.N; i++ { + msg := AcquireMessage() + rawMsg := bytes.NewBufferString(rawMsgStr) + _ = ParseMessage(msg, rawMsg) + ReleaseMessage(msg) + } +} diff --git a/repeating_group.go b/repeating_group.go index 853cc7d35..7da28554f 100644 --- a/repeating_group.go +++ b/repeating_group.go @@ -76,17 +76,46 @@ type Group struct{ FieldMap } // RepeatingGroup is a FIX Repeating Group type. type RepeatingGroup struct { - tag Tag - template GroupTemplate - groups []*Group + tag Tag + template GroupTemplate + groups []*Group + tagOrder tagOrder // cached to avoid closure allocation per Read/Add call + delimiter Tag // cached to avoid template[0].Tag() calls } // NewRepeatingGroup returns an initilized RepeatingGroup instance. func NewRepeatingGroup(tag Tag, template GroupTemplate) *RepeatingGroup { - return &RepeatingGroup{ + rg := &RepeatingGroup{ tag: tag, template: template, } + rg.initCachedValues() + return rg +} + +// initCachedValues pre-computes tag ordering and delimiter once per RepeatingGroup. +// Previously, groupTagOrder() created a new closure with a new map on every call, +// causing significant allocations during message parsing with repeating groups. +func (f *RepeatingGroup) initCachedValues() { + if len(f.template) > 0 { + f.delimiter = f.template[0].Tag() + } + + tagMap := make(map[Tag]int, len(f.template)) + for i, tmpl := range f.template { + tagMap[tmpl.Tag()] = i + } + f.tagOrder = func(i, j Tag) bool { + orderi := math.MaxInt32 + orderj := math.MaxInt32 + if iIndex, ok := tagMap[i]; ok { + orderi = iIndex + } + if jIndex, ok := tagMap[j]; ok { + orderj = jIndex + } + return orderi < orderj + } } // Tag returns the Tag for this repeating Group. @@ -96,10 +125,12 @@ func (f RepeatingGroup) Tag() Tag { // Clone makes a copy of this RepeatingGroup (tag, template). func (f RepeatingGroup) Clone() GroupItem { - return &RepeatingGroup{ + rg := &RepeatingGroup{ tag: f.tag, template: f.template.Clone(), } + rg.initCachedValues() + return rg } // Len returns the number of Groups in this RepeatingGroup. @@ -115,7 +146,10 @@ func (f RepeatingGroup) Get(i int) *Group { // Add appends a new group to the RepeatingGroup and returns the new Group. func (f *RepeatingGroup) Add() *Group { g := new(Group) - g.initWithOrdering(f.groupTagOrder()) + if f.tagOrder == nil { + f.initCachedValues() + } + g.initWithOrdering(f.tagOrder) f.groups = append(f.groups, g) return g @@ -153,34 +187,15 @@ func (f RepeatingGroup) findItemInGroupTemplate(t Tag) (item GroupItem, ok bool) return } -func (f RepeatingGroup) groupTagOrder() tagOrder { - tagMap := make(map[Tag]int) - for i, f := range f.template { - tagMap[f.Tag()] = i - } - - return func(i, j Tag) bool { - orderi := math.MaxInt32 - orderj := math.MaxInt32 - - if iIndex, ok := tagMap[i]; ok { - orderi = iIndex - } - - if jIndex, ok := tagMap[j]; ok { - orderj = jIndex - } - - return orderi < orderj +func (f RepeatingGroup) getDelimiter() Tag { + if f.delimiter != 0 { + return f.delimiter } -} - -func (f RepeatingGroup) delimiter() Tag { return f.template[0].Tag() } func (f RepeatingGroup) isDelimiter(t Tag) bool { - return t == f.delimiter() + return t == f.getDelimiter() } func (f *RepeatingGroup) Read(tv []TagValue) ([]TagValue, error) { @@ -194,7 +209,19 @@ func (f *RepeatingGroup) Read(tv []TagValue) ([]TagValue, error) { } tv = tv[1:cap(tv)] - tagOrdering := f.groupTagOrder() + // Use cached tag ordering, initialize if needed. + if f.tagOrder == nil { + f.initCachedValues() + } + tagOrdering := f.tagOrder + + // Pre-allocate groups slice to avoid repeated allocations during append. + if cap(f.groups) < expectedGroupSize { + f.groups = make([]*Group, 0, expectedGroupSize) + } else { + f.groups = f.groups[:0] + } + group := new(Group) group.initWithOrdering(tagOrdering) for len(tv) > 0 { @@ -222,7 +249,7 @@ func (f *RepeatingGroup) Read(tv []TagValue) ([]TagValue, error) { } if len(f.groups) != expectedGroupSize { - return tv, repeatingGroupFieldsOutOfOrder(f.tag, fmt.Sprintf("group %v: template is wrong or delimiter %v not found: expected %v groups, but found %v", f.tag, f.delimiter(), expectedGroupSize, len(f.groups))) + return tv, repeatingGroupFieldsOutOfOrder(f.tag, fmt.Sprintf("group %v: template is wrong or delimiter %v not found: expected %v groups, but found %v", f.tag, f.getDelimiter(), expectedGroupSize, len(f.groups))) } return tv, err diff --git a/repeating_group_test.go b/repeating_group_test.go index abd316d78..99a14835b 100644 --- a/repeating_group_test.go +++ b/repeating_group_test.go @@ -54,6 +54,26 @@ func TestRepeatingGroup_Add(t *testing.T) { } } +func TestRepeatingGroup_Get(t *testing.T) { + f := RepeatingGroup{template: GroupTemplate{GroupElement(1)}} + + g1 := f.Add() + g1.SetField(Tag(1), FIXString("first")) + g2 := f.Add() + g2.SetField(Tag(1), FIXString("second")) + + assert.Equal(t, 2, f.Len()) + + group0 := f.Get(0) + var val FIXString + require.Nil(t, group0.GetField(Tag(1), &val)) + assert.Equal(t, "first", string(val)) + + group1 := f.Get(1) + require.Nil(t, group1.GetField(Tag(1), &val)) + assert.Equal(t, "second", string(val)) +} + func TestRepeatingGroup_Write(t *testing.T) { f1 := RepeatingGroup{tag: 10, template: GroupTemplate{ GroupElement(1), diff --git a/tag_value.go b/tag_value.go index 28bba7a7a..41a38b95b 100644 --- a/tag_value.go +++ b/tag_value.go @@ -29,10 +29,14 @@ type TagValue struct { } func (tv *TagValue) init(tag Tag, value []byte) { - tv.bytes = strconv.AppendInt(nil, int64(tag), 10) - tv.bytes = append(tv.bytes, []byte("=")...) + // Pre-allocate exact capacity to avoid slice growth allocations. + // Previous impl used append with nil slice, causing multiple grows. + capacity := 10 + 1 + len(value) + 1 // max tag digits + '=' + value + SOH + tv.bytes = make([]byte, 0, capacity) + tv.bytes = strconv.AppendInt(tv.bytes, int64(tag), 10) + tv.bytes = append(tv.bytes, '=') tv.bytes = append(tv.bytes, value...) - tv.bytes = append(tv.bytes, []byte("")...) + tv.bytes = append(tv.bytes, '\001') // SOH (0x01) - FIX field delimiter tv.tag = tag tv.value = value @@ -89,7 +93,7 @@ func (tv TagValue) String() string { func (tv TagValue) total() int { total := 0 - for _, b := range []byte(tv.bytes) { + for _, b := range tv.bytes { total += int(b) }