Skip to content

Conversation

@yanfali
Copy link
Contributor

@yanfali yanfali commented Jan 2, 2017

  • one request and one response schema based off real world schema usage
  • remove allocation of new byte.Buffer from benchmarks, we're not that
    interested in measuring that.

@smyrman here's a more realistic Schema based on something in production.

@yanfali
Copy link
Contributor Author

yanfali commented Jan 2, 2017

I've also remove the allocation of byte.Buffer from the benchmarks since that's not we're measuring here and simply truncate the buffer before use. It makes a small but measurable difference.

Copy link
Collaborator

@smyrman smyrman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As for removing buffer allocation from the benchmark, sure, that's OK, but it needs to be done in a different way to avoid benchmarks affecting each other's results.

)

// Optional schema field
func Optional(s schema.Field) schema.Field {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would vote for removing this method, and rely only on using Required().

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are general helpers. I actually have a library of validation help functions that have different defaults, I copied these over for expediency. I'm not concerned since they are in the _test framework and not for general use here. I can certainly remove it, it just made it easier to port the code over.

return s
}

func rfc3339Nano() schema.Field {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could "safely" be a var, just like schema.IDField

Since it's not a pointer value, it will be copied on assignment, so that you could still do:

...
s := Schema{
        Fields: schema.Fields{
                "x": Required(rfc3339Nano),
        },
}

func String(min, max int, description string) schema.Field {
return schema.Field{
Description: description,
Required: true,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it not be more appropriate to rely on Required(String(...))?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably, all just ported over from places where default was different.

}
}

var (
Copy link
Collaborator

@smyrman smyrman Jan 2, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All this schemas are left in the namespace... Perhaps it would be better just to leave Complex1 (rather than requestSchema) and Complex2(Rather than responseSchema) in the namespace and initalize them in an init() function.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, would be nice to place it next to the Student schema definition or move the Student schema definition to be in the same place.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could also rename the Student schema to e.g. "small". Then the benchmark results would read quite well when put in the same table test list.

.../Schema=Small
.../Schema=Complex1
.../Schema=Complex2

Wheter it's a "student list", "request" or a "response" is really quite irrelevant...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's a pretty specific namespace and only run during benchmarking but sure we can clean that up.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really consider the student schema to be complex. This schema approaches the complexity of my actual uses cases in production.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's a pretty specific namespace and only run during benchmarking but sure we can clean that up.

The namespace is the same for the entire jsonschema_test package. Anyway thanks for cleaning it up.

I don't really consider the student schema to be complex.

No, which is why I suggest you call it "Small":-)

})
b.Run("response", func(b *testing.B) {
for i := 0; i < b.N; i++ {
buf.Truncate(0)
Copy link
Collaborator

@smyrman smyrman Jan 2, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better to use a freshly allocated buffer to get as similar results as possible. Here the first sub-test might grow the buffer (in the first loop), so that when the second test starts, it doesn't need to grow the buffer like the first test did.

Also the ordering of the benchmark tests could change their results, which is always bad.

Copy link
Collaborator

@smyrman smyrman Jan 2, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PS! It's possible to pause the benchmark timer:

b.StopTimer()
buf := new(bytes.Buffer)
b.StartTimer()
enc := jsonschema.NewEncoder(buf)

Copy link
Contributor Author

@yanfali yanfali Jan 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand but I'm more concerned we are measuring the noise of new, rather than the code under test. One thing specifically I want to see is the effect of all the buffer allocation in the new code vs just simply writing to a io.writer. The point of the benchmark is to test the code, not new(bytes.Buffer) from the runtime. Turning this into a truncate amortizes the cost once across all the benchmarks and effectively removes it from the test.

}
)

func BenchmarkEncoderComplex(b *testing.B) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not add these benchmarks as cases to the existing table of sub-tests in benchmark_test.go?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried initially but took a while to sort out the import naming and conventions. I can merge them now I have it working.

b.Run(tc.Name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
enc := jsonschema.NewEncoder(new(bytes.Buffer))
buf.Truncate(0)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, we still need to allocate a new buffer to avoid benchmarks affecting each other in a bad way.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is deliberate. The allocations are measurable. Switching to truncate removes them and makes it clearer where the time is being spent.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the docs:

Truncate discards all but the first n unread bytes from the buffer but continues to use the same allocated storage.

So at least we need to allocate a new buffer per sub-test, even if we don't do so within the loop itself... That should eliminate the chance of Grow affecting different sub-tests differently.

Or use ioutil.Discard as the writer.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry but this is a strawman argument. we completely control the test loop. The encoder is only using the io.Writer interface.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(See discussion above instead)

@yanfali
Copy link
Contributor Author

yanfali commented Jan 3, 2017

Here's without the new bytes.Buffer

BenchmarkEncoder/Schema={Fields:{"b":Bool{}}}-4         	 1000000	      1027 ns/op	     240 B/op	      12 allocs/op
BenchmarkEncoder/Schema={Fields:{"s":String{}}}-4       	 2000000	       977 ns/op	     224 B/op	      12 allocs/op
BenchmarkEncoder/Schema={Fields:{"s":String{MaxLen:42}}}-4         	 1000000	      1179 ns/op	     248 B/op	      14 allocs/op
BenchmarkEncoder/Schema=Simple-4                                   	  200000	      8489 ns/op	    1200 B/op	      55 allocs/op
BenchmarkEncoder/Schema=Complex1-4                                 	   50000	     31846 ns/op	    6162 B/op	     228 allocs/op
BenchmarkEncoder/Schema=Complex2-4                                 	   20000	     79457 ns/op	   14237 B/op	     545 allocs/op

Here's with

BenchmarkEncoder/Schema={Fields:{"b":Bool{}}}-4         	 1000000	      1243 ns/op	     496 B/op	      14 allocs/op
BenchmarkEncoder/Schema={Fields:{"s":String{}}}-4       	 1000000	      1243 ns/op	     480 B/op	      14 allocs/op
BenchmarkEncoder/Schema={Fields:{"s":String{MaxLen:42}}}-4         	 1000000	      1639 ns/op	     504 B/op	      16 allocs/op
BenchmarkEncoder/Schema=Simple-4                                   	  200000	      9659 ns/op	    2528 B/op	      59 allocs/op
BenchmarkEncoder/Schema=Complex1-4                                 	   50000	     34222 ns/op	    9827 B/op	     233 allocs/op
BenchmarkEncoder/Schema=Complex2-4                                 	   20000	     84637 ns/op	   26321 B/op	     552 allocs/op

}
buf := new(bytes.Buffer)
buf := &bytes.Buffer{}
buf.Grow(65535)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it be cleaner to write buf := bytes.NewBuffer(make([]byte, 65535))?

}
}

