DISCLAIMER: I know that this looks a bit like a bug report, but as far as I can tell it isn’t (yet). Therefore I am posting it here on the forum, because my intention is to first discuss and investigate the problem further, before posting a detailed (hopefully complete) bug report to the
modular/modular
repo. If I should do something differently I am open for your feedback and happy to follow your advise!
While working on Larecs🌲 we found a discrepancy between the behavior of code executed with mojo test
and mojo run
. We identified that a value passed to a constructor (Query.__init__
) had different values right before the constructor was called and inside at the start of the constructor. We observed this behavior only when using mojo test
to execute the code, during mojo run
everything worked as expected!
Code (abbreviated)
full code can be found here
...
def without_bar():
world = World[Foo, Bar, Zork]()
_ = world.add_entities(Foo(0), count=n())
_ = world.add_entities(Foo(0), Bar(0), count=n())
query = world.query[Foo]().without[Bar]()
count = 0
for _ in query:
count += 1
assert_equal(count, n())
def without_zork():
world = World[Foo, Bar, Zork]()
_ = world.add_entities(Foo(0), count=n())
_ = world.add_entities(Foo(0), Zork(0), count=n())
_ = world.add_entities(Foo(0), Bar(0), Zork(0), count=n())
query = world.query[Foo]().without[Zork]()
count = 0
for _ in query:
count += 1
assert_equal(count, n())
for _ in range(n()):
_ = world.add_entity(Foo(0), Zork(0))
count = 0
for _ in query:
count += 1
assert_equal(count, 2 * n())
def execute():
print("Run without_bar()...")
without_bar()
print("Run without_zork()...")
without_zork()
def test_execute():
print(compile.compile_info[execute, emission_kind="llvm-opt"]())
execute()
...
Test execution command
pixi run mojo test -D ASSERT=all -I src/ test/mac_bug_test.mojo
Observed output (without LLVM dump…
)
Run without_bar()...
Query.__iter__ <before constructor call>
+ Mask [1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000]
- Mask [0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000]
_EntityIterator.__init__ <inside constructor call>
+ Mask [1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000]
- Mask [0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000]
Run without_zork()...
Query.__iter__ <before constructor call>
+ Mask [1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000]
- Mask [0010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000]
_EntityIterator.__init__ <inside constructor call>
+ Mask [1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000]
- Mask [0100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000]
Unhandled exception caught during execution: At /Volumes/Programming/tree-d/larecs/test/mac_bug_test.mojo:49:17: AssertionError: `left == right` comparison failed:
left: 20
right: 10
As can be seen, the - Mask
(a bitmask with 256 bits SIMD[UInt8, 32]
) is different before the call to _EntityIterator.__init__
and inside the called constructor.
Code calling _EntityIterator.__init__
# src/larecs/query.mojo:84
struct BitMask:
var value: SIMD[UInt8, 32]
struct Query[has_without_mask: Bool]:
var _mask: BitMask
var _without_mask: StaticOptional[BitMask, has_value=has_without_mask]
...
fn __iter__(self, out iterator: _EntityIterator[...]) raises:
print("Query.__iter__ <before constructor call>")
print("+ Mask " + self._mask.__str__())
@parameter
if Self.has_without_mask:
print("- Mask " + self._without_mask[].__str__())
else:
print("- Mask <None>")
iterator = self._world[].Iterator(
Pointer(to=self._world[]._archetypes),
Pointer(to=self._world[]._locks),
self._mask,
self._without_mask,
)
Start of __EntityIterator.__init__
# src/larecs/query.mojo:491
struct _EntityIterator:z
fn __init__(
out self,
archetypes: Pointer[List[Self.Archetype], archetype_origin],
lock_ptr: Pointer[LockMask, lock_origin],
owned mask: BitMask,
owned without_mask: StaticOptional[BitMask, has_without_mask] = None,
owned start_indices: Self.StartIndices = None,
) raises:
print("_EntityIterator.__init__ <inside constructor call>")
print("+ Mask " + mask.__str__())
@parameter
if has_without_mask:
print("- Mask " + without_mask[].__str__())
else:
print("- Mask <None>")
...
Minimal reproducer
- Unfortunately I could not produce a small isolated reproducer of the bug. All my attempts lead to eventually depending on code from Larecs.
- This is the minimal code I could come up with, that shows the buggy behavior: larecs/test/mac_bug_test.mojo at a898ccee0a15c06fcb80bf9b2ed92f9cd2bdaccf · samufi/larecs · GitHub
- Some learnings from trying to reduce the code:
- There must be two
Query
s, which both have awithout_mask
. - They must be constructed in to different functions (don’t need to be different test cases, just different functions).
- One
Query
needs to be iterated more than once (in this case theQuery
fromwithout_zork
). - The execution order of the two functions
without_zork
andwithout_bar
is not important.without_zork
is always the failing function!
- There must be two
Investigations done so far
- Comparison of bitmask value through print statements
- Execution of
test/mac_bug_test.mojo
with different commands:pixi run mojo test -D ASSERT=all -I src/ test/mac_bug_test.mojo
pixi run mojo test -I src/ test/mac_bug_test.mojo
pixi run mojo run -I src/ test/mac_bug_test.mojo
- Inspection of generated LLVM IR (by using
compile.compile_info()
) - Tested on Linux – everything looked fine there and worked as expected🤷
Observations
Different execution contexts
Command | BitMask mutated | Test Outcome |
---|---|---|
mojo test -D ASSERT=all |
yes | ![]() |
mojo test |
no | ![]() |
mojo run |
no | ![]() |
LLVM IR inspection
I am by no means a compiler engineer or claim in any way to be an expert for LLVM. Out of curiosity and hope, that I’d find a hint for the causes of the previously described problem, I looked a bit into the optimized LLVM IR produced by the compile
module. Maybe this is completely wrong and this IR isn’t at all what the mojo test
command executes, but here are my findings anyway.
My conclusion was, that all the calls of the constructor _EntityIterator.__init__
receive the BitMask
(that is somehow mutated while passed to the constructor) as a literal?!
(The full LLVM IR dump is attached at the end)
; bug.ll:2252
%1056 = call { i1, { ptr, i64 }, { ptr, ptr, i8, i64, i64, i64, { { [0 x { ptr, i64, i64 }] } }, { { [0 x i64] } }, { ptr, <8 x i32>, <32 x i8>, { { [1 x <32 x i8>] } }, i64, i64, i64 } } } @"larecs::query::_EntityIterator::__init__(::Pointer[$0, ::List[larecs::archetype::Archetype[$3, $4], ::Bool(False)], $1, ::AddressSpace(::Int(0))],::Pointer[::Bool(True), larecs::lock::LockMask, $2, ::AddressSpace(::Int(0))],larecs::bitmask::BitMask,larecs::static_optional::StaticOptional[larecs::bitmask::BitMask, $5],larecs::static_optional::StaticOptional[::List[::UInt, ::Bool(True)], $6])_REMOVED_ARG,ComponentTypes=[[typevalue<#kgen.instref<\1B\22mac_bug_test::Foo\22>>, struct<(scalar<ui8>) memoryOnly>], [typevalue<#kgen.instref<\1B\22mac_bug_test::Bar\22>>, struct<(scalar<ui8>) memoryOnly>], [typevalue<#kgen.instref<\1B\22mac_bug_test::Zork\22>>, struct<(scalar<ui8>) memoryOnly>]],has_without_mask=1,has_start_indices=0"(ptr nonnull %46, ptr nonnull %45, <32 x i8> <i8 1, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0>, { { [1 x <32 x i8>] } } { { [1 x <32 x i8>] } { [1 x <32 x i8>] [<32 x i8> <i8 4, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0>] } })
; bug.ll:3474
%1635 = call { i1, { ptr, i64 }, { ptr, ptr, i8, i64, i64, i64, { { [0 x { ptr, i64, i64 }] } }, { { [0 x i64] } }, { ptr, <8 x i32>, <32 x i8>, { { [1 x <32 x i8>] } }, i64, i64, i64 } } } @"larecs::query::_EntityIterator::__init__(::Pointer[$0, ::List[larecs::archetype::Archetype[$3, $4], ::Bool(False)], $1, ::AddressSpace(::Int(0))],::Pointer[::Bool(True), larecs::lock::LockMask, $2, ::AddressSpace(::Int(0))],larecs::bitmask::BitMask,larecs::static_optional::StaticOptional[larecs::bitmask::BitMask, $5],larecs::static_optional::StaticOptional[::List[::UInt, ::Bool(True)], $6])_REMOVED_ARG,ComponentTypes=[[typevalue<#kgen.instref<\1B\22mac_bug_test::Foo\22>>, struct<(scalar<ui8>) memoryOnly>], [typevalue<#kgen.instref<\1B\22mac_bug_test::Bar\22>>, struct<(scalar<ui8>) memoryOnly>], [typevalue<#kgen.instref<\1B\22mac_bug_test::Zork\22>>, struct<(scalar<ui8>) memoryOnly>]],has_without_mask=1,has_start_indices=0"(ptr nonnull %46, ptr nonnull %45, <32 x i8> <i8 1, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0>, { { [1 x <32 x i8>] } } { { [1 x <32 x i8>] } { [1 x <32 x i8>] [<32 x i8> <i8 4, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0>] } })
; bug.ll:17816
%417 = call { i1, { ptr, i64 }, { ptr, ptr, i8, i64, i64, i64, { { [0 x { ptr, i64, i64 }] } }, { { [0 x i64] } }, { ptr, <8 x i32>, <32 x i8>, { { [1 x <32 x i8>] } }, i64, i64, i64 } } } @"larecs::query::_EntityIterator::__init__(::Pointer[$0, ::List[larecs::archetype::Archetype[$3, $4], ::Bool(False)], $1, ::AddressSpace(::Int(0))],::Pointer[::Bool(True), larecs::lock::LockMask, $2, ::AddressSpace(::Int(0))],larecs::bitmask::BitMask,larecs::static_optional::StaticOptional[larecs::bitmask::BitMask, $5],larecs::static_optional::StaticOptional[::List[::UInt, ::Bool(True)], $6])_REMOVED_ARG,ComponentTypes=[[typevalue<#kgen.instref<\1B\22mac_bug_test::Foo\22>>, struct<(scalar<ui8>) memoryOnly>], [typevalue<#kgen.instref<\1B\22mac_bug_test::Bar\22>>, struct<(scalar<ui8>) memoryOnly>], [typevalue<#kgen.instref<\1B\22mac_bug_test::Zork\22>>, struct<(scalar<ui8>) memoryOnly>]],has_without_mask=1,has_start_indices=0"(ptr nonnull %32, ptr nonnull %31, <32 x i8> <i8 1, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0>, { { [1 x <32 x i8>] } } { { [1 x <32 x i8>] } { [1 x <32 x i8>] [<32 x i8> <i8 2, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0>] } })
Conclusions / Hypothesis
mojo test
andmojo run
differ in compilation options (optimization levels, test instrumentation, linking) which could change codegen.- Test-runner instrumentation or different runtime in
test
mode introduces aliasing or unintended writes. - macOS-specific codegen/runtime behavior/ABI issue.
- A bug in the optimizer that is only triggered for the test build configuration on macOS.
Open Questions / Next Steps?
- Do you share our conclusions so far?
- What are the exact differences between
mojo run
andmojo test
in terms of compilation, optimization levels, and runtime instrumentation? Are different optimization strategies used by default? - Does the test runner perform extra transformations or link with different runtime support that could lead to mutation or aliasing differences?
- Are there known macOS-specific codegen/runtime issues that could explain this behavior?
- Would maintainers like me to:
- produce assembly diffs for
test
vsrun
, - run
mojo test
with optimizations disabled (e.g.,-O0
),
- produce assembly diffs for
- Any other recommended immediate steps I should try?
Attachements
Environment
- Hardware: MacBook Air M4 2025
- OS: macOS 15.6.1 (24G90)
- Mojo version: 25.05
Exact steps to reproduce
- Clone or create repo: GitHub - samufi/larecs: Larecs🌲 – a performance-oriented archetype-based ECS
- Check out branch
mac-bug
- Run (test):
mojo test -D ASSERT=all -I src/ test/mac_bug_test.mojo
- Observe behavior
- Optional: Run
mojo run
for comparison:
mojo run -I src/ test/mac_bug_test.mojo