DEV Community

Rohan Singh Rajput
Rohan Singh Rajput

Posted on

From 11 allocations to 2: speeding up a FIX engine's price encoder in Go

FIX is the protocol that moves orders and quotes between brokers, exchanges and trading firms. QuickFIX/Go is a widely used Go implementation of it. Every price and quantity on every outgoing message passes through one small function, so I went looking at what it costs.

Status: the change is open as quickfixgo/quickfix#787, discussed in #786. I'll update this post if it changes in review.

The function

FIXDecimal.Write turns a decimal into the bytes that go on the wire:

func (d FIXDecimal) Write() []byte {
    return []byte(d.Decimal.StringFixed(d.Scale))
}
Enter fullscreen mode Exit fullscreen mode

It looks harmless. The benchmark says otherwise:

BenchmarkFIXDecimalWrite   155 ns/op   208 B/op   11 allocs/op
Enter fullscreen mode Exit fullscreen mode

Eleven heap allocations to format -124.3456. StringFixed rounds through big.Int arithmetic, builds intermediate strings, and then the result is copied again into a []byte. On a path that runs per field, per message, that is a lot of garbage for the collector to chase, and GC pressure is exactly what latency-sensitive systems try to avoid.

The idea

Most prices fit comfortably in an int64 coefficient with a small exponent. For those, there is no need for big.Int at all:

  1. Read the coefficient and exponent of the decimal.
  2. Shift the coefficient so its last digit lands on the last requested decimal place. Shifting right means rounding; shifting left means multiplying by a power of ten.
  3. Write the digits into a small stack buffer, insert the decimal point, and copy once into an exactly-sized result.

The details that matter:

  • Rounding must match exactly. StringFixed rounds half away from zero. On the magnitude that is if 2*remainder >= divisor { quotient++ }. I wrote the comparison as r >= div-r so it cannot overflow for divisors above 2^63.
  • MinInt64 has no positive counterpart. Negating it as a signed value overflows, so the sign is stripped on a uint64 magnitude instead.
  • Multiplying up can overflow. bits.Mul64 reports the high word, so overflow is detected rather than silently wrapped.
  • Negative zero must not appear. A value like -0.001 at scale 2 rounds to 0.00, not -0.00.
  • Everything else falls back. Coefficients beyond int64, negative scale, and scales of 20 or more take the original path, so behaviour is unchanged outside the fast path's range.

Proving it is the same function

A faster function that is subtly wrong is worse than a slow one in a trading system. So the test is differential: for a set of awkward coefficients (zero, ±1, rounding ties like 5, 50 and 995, MaxInt64, MinInt64, and a few hundred random ones), every exponent from -25 to 25, and a spread of scales, the new output must equal decimal.StringFixed byte for byte. That is on the order of a hundred thousand comparisons.

for _, c := range coefs {
    for exp := int32(-25); exp <= 25; exp++ {
        for _, scale := range []int32{-2, 0, 1, 2, 4, 8, 12, 18, 19, 20} {
            dec := FIXDecimal{Decimal: decimal.New(c, exp), Scale: scale}
            assert.Equal(t, dec.Decimal.StringFixed(scale), string(dec.Write()))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

It passed on the first run, which is the point of designing against the edge cases above before verifying them.

The result

Measured with benchstat over 10 runs on an Apple M5, Go 1.27:

before after change
time/op 155 ns 20.5 ns -86.8%
B/op 208 24 -88.5%
allocs/op 11 2 -81.8%

The two remaining allocations are the final result slice and the digit storage behind the big.Int copy that Coefficient() returns. Getting the second one to zero would need an API from the decimal library that exposes the coefficient without copying.

Takeaways

  • Benchmark before you guess. The benchmark was already in the repo; the number just needed reading.
  • A fast path with a fallback is a safe way to optimise a library you do not own. Behaviour outside the fast path is untouched.
  • Differential testing against the slow implementation is the cheapest way to trust a rewrite.

If you work on Go services that touch FIX, I'd like to hear what else on the hot path is worth profiling.

Top comments (1)

Collapse
 
kashif_manzer profile image
Kashif Manzer •

The fallback design is what makes this safe to land, because the tricky cases keep the old behavior. One thing I would look at next is how Write is used by the caller. If the result is copied into a message buffer anyway, an append style variant that writes into that buffer could remove the final allocation as well as the extra copy. That would matter more than the remaining Coefficient copy on a per field path, since it shows up on every message. The differential test approach is the right call here, since a rounding difference would be worse than the original cost.