func getComplexSchema() (schema.Schema, schema.Schema) {
Copy link
Collaborator

@smyrman smyrman Jan 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not obvious why this method need to return two schemas, and why we want to call them requestSchema and responseSchema.

I would suggest moving everything to benchmark_test.go file renaming the file to testutils_test.go and initialize the schemas like this:

// reusable Schemas for benchmarks and tests.
var (
        schemaSmall *schema.Schema
        schemaComplex1 *schema.Schema
        schemaComplex2 *schema.Schema
)

func init() {
        // Initalize the schemas:
        // schemaSmall = todays Student schema (or one with similar complexity).
        // schemaComplex1 = this review's requestSchema
        // schemaComplex2 = this review's responseSchema
}

After that, replace any reference to Student with Small in the other tests.

Copy link
Collaborator

@smyrman smyrman Jan 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest moving everything to benchmark_test.go file

On second thought.. maybe just rename the file to testutils_test.go. That would be a good place for this helper functions, and perhaps also some helper functions from all_test.go should move here later.

The definitions of the reusable test/benchark schemas, I think both testutils_test.go, all_test.go and benchmark_test.go could work...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I disagree with your suggestion, now you're back to polluting the name space. You can't have it both ways. Sure we can rename the file but then you loose the intent of what the file was for.

Copy link
Contributor Author

@yanfali yanfali Jan 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getComplexSchema is a template generator for two schemas based off actual production code. The fact the signature returns multiple schema is irrelevant; it was done for name spacing reasons. I have an idea that may convey intent better, if we return the test structs directly maybe?

Copy link
Collaborator

@smyrman smyrman Jan 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I where not convened concerned with the so called "requestSchema" and "responseSchema" polluting the namespace but about all the intermediate ones.

The suggested names complexSchema1/2 could be useful for reuse in tests.

getComplexSchema

Even test code should be clean. Perhaps even cleaner than the main code, since tests also serve as examples/documentation to package maintainers and users.

I believe a good clean function should do one and only one thing. I think this method does two which makes it unclean, and it makes it harder than it has to be to reuse (one of) the complex schema for tests.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good argument. I will separate this in to two helper functions.

As an aside, this is test code. Getting too attached to excessively "clean" test code is among the worst mistakes I feel you can make because it it can become a burden on the developers; with test code you must always be prepared to completely throw it out, it's not your product - it's an expression of the current state of the implementation that helps you have comfort that the code is doing what you intended.

I do agree it should serve as a form of documentation though, but by conveying the intent.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will separate this into two helper functions.

Better👍

Schema: responseSchema,
},
}
buf := bytes.NewBuffer(make([]byte, 65535))
Copy link
Collaborator

@smyrman smyrman Jan 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helps, but if any one benchmark was to trigger a Grow (which usually means doubling the buffer size I think), that one benchmark would be unfairly punished. Maybe it's better to just rely on ioutil.Discard? Then we don't need to worry about the time spent allocating/growing the buffer at all. Of course, the write itself also becomes free.

