-
Notifications
You must be signed in to change notification settings - Fork 373
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
LSM: Revert groove to use update + insert and vendor hasUniqueRepresentation #1239
Conversation
Save a lookup by requiring the old object to be passed in to the Groove, and only check if constants.verify is true.
src/stdx.zig
Outdated
|
||
.Struct => |info| { | ||
// Packed structs are always unique. | ||
if (info.backing_integer != null) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If a struct is packed, we don't need to recursively check it like the unpacked case below, since Zig ensures packed structs can only contain other packed structsa.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's add a couple of tests here though, just to sanity-check things.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, for a good measure, let's assert that the bitsize of all fields adds up to the sizeof the whole struct (that is, that we don't have padding bits).
I am thinking about cases like
const S = packed struct {
x: u24,
y: u3,
};
I think the language may work both as "the spare bits after y
are zeroed" and "the spare bits after y
are arbitrary". I think they are actually zeroed, but let's assert that we just don't have such weird structs altogether.
The expectation is that the state machine will call .update() when there is in fact an update. We still check stdx.equal_bytes gated behind constants.verify. We also double check key_from_value and that the update is not a tombstone, too.
Can we add |
…rder * target first style for copies gets const protection, which we can't take advantage of here. * There's no real convention, because this .update() method doesn't update a parameter with an existing one, but rather updates the groove based off of both. * There's no performance cost to using a struct (asm generated on godbolt was identical and double checked on the benchmark).
src/stdx.zig
Outdated
|
||
.Struct => |info| { | ||
// Packed structs are always unique. | ||
if (info.backing_integer != null) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's add a couple of tests here though, just to sanity-check things.
src/stdx.zig
Outdated
|
||
.Struct => |info| { | ||
// Packed structs are always unique. | ||
if (info.backing_integer != null) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, for a good measure, let's assert that the bitsize of all fields adds up to the sizeof the whole struct (that is, that we don't have padding bits).
I am thinking about cases like
const S = packed struct {
x: u24,
y: u3,
};
I think the language may work both as "the spare bits after y
are zeroed" and "the spare bits after y
are arbitrary". I think they are actually zeroed, but let's assert that we just don't have such weird structs altogether.
src/lsm/groove.zig
Outdated
} | ||
|
||
groove.objects.put(new); | ||
groove.objects_cache.upsert(new); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This makes my my Rust eye twitch a bit. Isn't this shared mutable aliasing? I think the old
object might be the object from the cache (maybe(old == old_from_cache)
in the if above). If that's the case, I think we might be clobbering old here, in which case the inline for
below would get wrong results when accessing old
fields?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We have an explicit guard against this above; since I've been bitten by this before here 😄:
assert(new != old);
I think that's enough to prevent it from happening?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, I think that's different. The new
is an object on state_machine stack:
tigerbeetle/src/state_machine.zig
Line 870 in c367d1d
var dr_account_new = dr_account.*; |
the old
is an object from the cache:
tigerbeetle/src/state_machine.zig
Line 813 in c367d1d
const dr_account = self.forest.grooves.accounts.get(t.debit_account_id) orelse return .debit_account_not_found; |
So new
and old
are different.
However, when we do groove.objects_cache.upsert(new);
this might invalidate old
still, if it was in the cache. That is, after this line I would expect new.*
and old.*
to be equal, because the old
memory was overwritten when we upserted new
.
Not 100% sure that this could happen, but I think it does?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ahhh yes, that is the case. Good catch!
In the past, I took an explicit stack copy of old
within the function which prevented that. We could move the object cache upsert to the end - there's no reason it needs to be done before the index trees - with an explicit comment explaining the above. That way we can get correct behaviour without a stack copy, but it still feels a bit fragile...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that's ok, as long as we document that with a maybe
.
I wonder if we even can require that this is the object from the cache? It must be, if we are updating, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am also reeeeeally wondering why CI is green here 🤔 in release
, I can see compiler hoisting the loads to before the cache update, which would give the correct behavior.
But in debug, I expect the overwrite to be reflected robustly. Could you also take a look what's up with tests passing here? Either my theory about the overwrite is wrong (good case), or we somehow don't test this case at all (which would be pretty scary).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you also take a look what's up with tests passing here? Either my theory about the overwrite is wrong (good case), or we somehow don't test this case at all (which would be pretty scary).
As far as I can tell, we don't have any tests (unit or fuzz) that actually check the result of the secondary index trees, and in this case, if old.* == new.*
it'll just skip updating the secondary index so there's nothing that's hit on the way down 😬...
Also just noticed the forest fuzz (which I forgot to run / update in this PR) has also been broken for a few weeks. I need to look at proper infra for fuzzing ASAP, but maybe as a workaround for now we can just run each fuzz for a fixed time on CI?
We previously did run some more fuzzes in CI, but with a fixed seed for a single iteration.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if we even can require that this is the object from the cache? It must be, if we are updating, right?
It could in theory be a stack copy, but we don't have an explicit use case for that yet so I've made the check assert(old_from_cache == old);
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also just noticed the forest fuzz (which I forgot to run / update in this PR) has also been broken for a few weeks.
I think that is fixed as part of #1234, which merged this morning.
src/lsm/groove.zig
Outdated
if (constants.verify) { | ||
const old_from_cache = groove.objects_cache.get(@field(old, primary_field)).?; | ||
assert(@field(old_from_cache, primary_field) == @field(old, primary_field)); | ||
assert(old_from_cache.timestamp == old.timestamp); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you assert that stdx.bytes_equal(Object, old, old_from_cache);
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm now aserting they're actually one and the same: #1239 (comment)
// objects_cache. If we upsert first, old.* == new.* and no secondary indexes will | ||
// be updated! | ||
groove.objects_cache.upsert(new); | ||
groove.objects.put(new); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For an extra tripple check, can we assert here that now old & new contents is the same?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok - so that won't actually hold; if the old value comes from the stash of the cache_map, and we insert a new value, it'll go into the cache - so now old.* != new.*
.
The assertion at the start, assert(old_from_cache == old);
will still hold because if it comes from the stash from the caller, it should still come from the stash inside.
src/stdx.zig
Outdated
return true; | ||
// Only consider packed structs unique if they are byte aligned. | ||
if (info.backing_integer) |backing_integer| { | ||
return @sizeOf(@Type(.{ .Struct = info })) * 8 == @bitSizeOf(backing_integer); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@sizeOf(T)
would be simpler I think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
... yes, I haven't had my Monster yet. 🤣
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's time to go pull an espresso too... ☕
@@ -910,9 +910,7 @@ pub fn GrooveType( | |||
// Sanity check to ensure the caller didn't accidentally pass in an alias. | |||
assert(new != old); | |||
|
|||
if (has_id) { | |||
assert(old.id == new.id); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 I actually do line one-line without braces form quite a bit more, when it works
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, we don't need to use kernel style indented returns etc.
Motivation
Whenever we update an object, we always have the old object around. Previously, we used to do a lookup to find the old one in any case, to give a simpler
upsert
function signature to the state machine - but this was resulting in duplicated work.Changes
Revert that, going back to having an explicit
update
andinsert
with the state machine choosing which is better for its needs. Additionally, switch to usingstdx.equal_bytes
in the Grooveupdate
hot path. This required vendoring a small fix for ziglang/zig#17592.Benchmarks
These two things combined give a nice performance bump:
# on tmpfs ./scripts/benchmark.sh --print-batch-timings --transfer-count=10_000_000 --account-count=10 --id-order=sequential
Before:
After: