Conversation
Claude review of PR #470 (a34983a)Must fix
Suggestions
Nits
CLAUDE.md improvements
Resolved from prior review
|
314af18 to
02938a3
Compare
qdeslandes
left a comment
There was a problem hiding this comment.
Alright, first pass of review and a few things to fix. I'll do a second, deeper pass, when those are solved. That being said, it's a very welcome addition! :D
02938a3 to
1aae907
Compare
1aae907 to
550c254
Compare
550c254 to
d6883e1
Compare
There was a problem hiding this comment.
Super neat implementation! Haven't seen an insertion order hashset like this implemented before. First two commits and last commit LGTM. Have a few nits and some questions on hashset.h/c but I think this is pretty much ready. (Haven't reviewed the tests yet, will do that tomorrow)
There was a problem hiding this comment.
Now that we keep insertion order, should we add a simple bf_hashset_take?
There was a problem hiding this comment.
What should it give us, bf_hashset_elem*? I'm not sure how useful that is.
There was a problem hiding this comment.
We have bf_hashset_remove, which already takes ownership of a single element of data. I was thinking it would be more along the lines of bf_dynbuf_take (where it takes all of the data), but I think we can wait to see if Quentin thinks that'd be helpful
8c9959f to
0e7595e
Compare
src/libbpfilter/core/hashset.c
Outdated
| if (!_bf_hashset_find(set, data, &idx)) | ||
| return -ENOENT; | ||
|
|
||
| slot = &set->slots[idx]; |
There was a problem hiding this comment.
Claude: suggestion: In bf_hashset_remove, after set->ops.free(&slot->data, ...) runs, the data parameter passed to this function may be a dangling pointer if the caller passed elem->data from a bf_hashset_foreach loop (as the foreach_remove test does). The code is currently correct because data is not used after the free callback runs, but this is fragile — a future change that reads data after this point would introduce a use-after-free.
Consider adding a comment:
// Note: the free callback may invalidate the `data` parameter if it
// points into the same allocation as slot->data. Do not use `data`
// after this point.
if (set->ops.free)
set->ops.free(&slot->data, set->ctx);There was a problem hiding this comment.
bf_list_foreach(list, elem) bf_list_delete(elem) has the same problem. I think that after you call a function called *_delete, you should expect the element to be unusable.
A simple data hashing function. C stdlib lacks one.
0e7595e to
ef58a62
Compare
src/libbpfilter/core/hashset.c
Outdated
|
|
||
| if (data == _BF_HASHSET_TOMBSTONE) { | ||
| return bf_err_r( | ||
| -EINVAL, "data pointer collides with internal tombstone sentinel"); |
There was a problem hiding this comment.
Claude: suggestion: bf_hashset_add probes the table twice for every non-duplicate insertion: once via bf_hashset_contains (which calls _bf_hashset_find), then again in the for loop at line 228 to locate a free slot. Both walks start from the same hash index and traverse the same probe chain.
Consider extending _bf_hashset_find to also return the index of the first tombstone or empty slot it encounters during probing, so bf_hashset_add can reuse that index and skip the second scan.
src/libbpfilter/core/hashset.c
Outdated
| grown.cap = new_cap; | ||
|
|
||
| bf_hashset_foreach (set, elem) { | ||
| r = bf_hashset_add(&grown, elem->data); |
There was a problem hiding this comment.
Claude: suggestion: During _bf_hashset_grow, bf_hashset_add(&grown, elem->data) performs a bf_hashset_contains duplicate check and a _bf_hashset_needs_grow load-factor check on every re-inserted element. Both are redundant during rehashing: elements are known unique (they come from the original set), and the new table was just allocated at 2 * old_cap so the load factor cannot trigger a nested grow.
Consider a private _bf_hashset_insert_unchecked helper that skips both checks to avoid the overhead on every rehash.
|
I let Claude search for optimizations overnight, and it proposed a different representation that has much better cache locality - |
ef58a62 to
a34983a
Compare
| #define _BF_HASHSET_INIT_CAP 16 | ||
| /* Maximum load factor before growing. Lowering this number reduces collisions | ||
| * but causes higher memory usage. */ | ||
| #define _BF_HASHSET_MAX_LOAD_NUM 5 |
There was a problem hiding this comment.
Claude: must-fix: The load factor threshold implemented here is 50%, not 70% as stated in the PR description.
_BF_HASHSET_MAX_LOAD_NUM = 5 and _BF_HASHSET_MAX_LOAD_DEN = 10 produce the check slots_in_use * 10 >= cap * 5, which triggers growth when occupancy reaches 50%. The PR description says "grows 2x when load factor exceeds 70%".
If 70% is intended, the constants should be:
#define _BF_HASHSET_MAX_LOAD_NUM 7
#define _BF_HASHSET_MAX_LOAD_DEN 10This also affects bf_hashset_reserve (line 206), which currently computes count * 10 / 5 = 2 * count slots instead of the count * 10 / 7 ~ 1.43 * count that a 70% threshold would require.
If 50% is intentional, the PR description should be corrected.
| if (count == 0) | ||
| return 0; | ||
|
|
||
| needed = count * _BF_HASHSET_MAX_LOAD_DEN / _BF_HASHSET_MAX_LOAD_NUM; |
There was a problem hiding this comment.
Claude: suggestion: count * _BF_HASHSET_MAX_LOAD_DEN can overflow size_t when count is large (greater than SIZE_MAX / 10). For example, count = SIZE_MAX / 10 + 1 wraps needed to a small value, causing the function to silently return 0 without reserving capacity.
A simple guard before the multiplication would prevent this:
if (count > SIZE_MAX / _BF_HASHSET_MAX_LOAD_DEN)
return -ENOMEM;In practice the packet-filtering use case won't hit this, but since bf_hashset_reserve is a public API in a generic container, the guard would make the contract more robust.
| } bf_hashset_ops; | ||
|
|
||
| /** | ||
| * @brief A single slot in the hashset's backing array. |
There was a problem hiding this comment.
Claude: suggestion: The @brief says "A single slot in the hashset's backing array" but bf_hashset_elem is not a slot. The slots array holds bf_hashset_elem * pointers -- each pointer can be NULL, the tombstone sentinel, or point to a heap-allocated bf_hashset_elem. The struct itself is a separately-allocated element node containing data plus linked-list pointers for insertion-order traversal.
Suggested: @brief Element node stored in a hashset, threaded through an insertion-order linked list.
| */ | ||
| typedef struct bf_hashset_elem | ||
| { | ||
| /// Data pointer, tombstone, or NULL. |
There was a problem hiding this comment.
Claude: nit: The inline comment says "Data pointer, tombstone, or NULL" but elem->data never holds a tombstone. Tombstones are sentinel values stored at the slots[] level (where each slot is a bf_hashset_elem *), not inside the data field of a live element. A bf_hashset_elem is only allocated for live entries.
Suggested: /// User-provided data pointer.
| { | ||
| bf_hashset_elem **new_slots; | ||
|
|
||
| new_slots = (bf_hashset_elem **)calloc(new_cap, sizeof(*new_slots)); |
There was a problem hiding this comment.
Claude: nit: The calloc return is cast to (bf_hashset_elem **), but the two other allocation sites in this file (lines 129, 242) and the wider codebase (e.g. core/list.c, chain.c, cgen.c) never cast malloc/calloc returns. In C, the void * return converts implicitly. Drop the cast for consistency:
new_slots = calloc(new_cap, sizeof(*new_slots));| return set->ops.hash(data, set->ctx) % set->cap; | ||
| } | ||
|
|
||
| static int _bf_hashset_resize(bf_hashset *set, size_t new_cap) |
There was a problem hiding this comment.
Claude: nit: _bf_hashset_resize is the only static function in this file without an assert(set) at the top. The other static helpers (_bf_hashset_index, _bf_hashset_grow, _bf_hashset_needs_grow, _bf_hashset_find) all start with assert(set). Adding it here would be consistent.
yaakov-stein
left a comment
There was a problem hiding this comment.
Claude has a few valid nits/suggestions and I have one last comment on the tests. Overall LGTM once those points are taken care of!
| /** | ||
| * @brief Pre-allocate capacity for at least @p count elements. | ||
| * | ||
| * Ensures the backing array is large enough to hold @p count elements without |
There was a problem hiding this comment.
nit: backticks a few times in this doxygen
| * @return 0 on success, -ENOENT if the element is not found, or a negative | ||
| * errno value on failure. | ||
| */ | ||
| int bf_hashset_delete(bf_hashset *set, const void *data); |
There was a problem hiding this comment.
Just wondering - why delete instead of remove?
| return set->ops.hash(data, set->ctx) % set->cap; | ||
| } | ||
|
|
||
| static int _bf_hashset_resize(bf_hashset *set, size_t new_cap) |
| { | ||
| bf_hashset_elem **new_slots; | ||
|
|
||
| new_slots = (bf_hashset_elem **)calloc(new_cap, sizeof(*new_slots)); |
There was a problem hiding this comment.
Regarding the tests, I think there's a couple areas that are especially complex that we'd want to test (some of which you already have covered):
- Ensure insertion order is maintained across a grow operation (already covered)
- Validate that this works for several elements that collide, i.e. their hashes are equal. On top of this, it'd be worth testing that removing some of those colliding elements and creating tombstones within that linear probe doesn't cause any buggy behavior. (I don't believe these cases are covered)
I'm not necessarily opposed to the change as it simplifies some parts of the code, but I'm confused by the claim that this has better cache locality - shouldn't the cache locality here be much worse? Whenever we need to check for equality we need to load a non-contiguous piece of memory. We also can't take advantage of the spatial locality anymore. Can you explain to me what cases you saw ~40% speedup on? |
Currently,
bf_setusesbf_listunder the hood. This is suboptimal performance-wise. Here I am changingbf_setto be backed bybf_hashset.lib: helper: add FNV-1a hash function- imported from another PR, a simple hash function.lib: add bf_hashset- main commit. Implements a simple hashset (tombstones on deletion, grows 2x when load factor exceeds 70%, never shrinks, linear probing on collision). See https://en.wikipedia.org/wiki/Linear_probing (with "special flag value" strategy in "Deletion" section).lib: set: use bf_hashset for elems field- changesbf_set's inner structureSee #460 for previous attempt.
Fixes #418