

Let’s build something Outrageous – Part 12: Finding Things
source link: https://maxdemarzi.com/2021/08/24/lets-build-something-outrageous-part-12-finding-things/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Let’s build something Outrageous – Part 12: Finding Things
Knowing yourself is the beginning of all wisdom
Aristotle
At some point in your life you will look at the mirror and not recognize the person staring back at you. It’s not only the way you look, but the choices you made and the results of those choices that will seem puzzling. You may decide right then and there to embark in the most important quest of your life. Finding yourself.
Nodes and Relationships don’t get the luxury of having mirrors, but we still need to find them every once in a while. So far all we are able to do is get a node by a label and key or use the id to get a node or relationship. This is great and covers a lot of use cases, but sometimes the primary id is not what we are looking for. We may be looking for all nodes of a type that share a property value equal to something, or greater/less than something else. How can we go about finding the answers to these types of queries in our graph?
Having never done this before, I don’t know. So we’re going to try something here and see what happens. We need to first start with the types of operations we are going to support. For now, this is our list:
enum
Operation {
EQ,
// A == B
NEQ,
// A != B
GT,
// A > B
GTE,
// A >= B
LT,
// A < B
LTE,
// A <= B
IS_NULL,
// A is Null
STARTS_WITH,
// A starts with B
CONTAINS,
// A contains B
ENDS_WITH,
// A ends with B
NOT_IS_NULL,
// A is not Null
NOT_STARTS_WITH,
// A does not start with B
NOT_CONTAINS,
// A does not contain B
NOT_ENDS_WITH,
// A does not end with B
UNKNOWN
};
We could add case insensitive options to the starts/ends/contains variants, but let’s just keep it simple for now. We’ll figure out how to find Nodes then we can “copy pasta” for the relationships. We’ll create a findNodes method that takes the type of node we are searching for, the name of the property we care about, the operation we are trying to perform, the value we are comparing against and a skip and limit so it can be iterated on. Looks a bit like this:
std::vector<Node> NodeTypes::findNodes(
uint16_t
type_id,
const
std::string &property, Operation operation, std::any value,
uint64_t
skip,
uint64_t
limit) {
std::vector<Node> nodes;
if
(ValidTypeId(type_id)) {
uint16_t
property_type_id = properties[type_id].getPropertyTypeId(property);
int
current = 1;
uint64_t
max_id = key_to_node_id[type_id].size();
The easiest things to find are IS_NULL and NOT_IS_NULL since we don’t care what type of property it is, we don’t even have to read the property, we just need to know it exists. If you recall earlier posts we keep track of deleted nodes and deleted properties in Roaring Bitmaps, so we can leverage those to help us answer this operation. For example for IS_NULL, we start with a blank bitmap and we add the deleted properties bitmap to it, then we remove the deleted nodes since those don’t count. Whatever is left are node ids of the properties set to null, so we iterate through them and grab the ones we need. Once we have more than the skip+limit we are done.
// Start blank, union with deleted properties, remove deleted nodes
if
(operation == Operation::IS_NULL) {
roaring::Roaring64Map blank;
blank |= properties[type_id].getDeletedMap(property);
blank -= getDeletedMap(type_id);
for
(roaring::Roaring64MapSetBitForwardIterator iterator = blank.begin(); iterator != blank.end(); ++iterator) {
if
(current > (skip + limit)) {
break
;
}
if
(current++ > skip ) {
nodes.emplace_back(getNode(type_id, *iterator));
}
}
return
nodes;
}
Finding properties that are NOT_IS_NULL follows a similar idea. We start with a full bitmap from 0 to max_id, remove the deleted nodes, then remove the deleted properties. Whatever is left has a valid value for that property.
Let’s now move on to the harder ones and try to find a node with a property equal to something. Since we don’t have any indexes we’re going to have to brute force it by looking at every property. We can do so one at a time or in batches. Lets try one at a time first and see where that gets us, we can implement the batching later (or maybe you can, I’d love some help!). We are going to iterate from zero to the max id, return once we get all the nodes we need, skip any deleted nodes and skip any deleted properties.
for
(
uint64_t
internal_id=0; internal_id < max_id; ++internal_id) {
// If we reached out limit, return
if
(current > (skip + limit)) {
return
nodes;
}
// If the node has been deleted, ignore it
if
(deleted_ids[type_id].contains(internal_id)) {
continue
;
}
// If the property has been deleted, ignore it
if
(properties[type_id].isDeleted(property, internal_id)) {
continue
;
}
Before we go much further, we need an Expression class that will evaluate the operation we are trying to perform, the property of the node (a) and the property we are comparing against (b). As much as I love copy and paste, I don’t want to have to do this a billion times, so I’m actually going to use a template for our Evaluate method:
class Expression {
public:
template <typename T>
static bool Evaluate(Operation operation, T a, T b) {
switch(operation) {
case Operation::EQ: {
return EQ(a, b);
}
case Operation::NEQ: {
return NEQ(a, b);
}
case Operation::GT: {
return GT(a, b);
}
...
…and more templates for our comparisons:
template
<
typename
T>
static
bool
EQ(T a, T b) {
return
a == b;
}
template
<
typename
T>
static
bool
NEQ(T a, T b) {
return
a != b;
}
template
<
typename
T>
static
bool
GT(T a, T b) {
return
a > b;
}
Look out world, I’m a real C++ programmer now. To use these we need to call them in our loop. For example when looking at an Integer property to compare, we first make sure we are comparing to an integer value, then we pass in “int64_t” for our class to our Evaluate method in Expression and pass in the operation, the property of the node and the value casted from any to int64_t:
case
Properties::getIntegerPropertyType(): {
if
(Properties::isIntegerProperty(value)) {
if
(!Expression::Evaluate<
int64_t
>(operation, properties[type_id].getIntegerProperty(property, internal_id), std::any_cast<
int64_t
>(value))) {
continue
;
}
}
break
;
}
If the evaluation of the expression is not true, we ignore it and continue on to the next value. If it does turn out to be true, we break out of the switch statement, add our valid node and increment our current count:
if
(current > skip) {
nodes.emplace_back(getNode(type_id, internal_id));
}
current++;
Alright, let’s test this out in our gatling based benchmark by creating one million nodes with a number property between 1 and 10.
"number"
-> usFaker.number().numberBetween(
1
,
100000
),
Then once that’s done, we’ll go find them. We should have about 10 nodes with each number property, so to make this a hard test, we’re going to return 10 but skip the first 5. That will force the database to go through all 1 million node properties.
val fakeFeeder: Iterator[Map[String, Any]] = Iterator.continually(
Map(
"number" -> usFaker.number().numberBetween(1,100000)
)
)
...
def createPostRequest: HttpRequestBuilder = {
http("FindNodeFakeProperties")
.post("/db/" + params.rageDB + "/nodes/Address/number/EQ?limit=10&skip=5")
.body(StringBody( """${number}"""))
.asJson
.check(status.is(200))
}
We run our test and here are the results:
================================================================================
---- Global Information --------------------------------------------------------
> request count 1948 (OK=1948 KO=0 )
> min response time 278 (OK=278 KO=- )
> max response time 3108 (OK=3108 KO=- )
> mean response time 992 (OK=992 KO=- )
> std deviation 335 (OK=335 KO=- )
> response time 50th percentile 995 (OK=995 KO=- )
> response time 75th percentile 1178 (OK=1178 KO=- )
> response time 95th percentile 1585 (OK=1585 KO=- )
> response time 99th percentile 1853 (OK=1853 KO=- )
> mean requests/sec 31.934 (OK=31.934 KO=- )
---- Response Time Distribution ------------------------------------------------
> t < 800 ms 518 ( 27%)
> 800 ms < t < 1200 ms 989 ( 51%)
> t > 1200 ms 441 ( 23%)
> failed 0 ( 0%)
================================================================================
About 32 requests per second. It takes us about 1 second to get our answer and our max is 3 seconds. That’s not a bad start, but I think we can do better. One of the things we’re doing is calling getIntegerProperty in our loop:
properties[type_id].getIntegerProperty(property, internal_id)
This method is doing a bunch of check work which make sense when being called randomly but not from this internal search method.
int64_t
Properties::getIntegerProperty(
const
std::string& key,
uint64_t
index) {
auto
search = integers.find(key);
if
(search != integers.end()) {
if
(index < integers[key].size() ) {
return
integers[key][index];
}
}
return
tombstone_int;
}
I’m going to make “integers” public and trade it for:
properties[type_id].integers[property][internal_id]
Lets recompile, recreate our nodes and try the benchmark again:
================================================================================
---- Global Information --------------------------------------------------------
> request count 2840 (OK=2840 KO=0 )
> min response time 169 (OK=169 KO=- )
> max response time 2250 (OK=2250 KO=- )
> mean response time 677 (OK=677 KO=- )
> std deviation 333 (OK=333 KO=- )
> response time 50th percentile 636 (OK=636 KO=- )
> response time 75th percentile 849 (OK=849 KO=- )
> response time 95th percentile 1360 (OK=1360 KO=- )
> response time 99th percentile 1529 (OK=1529 KO=- )
> mean requests/sec 46.557 (OK=46.557 KO=- )
---- Response Time Distribution ------------------------------------------------
> t < 800 ms 2007 ( 71%)
> 800 ms < t < 1200 ms 592 ( 21%)
> t > 1200 ms 241 ( 8%)
> failed 0 ( 0%)
================================================================================
Nice. We jumped from 32 to 46 queries per second, dropped our mean to about 2/3 of a second and our max is 2.2 seconds. But something else is bothering me about this query. For every single node I am checking if the value is a of a particular type and I am casting that value to the type we care about. But I should only ever have to do this once per loop, not every single time.
if
(Properties::isIntegerProperty(value)) {
...
std::any_cast<
int64_t
>(value)
Let’s avoid that extra work. We’ll create a void pointer to hold our value, check and cast into it once and just reinterpret cast in the loop.
void
* valuePointer;
switch
(property_type_id) {
...
case
Properties::getIntegerPropertyType(): {
if
(Properties::isIntegerProperty(value)) {
valuePointer = std::any_cast<
int64_t
>(&value);
break
;
}
return
nodes;
}
...
switch
(property_type_id) {
...
case
Properties::getIntegerPropertyType(): {
if
(!Expression::Evaluate<
int64_t
>(operation, properties[type_id].integers[property][internal_id], *
reinterpret_cast
<
int64_t
*>(valuePointer))) {
continue
;
}
We will recompile again and redo our benchmark and what do we get:
================================================================================
---- Global Information --------------------------------------------------------
> request count 3474 (OK=3474 KO=0 )
> min response time 139 (OK=139 KO=- )
> max response time 2171 (OK=2171 KO=- )
> mean response time 554 (OK=554 KO=- )
> std deviation 276 (OK=276 KO=- )
> response time 50th percentile 450 (OK=450 KO=- )
> response time 75th percentile 660 (OK=660 KO=- )
> response time 95th percentile 1141 (OK=1141 KO=- )
> response time 99th percentile 1336 (OK=1336 KO=- )
> mean requests/sec 56.951 (OK=56.951 KO=- )
---- Response Time Distribution ------------------------------------------------
> t < 800 ms 2833 ( 82%)
> 800 ms < t < 1200 ms 530 ( 15%)
> t > 1200 ms 111 ( 3%)
> failed 0 ( 0%)
================================================================================
Double Nice. We’ve gone from 32 to 57 requests per second, our mean time is down to about half a second and our max… well our max is still hanging out just over 2 seconds but that’s better than the 3 we started with. But there still things I don’t like. For example the way we access the values in the vectors. We have to get the map entry via the property string then fetch the index. Let’s get another point to just the vector and skip finding the property in the map every time:
properties[type_id].integers[property][internal_id]
vs
integerVector[internal_id]
…and one more time:
================================================================================
---- Global Information --------------------------------------------------------
> request count 3925 (OK=3925 KO=0 )
> min response time 122 (OK=122 KO=- )
> max response time 1703 (OK=1703 KO=- )
> mean response time 490 (OK=490 KO=- )
> std deviation 219 (OK=219 KO=- )
> response time 50th percentile 475 (OK=475 KO=- )
> response time 75th percentile 582 (OK=582 KO=- )
> response time 95th percentile 967 (OK=967 KO=- )
> response time 99th percentile 1152 (OK=1152 KO=- )
> mean requests/sec 64.344 (OK=64.344 KO=- )
---- Response Time Distribution ------------------------------------------------
> t < 800 ms 3579 ( 91%)
> 800 ms < t < 1200 ms 324 ( 8%)
> t > 1200 ms 22 ( 1%)
> failed 0 ( 0%)
================================================================================
Got another 7 requests per second, cut our mean to under half a second and our max is now hovering around 1.7 seconds. But the code looks like a mess. I have two switch statements, pointers, too many ifs. Let’s try again and simplify this by first getting our deleted nodes and properties into a single roaring bitmap. Second by “type” we’ll any_cast the value into the right type. Third, we will get a reference to the vector of values we are going to be checking, and fourth we’ll iterate over this vector. So the code looks like this:
roaring::Roaring64Map blank;
blank |= properties[type_id].getDeletedMap(property);
blank |= getDeletedMap(type_id);
switch
(property_type_id) {
case
Properties::getIntegerPropertyType(): {
if
(Properties::isIntegerProperty(value)) {
const
int64_t
integerValue = std::any_cast<
int64_t
>(value);
const
std::vector<
int64_t
> &vec = properties[type_id].integers[property];
for
(unsigned internal_id = 0; internal_id < vec.size(); ++internal_id) {
// If we reached out limit, return
if
(current > (skip + limit)) {
return
nodes;
}
// If the node or property has been deleted, ignore it
if
(blank.contains(internal_id)) {
continue
;
}
if
(!Expression::Evaluate<
int64_t
>(operation, vec[internal_id], integerValue)) {
continue
;
}
// If it is true add it if we are over the skip, otherwise ignore it
if
(current > skip) {
nodes.emplace_back(getNode(type_id, internal_id));
}
current++;
}
return
nodes;
}
}
At least it makes more sense. Let’s see what happens when we run our benchmarks:
================================================================================
---- Global Information --------------------------------------------------------
> request count 36694 (OK=36694 KO=0 )
> min response time 10 (OK=10 KO=- )
> max response time 132 (OK=132 KO=- )
> mean response time 52 (OK=52 KO=- )
> std deviation 10 (OK=10 KO=- )
> response time 50th percentile 51 (OK=51 KO=- )
> response time 75th percentile 59 (OK=59 KO=- )
> response time 95th percentile 69 (OK=69 KO=- )
> response time 99th percentile 85 (OK=85 KO=- )
> mean requests/sec 601.541 (OK=601.541 KO=- )
---- Response Time Distribution ------------------------------------------------
> t < 800 ms 36694 (100%)
> 800 ms < t < 1200 ms 0 ( 0%)
> t > 1200 ms 0 ( 0%)
> failed 0 ( 0%)
================================================================================
What? It’s 10 times better. We’re now all the way up to 600 requests per second, our mean is down to 50ms and our max is under 150ms. That is sooooo much better. Ok, so it looks like I have no clue what I’m doing.
UPDATE: I didn’t like having to make the internal pieces of Properties public, so I cleaned it up some by adding reference functions. I was only working on int64_t but I finished the rest and on my latest runs it looks like we got another 20% bump in performance:
================================================================================
---- Global Information --------------------------------------------------------
> request count 44170 (OK=44170 KO=0 )
> min response time 13 (OK=13 KO=- )
> max response time 134 (OK=134 KO=- )
> mean response time 43 (OK=43 KO=- )
> std deviation 13 (OK=13 KO=- )
> response time 50th percentile 41 (OK=41 KO=- )
> response time 75th percentile 49 (OK=49 KO=- )
> response time 95th percentile 69 (OK=68 KO=- )
> response time 99th percentile 81 (OK=81 KO=- )
> mean requests/sec 724.098 (OK=724.098 KO=- )
---- Response Time Distribution ------------------------------------------------
> t < 800 ms 44170 (100%)
> 800 ms < t < 1200 ms 0 ( 0%)
> t > 1200 ms 0 ( 0%)
> failed 0 ( 0%)
================================================================================
If any of you C++ folks like performance puzzles and can help make this faster, I’m all ears. Please join me on Slack.
In the mean time I’m going to investigate a way to vectorize it so we can check multiple node properties in batches instead of doing things one at a time. Until next time!
Recommend
-
7
Let’s build something Outrageous – Part 2 When I was a new Java developer I would sometimes wake up in the middle of the night hyperventilating and covered in sweat. Usually from a nightmare about
-
4
Let’s build something Outrageous – Part 5: Properties Whatever your views on the g...
-
9
Let’s build something Outrageous – Part 6: Relationships Good relationships are hard. They don’t just happen. They take time, patience and about two thousand lines of code to work together. I’m not convince...
-
12
Let’s build something Outrageous – Part 8: Queries with Lua Jamie Brandon wrote a monster of a blog post the other day
-
8
Let’s build something Outrageous – Part 7: Performance I don’t know why, but I need things to be fast. Nothing drives me up the wall more than waiting a long time for some database query to finish. It’s som...
-
8
Let’s build something Outrageous – Part 9: Docker In the movie “Field of Dreams“, a voice can be heard saying “
-
3
Let’s build something Outrageous – Part 10: Nulls We decided that our Nodes and Relationships will have Schema in our database. If we create a User Node Type and set an “age” property type as an Integer, th...
-
11
Let’s build something Outrageous – Part 11: Testing Helene has been testing software since we met in college back in 1999. Today she is the head of QA at S&P Market Intelligence managing hundreds of peo...
-
5
Let’s build something Outrageous – Part 13: Finding Things Faster In the previous blog post we started...
-
8
Let’s build something Outrageous – Part 14: Command Logging You may have noticed that homes are selling for ridiculously high prices lately. Some are calling it inflation, others see it as a supply chain sq...
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK