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))
}
It looks harmless. The benchmark says otherwise:
BenchmarkFIXDecimalWrite 155 ns/op 208 B/op 11 allocs/op
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:
- Read the coefficient and exponent of the decimal.
- 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.
- 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.
StringFixedrounds half away from zero. On the magnitude that isif 2*remainder >= divisor { quotient++ }. I wrote the comparison asr >= div-rso it cannot overflow for divisors above 2^63. -
MinInt64has no positive counterpart. Negating it as a signed value overflows, so the sign is stripped on auint64magnitude instead. -
Multiplying up can overflow.
bits.Mul64reports the high word, so overflow is detected rather than silently wrapped. -
Negative zero must not appear. A value like
-0.001at scale 2 rounds to0.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()))
}
}
}
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)
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.