Repository navigation
Fix HXCPP_GC_BIG_BLOCKS row count regression - #1409
dimensionscape wants to merge 6 commits into
Conversation
HaxeFoundation#1402 made BlockDataInfo's mUsedRows and mFraggedRows uint8_t, which fits the default 32k blocks (254 usable lines) but not big blocks (508). With HXCPP_GC_BIG_BLOCKS the counts wrap, the GC underestimates the live heap, and it ends up collecting on almost every new block. Use uint16_t for both (and for the allocator's mFraggedRows pointer), with static_asserts so they can't drop below IMMIX_USEFUL_LINES again.
| }; | ||
|
|
||
| // A block's row counts run up to IMMIX_USEFUL_LINES: 254 with the default 32k blocks, but 508 with | ||
| // HXCPP_GC_BIG_BLOCKS' 64k blocks, more than a uint8_t holds. |
There was a problem hiding this comment.
This comment doesn't seem very useful to keep, since it refers to the uint8_t that is removed by this PR. I think the static assert is more helpful at documenting the requirement
There was a problem hiding this comment.
Yeah, that's fair. It was originally a warning against regression, but the static asserts keep us safe. I'll remove it.
|
I think widening to |
I’d just keep it as a single uint16_t. The regression in #1402 came from using a type that was correct for default blocks but wrong for big blocks, and nobody caught it because big blocks isn’t built in CI (which highlights another issue). Then #1413 broke big blocks compilation and that went unnoticed for the same reason. Making the row type depend on the define just keeps that blind spot and makes things more complex than they need to be. Some stray uint8_t row count such as a local, a cast, a helper parameter, or whatever will compile fine in the default build and only truncate with big blocks (which is not being run). With everything uint16_t, any accidental narrowing shows up as a conversion warning in the builds people actually compile, and the static asserts make it impossible to shrink the fields below IMMIX_USEFUL_LINES again. Sanitisers don’t really change that. A default build never has more than 254 rows, so a uint8_t there won’t truncate in a correct program. The overflow that actually happened was 508 rows going into a uint8_t, and that case only exists with big blocks, where both versions are uint16_t anyway. There’s also basically no practical downside to uint16_t. The fields get promoted to int in expressions anyway, so there’s no meaningful performance difference. The size cost is at most a few bytes of padding in a structure that already contains a ~500-byte hole table, once per 32 KB block. And the churn repro behaves the same in default builds either way... |
The reason you would want to use a sanitizer on the gc is when there is a bug in the gc, not in the user's program. There was a recent regression where
We should solve the issue by adding a |
|
Agreed that the point of sanitising the GC is catching GC bugs, so let’s look at that directly. #1392 actually cuts the other way. In #1392 a negative delta wrapped through an unsigned subtraction. With the field narrowed and the update written as it is now (*mFraggedRows += static_cast((spaceEnd - spaceStart) >> IMMIX_LINE_BITS)), that delta reaches the field as 2^N - k whatever the width N. The static_cast is explicit, so no sanitiser looks at it, and the += overflows the field in the same cases at 8 bits as at 16. A truncation check sees the same thing either way. The difference is what’s left afterwards. A wrapped uint8_t lands in or next to the valid range (255, 254, 253...), so the corruption looks like a plausible count. A wrapped uint16_t lands at 65535/65534, which any invariant check flags immediately. That’s the tool I’d reach for: a debug assert that mUsedRows and mFraggedRows stay within IMMIX_USEFUL_LINES. It catches corrupted counts in every debug build, at both block sizes, with no sanitiser build needed, and it works best with the wider type. hxcpp doesn’t run sanitisers in CI (the only toolchain switch is HXCPP_THREAD_SANITIZE, on mac), and narrowing isn’t UB, so it only shows up under clang’s opt-in implicit-conversion checks. Happy to add those asserts here. The compile-time side works the same way. Narrowing diagnostics only fire where the source type is wider than the destination. With a define-dependent row type, the build everyone compiles has the narrowest field, so something like uint8_t rows{ info->mUsedRows } (the same kind of mistake as #1402) compiles cleanly by default and is only rejected in the big-blocks build. With one uint16_t it’s rejected in every build. Agreed on CI: a HXCPP_GC_BIG_BLOCKS build belongs in the tests, and I’m happy to add it. It’s needed right now, since #1413 stopped big blocks from compiling at all (IMMIX_BLOCK_SIZE and IMMIX_USEFUL_LINES narrow in their brace initialisers). But a single type isn’t adapting the regular build to the gap. It’s the one type that’s correct in both configurations, so both builds exercise the same code. The cost of uint16_t is 8 bytes per block: sizeof(BlockDataInfo) goes from 1568 to 1576 on x64, about 0.02% of a 32 KB block, and there’s no runtime difference. |
|
The concern for me is not about memory size or efficiency, it is about being accurate with the type used for what values are actually allowed to be represented. The point of #1402 was to be more accurate about this, but this seems to be a step backwards because we are adding back a large chunk of invalid state space.
A large size_t over the signed 32 bit integer limit would also overflow a positive |
getStats(), countRows() and reclaim() add each block's row counts into the heap stats that size working memory. In debug builds, trap there if mUsedRows or mFraggedRows is above the block's useful lines, so a corrupted count is caught before it skews the stats.
Nothing in CI builds big blocks, so a change that breaks it goes unnoticed. This is a debug build, so the block row count checks run as well.
With HXCPP_GC_BIG_BLOCKS, IMMIX_BLOCK_SIZE is 65536 and IMMIX_USEFUL_LINES is 508, too big for the uint16_t and uint8_t they were sized as, so their brace initialisers fail to compile. Default builds compute the same values: IMMIX_USEFUL_LINES still promotes to int, and every use of IMMIX_BLOCK_SIZE is size_t or non-negative arithmetic.
|
Alright, well neither type matches the valid range. Default blocks allow 0-254, so uint8_t still allows 255. Big blocks allow 0-508, so with either approach the field is a uint16_t with 65,027 invalid values. The real invariant is “at most IMMIX_USEFUL_LINES”, so I’ve added a check for that. In debug builds, getStats(), countRows() and reclaim() now trap if either count is above IMMIX_USEFUL_LINES. Every block goes through one of them on each collection, so the check doesn’t rely on covering each write path, including future ones. It also catches cases a sanitiser can’t: 255 fits in a uint8_t, and both fragged-row updates are explicit static_casts, which sanitisers skip. I’ve also added the big-blocks build to CI as you suggested. It’s a debug build of test/haxe, which includes the GC tests, so the checks run on every PR and can’t go stale. That build needed IMMIX_BLOCK_SIZE and IMMIX_USEFUL_LINES widened (65536 and 508 with big blocks), since #1413’s brace initialisers don’t compile there, so I merged master and fixed those too.. |
|
tobil4sk is right that the benefit is being able to tell what an acceptable range for variables are, I have zero concerns about the size / memory / performance difference for uint16 vs uint8. I knew that the constexpr change broke more stuff but I was hoping we would quickly fix it as part of this PR (probably shouldn't have pushed to master anyway). |
|
Okay, well, the #1413 fix is already in this PR, and CI passes. The only failure is Windows (64bit) (latest) / cffi(which also fails on master). The static_asserts and debug check cover the actual limit. It’s ready to merge as is. If you’d rather go with your own fix, that’s your call. |
#1402 changed
mUsedRowsandmFraggedRowsinBlockDataInfofrominttouint8_t. That's enough for the default 32k blocks, which have 254 usable lines, butHXCPP_GC_BIG_BLOCKSuses 64k blocks with 508 usable lines, so the counts wrap once a block has more than 255 lines in use.The worst effect is that the GC badly underestimates how much memory is in use after a collection. The working-memory target it sets stays far too small, so it ends up collecting almost every time it needs a new block, and a big-blocks build with a reasonable amount of live data spends most of its time in the GC. The wrap also means
countRows()never sees a full block as full, and a block with exactly 256 used lines looks empty.This changes both fields back to
uint16_t, along with the allocator'smFraggedRowspointer and the casts into it. It also addsstatic_asserts so the counts can't end up smaller thanIMMIX_USEFUL_LINESagain. The byte counts are fine asuint16_t(508 x 128 = 65024).See Repro:
Build with
-D HXCPP_GC_BIG_BLOCKSand compare against a build without it. With the define, the churn loop is dramatically slower.Note: on current master you may also hit the marking crash fixed by #1407 while testing this.