top | item 40542509

(no title)

neild | 1 year ago

There are some interesting results in here, but the slower cases are a bit misleading. The majority of time in the slow cases is spent constructing errors, not in errors.Is.

Some background for anyone not familiar with Go errors:

A Go error is an interface value with an Error method that returns the error's text. A simple error can be constructed with the errors.New function:

  var ErrNotFound = errors.New("not found")
A nil error indicates success, and a non-nil error indicates some other condition. This is the infamous "if err != nil {}" check. Comparing an error to nil is pretty fast, since it's just a single pointer comparison. On my laptop, it's about 0.5ns. Comparing a bool is about 0.3ns, so "err != nil" is quite a bit slower than "!found", but it's really unlikely the 0.2ns is going to be relevant outside of extremely hot loops.

We can also compare an error to some value: "if err == ErrNotFound {}". In this case, we say that ErrNotFound is a "sentinel" (some error value that you compare against). This is about 2.3ns on my laptop; there are two pointer comparisons in this case and a bit more overhead in comparing interface values. (You can actually make this check almost arbitrarily expensive; you could have an error value that's a gigabyte-large array, for example.)

It's common to annotate an error, adding some more useful information to it. For example, we might want our "not found" error to say what was not found:

  return fmt.Errorf("%q: not found", name) // "foo": not found
This is quite a bit more expensive than "return ErrNotFound". The fmt.Errorf function will parse a format string, produce the error text, and make two allocations (one for the error string, one for a small struct that holds it). This is about 84ns on my laptop--168 times slower than the fast path! But 84ns is still pretty fast, and you can't get away from the need for at least one allocation if you want to return an error that's varies based on the inputs of the function that produced it. (You can get faster than fmt.Errorf if it matters, but this comment is already getting large.)

A problem with using fmt.Errorf in this way is that you can't test the error against a sentinel any more. This was addressed a while back in Go 1.13 with the addition of error wrapping. You can return an error that wraps the sentinel (note the %w format verb):

  return fmt.Errorf("%q: %w", name, ErrNotFound) // "foo": not found
And you can then use the errors.Is function to ask whether an error is equal to ErrNotFound, or if it wraps ErrNotFound:

  if errors.Is(err, ErrNotFound) { ... }
On my laptop, producing a wrapping error like this and testing it with "err != nil" is about 91ns, and testing it with "errors.Is(err, ErrNotFound)" is about 98ns. So using Is is adding 7ns of overhead, which is not nothing, but is also pretty much lost in the noise compared to creating the error in the first place.

The example in this blog post went a step further, though, and created an error with not just a single layer of wrapping but one with four. The error text in the wrapped error cases is:

  GetValue couldn't get a value: queryValueStore couldn't get a value: queryDisk couldn't get a value: not found
(That is, by the way, a very difficult error to read. Don't hand users errors that look like that.)

Creating a stack of four wrapped errors like this on my laptop is 396ns, and inspecting it with errors.Is is another 21ns. 21ns is waaaaay more than the 0.5ns for a simple "err != nil" check, but again the runtime here is massively dominated by the expense of creating the error--which in this case involves repeatedly creating formatted strings and throwing them away, and two allocations for each layer in the stack.

In general, when doing low level optimization of Go code, avoiding allocations is the biggest bang for your buck. If microseconds matter, you absolutely should pay attention to the cost of constructing error values. But the cost of inspecting those values doesn't usually become an issue unless nanoseconds count, and will generally be dominated by the cost of construction.

Also, even the slowest cases here are running about 0.5-1.5μs, which absolutely matters in some cases, but is irrelevant in many others.

discuss

order

beautron|1 year ago

> In general, when doing low level optimization of Go code, avoiding allocations is the biggest bang for your buck.

I have found this to be the truth. I'm making a game in Go, and have learned that a smooth framerate depends on programming with allocation awareness. Always know where and when your memory is coming from.

zachmu|1 year ago

This is true.

We have in the past eliminated sentinel errors and gotten measurable gains from doing so, but this is mostly because our errors were both common (occurring several times on every request) and expensive to construct. The direct cost of errors.Is() was minor compared to the cost of building the error itself.

And you might be surprised how common it is for people to insist on wrapping an error at every layer of the stack. I've gotten in arguments with these people online, they're out there.

sethammons|1 year ago

I insist on wrapping all errors each time and only removing that when performance testing shows it to be a bottleneck. A top concern of my systems is debugability which includes descriptive, wrapped errors with structured logging (this is a super power for system development and I am surprised when folks don't give love to structured logs and detailed, reproducible errors).

I want organizational velocity in the general case. If wrapping an error is in a hot path and shows up in metrics, yeah, remove the wrapping. Otherwise, wrap the error.

What is your argument against that? It would seem you find the compute savings of non-wrapped errors outweighs developer time and customer impact. If that is not what you are saying, please correct me.

My creds are using Go since 1.2 and writing massively scaled systems processing multibillion events daily for hundreds of thousands of users with 4 to 5 9s of uptime across dozens of services maintained by hundreds of developers earning the company hundreds of millions of dollars.