Hello, and welcome to C++ Weekly. I'm your host, Jason Turner. In this episode I want
to talk to you about how you can get the fastest possible JSON access times.
Now as you probably already saw from the episode title and from the screenshot that I have up here,
I am talking about my JSON to C++ compiler translator. (I don't know,
something like that.) This is not a bunch of constexpr code that does compile time parsing
of the JSON to give you a result out, because that's still actually
really slow to do for anything that's bigger than a trivial JSON input file. It's also
difficult to use because there's no good way to actually embed a file into your C++ project
like we would like to, so we have to end up doing weird things where we take the JSON script that we
want to use and we then have to wrap it into a raw string literal or something like that.
So this is actually an extra build step - the goal is to produce something that has literally
zero run time overhead. We have a statically compiled JSON resource. (It's) 100% constexpr
capable, so you can choose to make compile time decisions based on that JSON resource file,
and the option of firewalling it behind a C++ file if you don't want to have to pay
the cost of compiling that data each time you want to use it. Now I think I can actually solve
some of the compile time even better than it currently is, although for small JSON resources
it's it's basically trivial compile time, and I'll show you how in a moment. I wanted it to be
100% compatible with the nlohmann JSON API, and I needed it to have a Valijson adapter support.
So if you just dig in to the header files here, as you can see, this is lefticus/json2cpp. I have
applied the MIT license here so it's pretty open. Maybe not 100% open. I opted not for public domain
in this particular case, and if you look at this you can see that I have a JSON struct. It
is not templatized in the same way that nlohmann JSON is, so I'm probably going to update that at
some point in the near future, but it has the same behavior from an iterator perspective,
the same behavior from a "for each" perspective, the same behavior from size, from indexing,
and everything like that, except it defaults to using string views for everything because it's
all constexpr capable instead of standard strings. So depending on your use case
for actually accessing this compile time known JSON, you can probably get some like, really fast
access times and that's partially because there's literally no dynamic allocation occurring at all
during the use of this. You are using statically known compile time resources, so the compiler,
when it goes to generate these things, it knows like, literally, exactly where those objects live;
and once you start accessing them then it's going to be able to access a bunch of stuff
that's already in cache because it's one big blob of data that happens to exist in your binary.
So if you care, you can flip through this file and you can see that it has
the same API as nlohmann JSON, so it should be a drop-in replacement.
I was able to prove that to myself, at least mostly, by writing this adapter class for
Valijson, and really I just took the Valijson nlohmann adapter and
it's pretty much a find and replace operation to use my constexpr JSON types.
So let's go ahead and take a quick look at what this actually does. Okay so in the build folder
I have the schema validator. Now this thing is a particular application, if you will, that doesn't
necessarily apply to the generic user here. This is really just an executable meant for test;
but I've got this 'json2cpp', and it expects a handful of parameters. That is - the name of the
document that you want it to create. So if you take a look at the average JSON file, you know,
it's going to look something like this - it's like just an array, or it's going to look like this
where it's a simple object with key value pairs with some kind of typing. So the document name
is the name of the actual C++ object that we want it to create. Then we've got the file name,
and that is the file name of the thing that we want it to translate;
and then we have the 'output_base_name'. Now it's going to generate three files for
us - that's why we just give it a base name. So let's go ahead and translate this example
that we were just looking at a moment ago. So that's the file name. I'm going to give it
a very explicit document name so that we can actually see what it's done,
and the base name I'm going to call it, again, 'test_document'. So
there we go. It ran it ran extremely quickly. What did it run in? 0.028
seconds. I think that's fast enough. That's a small document, but even for large documents,
which I'll just go ahead and demonstrate - this is a rather large schema document,
yeah, with 155,000 objects in it, it ran in 2.5 seconds. I think this is a debug build even, so,
you know what? It's fast enough. Okay let's look at the test document that was created,
and, like I said, we have three files that were created for us.
There's the cpp file, the header file and the Impl file. Let's start in the
header file. So the header file is simply a function that says "get_test_document,"
and you'll notice that this is returning a const reference to one of these constexpr JSON
things that I just pointed you at a moment ago.
And... then let's look at the cpp file. Now all the cpp file does is include the Impl file,
so this gives you that compilation firewall that I mentioned before. In this case I can include,
if I don't care about the compile time nature of this, I can include just the header file
and then put the cpp file in my project list of cpp files that I want to compile and know for a
fact that that test document is only going to be compiled once but I'll still get very fast
access to this thing and zero runtime loading overhead. Let's go ahead and dig into what that
looks like. So this is the implementation that was created for us. It's actually useful to go down to
the bottom of the document and we can see that I created a static constexpr auto document here
which is one of my constexpr JSON objects, and it is initialized with an object known
as 'data_0'. So this is the top level element in the JSON document, and then I have created
this const 'get_test_document' function, but your entry point, if you care about
the constexpr side of things, is probably just going to directly be this object right here.
So like I said, it creates a JSON object from 'object_data_0'. 'object_ data_0' is simply a
name value pair of 'glossary' to 'object_data_1', and note I did do some raw string literal escaping
here because I don't know what kind of strings might happen to exist in this JSON document.
It's still possible that things could break if someone were to have given me the actual string of
)string" then that would break it; It might be able to detect that.
'object_data_1' is, again, a name value pair of 'GlossDiv' and 'object_data_2' and then 'title'
and we have 'example glossary' here. Okay. So all it's done, and this leads directly back
to the last several episodes that I've done on constexpr - it's just a bunch of compile
time known and guaranteed data structures, and that's all that it is. If you want to compile this
(I'm just going to compile it to an object file,
0.45 seconds for that if I turn on optimizations) I don't, there's actually nothing different for it
to do if I turn on optimizations or not, because it's still creating the same object data. So it's
even just look here - my 'test_document.o' is 4k if I have -O3, and it is 110 bytes smaller.
Now that other very large schema file that I mentioned that has 155,000 objects in it,
this one can take considerably longer to compile - upwards to a minute or so depending on platform.
Now it is a 19 megabyte file, so this is probably not one that you want to include in lots of places
and actually make constexpr decisions on, but it's a thing. Okay that actually took 1.2 minutes.
So constexpr is a funny world where sometimes the optimized build is actually faster to compile than
the unoptimized build. I think I can get those times down if I move away from std::variant
to something that's a little bit more custom, and I'm probably going to play with that
shortly after recording this episode. Okay. So we've done interesting things.
It creates data structures. Let's take a quick look at our test suite
so you can see that I can actually make compile time decisions and say, "At compile time, because
I'm using static_require here from Catch2, does this thing have the expected number of elements?"
For example, does my count of elements equal this 'document.size'? Does the first key have
the name 'glossary'? So you could, in real code, do something like
(this is very much slidewear pseudocode that there's no chance of compiling
here) but you could take a JSON resource that is compiled as part of your build process,
and then make compile time decisions. So this could be the configuration file for your embedded
device. Then at compile time you decide which drivers to include, which hardware to instantiate,
what is the actual properties of the device that you're going to be using, whatever. This could be
script game code that is all known at compile time and compile time decisions are made based on that.
Don't underestimate the potential power here. This could drastically change the way
that you configure and build your projects and could, in some cases,
significantly change the startup and runtime performance of your projects,
particularly on embedded devices and perhaps, in fact, affect code size
depending on what exactly you want to rely on here. So this is kind of a poor way of doing this.
Let's make this, perhaps, a little bit more readable. Because it has that nlohmann JSON
API you could do code (uh) that looks much closer to this
at compile time. So anyhow I hope you enjoyed this episode of C++ Weekly. I am open to pull
requests and I am open to people checking out that json2cpp project, and let me know
if I screwed anything up. I know it needs more tests to make sure that the API is actually the
same as nlohmann JSON, but I think I cover most of it with my Valijson test because it has to
exercise most of the API but, yeah. I hope this is interesting. Thanks for watching.