Alternatively, we should at the very least move this to inside for i := range testCases (before the b.Run call), to assure each benchmarks starts with a writer that has the same capacity for it's first loop.

Copy link
Collaborator

@smyrman smyrman Jan 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think relying on ioutil.Discard as the io.Writeris probably the best and simplest option btw.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, but why would anything call grow? we control the buffer and we only pass it in as an io.Writer. I like the Discard idea, but it could make debugging harder if we needed to pin point any problems. Discard would also not trigger any of the io.Writer error behavior since it's essentially a null instance. Truncate has the behavior I'm after. It's resets the buffer without doing any new allocations.

    67	func (b *Buffer) Truncate(n int) {
    68		b.lastRead = opInvalid
    69		switch {
    70		case n < 0 || n > b.Len():
    71			panic("bytes.Buffer: truncation out of range")
    72		case n == 0:
    73			// Reuse buffer space.
    74			b.off = 0
    75		}
    76		b.buf = b.buf[0 : b.off+n]
    77	}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On reflection, I actually think the Discard idea is probably not a good idea because the writes are a part of the performance of the API. The memory bandwidth of the test host would affect the outcome and if we use Discard we wouldn't get a measurement of that. The only thing these changes do is hoist of the allocation Buffer itself outside of the measurement loop.

Copy link
Collaborator

@smyrman smyrman Jan 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, but why would anything call grow?

If any schema ends up bigger than the prealocated buffer, Grow will be called implicitly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes I see your argument, but we also control the test environment. At worst we can increase the initial buffer size to 1MiB. I don't see any realistic schema ever growing beyond that size.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is interesting. Setting the buffer to 2<<20 made the test worse. Setting the buffer to 2<<19 actually made it improve. It's probably some hardware related caching, but it's still interesting. I'm on a 13" macbook pro mid 2014.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, we could also just run the encoding once before the sub-benchmark loop starts! That would also ensure we always have enough space!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm happy to move the buffer inside the test harness loop and outside of the benchmark loop. That's a reasonable compromise. I don't know if we want to be too clever in a benchmark or test code. I find it just gets you into trouble :)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for i := range testCases {
	tc := testCases[i]
	enc := jsonschema.NewEncoder(buf)
	// We don't want buf grow to affect the benchmark, so we run through the encoding
	// once before the benchmark starts.
	buf.Truncate(0)
	enc.Encode(&tc.Schema)
 	b.Run(tc.Name, func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			buf.Truncate(0)
			enc.Encode(&tc.Schema)
			...

@yanfali
Copy link
Contributor Author

yanfali commented Jan 3, 2017

Performance appears the same, we're just making the GC work a bit harder.

Copy link
Collaborator

@smyrman smyrman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work!

@smyrman
Copy link
Collaborator

smyrman commented Jan 3, 2017

Performance appears the same, we're just making the GC work a bit harder.

Did you see my last suggestion?

We could run the Encode once before the b.Run starts (code example in inline comment). Then we only allocate just-enough bytes, and we reuse the buffer, but we always do it before the benchmarks start so it's not counted.

@yanfali
Copy link
Contributor Author

yanfali commented Jan 3, 2017

I did, though I'm not sure it's worth the extra complexity, let me run a quick benchmark and see how it compares. If it's roughly the same I think we should just leave it out.

@yanfali
Copy link
Contributor Author

yanfali commented Jan 3, 2017

Over three runs it's about the same performance as before, so I'd vote to keep it as straightforward as possible and this avoids having the extra comment explaining why we are doing this.

@smyrman
Copy link
Collaborator

smyrman commented Jan 3, 2017

Over three runs it's about the same performance as before, so I'd vote to keep it as straightforward as possible and this avoids having the extra comment explaining why we are doing this.

Sounds good. And thanks a lot for the new benchmarks. I will re-test #76 once this is merged :-)

@yanfali
Copy link
Contributor Author

yanfali commented Jan 3, 2017

@rs this is ready to go, I'm going to squash it first.

@yanfali yanfali force-pushed the yanfali-benchmark branch 2 times, most recently from 21d162c to c372bd1 Compare January 3, 2017 17:13
}

func getComplexSchema2() schema.Schema {

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extra return

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry? a bit confused by this comment.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an extra blank line.

 - Complex schema 1 is based off a request and Complex schema 2 is based off
 a response schema in real world schema usage

 - move allocation of byte.Buffer outside of benchmark loop, as we're
 not interested in measuring that.

 - set Buffer to 512Kb to avoid dynamic schema growth for expected test
 usage.
@yanfali yanfali force-pushed the yanfali-benchmark branch from c372bd1 to eb32177 Compare January 3, 2017 18:40
@yanfali
Copy link
Contributor Author

yanfali commented Jan 3, 2017

@rs thanks fixed. It was close enough to the return statement above that I was confused by the comment.

@rs rs merged commit c354f40 into rs:master Jan 3, 2017
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants