神刀安全网

Making a Go program 70% faster by avoiding common mistakes

Go – Making a program 70% faster, by avoiding common mistakes.

I often read articles where a developer would say they made this or that change to their code and a benchmark shows their function going from taking ~300ns to ~170ns and they were really happy about it. I think it’s great that there are devs out there really squeezing every CPU cycle they can. But this isn’t one of those articles.

At work we wrote a tool that takes a large number of trading data, does all kinds of calculations, comparisons, tagging and then saves the results in MongoDB. We originally wrote the tool in Scala, but it was using too much memory and wasn’t fast enough for us, so about two years ago we migrated the code to Go.

Blotterizer(because it calculates data for Trade Blotters :) ), has gone through several major improvements. Some of them were related to performance, like going from 8 hours to run the complete process down to about 20 minutes . Others were operational, for example, I used to have to manually run the tool locally, then upload a gzip file to the server and then load that data to the database, now all it takes is one click on the main App and then sit back and wait for a notification telling you the data is ready.

The problem.

Today I’m writing about a mistake I made a while ago, which only showed how bad it was for some of our largest customers. Customer X provided us with several years worth of data, and Blotterizer took about 131 minutes to process all that data, most customer’s data are is ready in less than 10 minutes, so this was way too long.

The wrong way to fix it

First, I did what every dev out there knows not to do (including myself), I just started making changes to the code thinking I knew better. I knew we were getting a huge amount of docs from MongoDB, so the code went from:

itrades := []trading.AggregationSetsTradeDates{} iter := collection.Pipe([]bson.M{{ ... }}).AllowDiskUse().Iter()  err := iter.All(&itrades) 

to

calling .Next() on the iterator. The thought was, I’m sure fetching all those docs at once is too slow.

That turned out not to make much of a difference, it was actually worse, because I had to make several calculations with each document 4 times, based on different parameters.

When I fetched them all at once, I could run 4 goroutines to spread the load, now that I was getting one doc at the time, sending each of them to a goroutine ended up causing more overhead. So this was a dead end.

I tried a few other embarrassing ideas which should remain untold for the time being, and then I decided it was time to do this right.

The right way to fix it.

One of the great things about Go is that it comes with very useful tools around the language, Writing benchmarks was the key here. So I went ahead, reverted my changes and wrote a benchmark to see how long processing only 3 transactions took.

The horror

The initial results looked terrible:

go test -bench=BenchmarkProcessTrades -run=none -benchmem PASS BenchmarkProcessTrades-4     1000    1734904 ns/op    50353 B/op     1613 allocs/op ok   github.com/ascendantcompliance/blotterizer 1.949s 

That is, it took 1.7 milliseconds to process 3 transactions and made 1613 allocations

No wonder this thing was slow! but then again, I knew this was slow, now came the hard part, what do I do to make it faster?

I added a few timers in between the process (the benchmark wasn’t of the least unit I could write, it involved several steps), but this showed that the biggest issue was when saving the data to the database.

I looked on the mgo mailing list to see if anyone else had run into performance issues, but nothing showed a problem with mgo . At this point I was about to give up, and was going to email the mailing list to see if they had any clues and that’s when I saw the huge issue I had:

err := collection.UpdateId(processingThisOne.ID, bson.M{    "$set": bson.M{     "std_" + strconv.Itoa(daySpread):              std,     "mean_" + strconv.Itoa(daySpread):             mea,     "impact_" + strconv.Itoa(daySpread):           impact(processingThisOne.ID, mea, collection),     "impact_" + strconv.Itoa(daySpread) + "_abs":  math.Abs(impact(processingThisOne.ID, mea, collection)),     "impact_ratio_" + strconv.Itoa(daySpread):     impactRatio(impact(processingThisOne.ID, mea, collection), processingThisOne.GrossUSD),     "zscore_" + strconv.Itoa(daySpread):           zScore,     "zscore_" + strconv.Itoa(daySpread) + "_zone": zone,    },   }) ...  func impact(tradeID bson.ObjectId, mean float64, collection *mgo.Collection) float64 {  var trade trading.Trade  err := collection.FindId(tradeID).One(&trade)  if err != nil {   log.Fatalf("Error getting document to calculate the Impact value, got: %s", err.Error())  }  ret := trade.GrossUSD - mean*trade.Quantity   if strings.ToLower(trade.TransactionType) == "buy" || strings.ToLower(trade.TransactionType) == "cover" {   return ret * -1  }  return ret } 

First, smaller issues, I was calling strconv.Itoa(daySpread) about 7 times, so I quickly moved that to a single variable

I then run the benchmark again and saw:

go test -bench=BenchmarkProcessTrades -run=diego -benchmem PASS BenchmarkProcessTrades-4     1000    1731701 ns/op    50227 B/op     1595 allocs/op ok   github.com/ascendantcompliance/blotterizer 1.926s 

Still slow, but the number of allocations went down, so I thought I was on the right track.

Next I saw that I called impact(processingThisOne.ID, mea, collection) 3 times, so I applied the same solution, assign it once and reuse the variable twice more.

Things we getting better:

go test -bench=BenchmarkProcessTrades -run=diego -benchmem PASS BenchmarkProcessTrades-4     2000     924453 ns/op    28258 B/op      774 allocs/op ok   github.com/ascendantcompliance/blotterizer 1.967s  

Down to 774 allocations and timing was about half!

There was more!

I was still looking to make this even better, and that’s when I saw

err := collection.FindId(tradeID).One(&trade) 

I was calling MongoDB for each document, to get the quantity that was traded, the gross usd value and the transaction type, I sat there for a moment trying to see how I could avoid this, and then I remembered that the code that runs the aggregation Pipe had the quantity and gross usd, so I just needed to add the transaction type to the return value from the aggregation. And that was it, this let me changed the impact function to:

 func impact(trade trading.TradeDatePrice, mean float64) float64 {  ret := trade.GrossUSD - mean*trade.Quantity  if strings.ToLower(trade.TransactionType) == "buy" || strings.ToLower(trade.TransactionType) == "cover" {   return ret * -1  }  return ret } 

No more calling mongo for each document!

go test -bench=BenchmarkProcessTrades -run=diego -benchmem PASS BenchmarkProcessTrades-4     3000     528994 ns/op    17186 B/op      363 allocs/op ok   github.com/ascendantcompliance/blotterizer 1.662s 

So now we are down to 0.5 milliseconds and only 363 allocations

This was a pretty big improvement, so I went and deployed this to our testing env. and rerun the process. What used to take 131 minutes now takes 76 minutes Which is a lot better.

Here I stopped, cleaned up the code and sent a pull request to have the changes reviewed. It was also time to take a well deserved break and spend some family time.

In the next couple of days I’ll be looking at other parts of the tool to see what other mistakes are waiting to be corrected :)

Note to self.

  • Go benchmarks are your friend
  • Try not to query your database if possible.

Thank you for reading and don’t hesitate to leave a comment/question.

@fmpwizard

Diego

转载本站任何文章请注明:转载至神刀安全网,谢谢神刀安全网 » Making a Go program 70% faster by avoiding common mistakes

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
分享按钮