`Indexer` and `Int` vs `UInt`

I think Int should be renamed to \Num(ber)?\ , since in Python, int represents an arbitrary-precision integer. Int is just an nicer name than BigInt.

For __getitem__, Int vs UInt is just a compile time thing, if Mojo optimizes negative indexes. However for other APIs there is also a difference in the generated machine code. It is probably fine sacrifice a few cycles for a more consistent API, nevertheless Mojo’s goal is to be the fastest language.

In this thread, I have noticed a desire to express invalid states in the type system, saying this will to more correct code. In my view it is not possible to represent everything statically. If you tried that, you would just pass on the complexity and the code wouldn’t be more correct.

@nate I’m not sure if you meant that somebody should compile all of perspectives on this thread into your template, or each of us should fill out your template individually. I’ll fill out the template with my perspectives to save others some time. This is just a summary of my posts; I’ve omitted a lot of detail for the sake of concision.

The type has to be signed to prevent underflow bugs. For example, len([]) - 1must be -1 and not 18446744073709551615 . It’s too easy to underflow an unsigned type.

Therefore, len should return an Int.

We don’t need to limit ourselves to one choice here. __getitem__ can be overloaded. But it should definitely accept the same type that len returns, i.e. Int. We could also offer overloads for unsigned integers. Potentially, collection traits should require implementers to define a __getitem__ overload for every integer type, and provide default implementations for everything other than Int.

I don’t have strong opinions on whether Mojo’s List should support negative indexing or not. If necessary for performance we can disable it. We just need to ensure that the compiler and/or linters warn programmers when they write x[-1]. (“This won’t do what you’re expecting.”)

I think the only reasonable options are Int (Swift-style), Int32 (Rust-style), or having no default (Zig-style).

If Int is the default, Mojo needs to guarantee a minimum size (e.g. 32 bits), and you should get a compile-time error if the literal is greater than the minimum size. This is the only way to ensure that statements involving literals are portable. We would also need to offer an escape hatch for writing code that it intentionally non-portable.

Int is a poor name because Mojo is aiming to attract developers from both the Python and C++ communities, and Mojo’s definition of Int is different from both of those languages.

More generally, the simplicity of the name Int, combined with the fact that it’s the default type when you write var x = 0, is going to lead to people (especially those coming from Python) choosing it when they don’t want to think about how large their integers are.

Altogether, this means that there is a significant risk the Mojo community will use Int incorrectly, and ultimately, that Mojo apps and libraries will be brittle and prone to overflow when compiled for a 32-bit platform.

The Rust community suffered from this exact problem, and eventually they agreed that Rust’s int/uint types should be renamed to isize/usize.

With this in mind, we can consider reasonable names for Int in Mojo.

Mojo uses the term “len”/“length” where C++ and Rust use the term “size”, so Len is an obvious choice:
var total: Len = len(some_dict) + len(some_list)

The term Len makes sense for indexing too. In the expression x[2], we can say that we are accessing the item at “length 2” (i.e. 2 steps) from the start of the list.

Lengths can be negative (i.e. they have a direction: forwards or backwards), because this type is just a repainting of Int.

When teaching Mojo users about Len, we would emphasise that this type is for “storing lengths, sizes, and positions of values in memory”. If you use this type for anything else, you’re making a mistake.

The term Offset is also okay, but I don’t think it makes as much sense as Len. The name Length would also be fine.

The other main talking point is “For what quantities is it safe/correct to use Int?”

Int has enough bits to refer to any location in the target device’s address space. It is meant to be used to reference memory locations, and count values in memory. If you’re using Int to store a quantity unrelated to the size of the address space (e.g. customer IDs or file sizes), you are using it incorrectly, and are potentially writing code that is unportable and suffers from unpredictable overflow bugs on 32-bit platforms.

1. What type should be returned from len

I think that it should be UInt for most collections. By making it Int, the function’s API declares that the value may be negative, and that means we either would need to include something in len that checks __len__ to ensure it isn’t negative (runtime overhead), leave a doc comment saying that len will never return a negative value and also put one on __len__ saying the same, removing the compiler’s ability to verify the API contract and hoping that all developers who write collections read all of the docs for all of the traits properly, or leave negative numbers as a valid return value from len, which leaves developers to figure out what len(col) == -1 * (2 ** 30) means and forces robust programs to add guards for every call to len. Using Int also has problems for ML accelerators which use arrays of 32-bit cores, where having more than half of the address space filled by a tensor is a strong possibility.

2. What type (or type bounded on a trait) should index operations take

If the current plan to move Int and UInt into SIMD comes to fruition, I think that a SIMD alias restricting it to integral types (ex: alias Integer[dtype] = SIMD[dtype, 1] requires dtype.is_integral() is a good choice for most __getitem__ implementations. Scatter and gather APIs can be left to another discussion. This allows hot-loop code on nominally 64-bit targets which have poor 64-bit operation support (some GPUs) to use better data types without resorting to dividing collections between “hot loop okay” and “cold paths only”. The presence of signed integers there is primarily predicated on the continued desire for negative indexing.

If we remove negative indexing, then UnsafePointer.__getitem__ will need to get overloads for signed integral types.

3. What is the default type when a value it assigned to a literal

From what I remember, many of the complaints about having IntLiteral be that type were related to having to cast everywhere. Once we have requires, it should be possible to create implicit casts with requires bounds that allow IntLiteral to implicitly cast to any “safe” instance of SIMD. Using __merge_with__, a minimal safe dtype can be chosen for use in collections of IntLiteral. This also has the advantage of pushing as much as possible towards smaller dtypes where SIMD operations are more effective, reversing the issue Mojo would otherwise have with many operations on List[Int] being half the speed of operations on std::vector<int> in C++ due to SIMD widths.

For values where the IntLiteral can’t fit into 64 bits, I think there are two options, we can either start to move to 128, 256 or 512 bits, potentially eventually moving to a BigInt or issue a warning that many platforms will see a substantial performance regression manipulating that number, which the cryptographic use-cases those dtypes are intended for can silence if they need very large literals.

To avoid needing comprehensive type inference, I think asking the user to be explicit in cases of ambiguity may be necessary. Users who don’t want the “smallest thing that fits” behavior of IntLiteral can freely pick a type.

4. What is the “correct” name for these types

I think that usize and isize from Rust are perfectly acceptable, with the added benefit that there are plenty of sources already online explaining them and that LLMs will be “familiar” with them. I’m not opposed to Size (UInt) and Offset (Int) either. The reason is that I think Int is often used instead of stopping and think about the valid value ranges for a particular variable or piece of data, and I think that giving the compiler more information is generally good.

5. Other

5.1 ABI concerns

What is the plan for when sizeof[(U)Int]() on the host CPU and on the accelerator disagree? Does Mojo lose the ability to do zero-copy? Do we have to parameterize Int/UInt on the target?

5.2 API Contract Explictness

When functions take a type which is more broad than they are prepared to handle, they have to perform runtime checks to “prove” that the type is narrowed. When the caller is required by the compiler to “prove” that their value is greater than zero by providing a UInt, if the caller has already provided it by using an unsigned type, there isn’t any check required. On the other end, if an API requires a number between 0 and 1000, a UInt8 is automatically okay. If we want to go even further to Ada-style ranges, this provides a powerful mechanism for making programmers able to specify API contracts. The more parts of the contract the compiler can automatically enforce, the fewer bugs relating to API mismatches will exist. This would also all Mojo to centralize many N <= x and x <= M checks into a single type which only performs necessary runtime checks.

More generally, it is very much my preference to make use of Mojo’s advanced type system to push for correctness without runtime performance overheads.

5.3 Helping programmers debug

To me, one of the largest differences between UInt and “Int that should never be negative” is that we have the ability to tell the compiler to check all of the UInt types after every operation for overflow (or check the overflow flag). Yes, this is very, very slow, especially in numerics-heavy code. However, in the same way that we have the ability to serialize CUDA kernels or force async runtimes to run single-threaded, this is a valuable debugging capability

5.4 I am unconvinced that using minimal integer types results in ecosystem fragmentation.

Rust should have the worst possible version of integer-based ecosystem fragementation. It only allows indexing using usize, it generally tries very to infer the types of integers, it has a fallback to Int32 and it requires explicit casts for all integer conversions. All of the data I’ve been able to find says that Rust is less fragmented than Mojo when it had implicit casts based on the frequency of casts. I have hypothesized that Rust may have avoided fragmentation by not having a type called Int, which forced users to think about what they actually wanted to store. I am open to looking at ecosystems seem to have this problem and gathering data from them, but I think that if we are going to entertain a variety of trade-offs in order to avoid something that some people think causes ecosystem fragmentation we should make sure that it actually does cause ecosystem fragmentation.

Hi all,

This discussion has reached the point where it is cycling and not adding any more light to things. I’ll answer a couple more questions, but I think we should probably close this thread soon.

This brings me directly to another issue, which address space is Int defined as?

The default address space, aka Address space zero of the UnsafePointer type. Mojo’s GPU support today supports targets with different size address spaces (GPUs) and this is proven.

My answer is “yes” I’m fine closing that door, LLVM doesn’t support any 8 or 16 bit processors as far as I know, and it certainly wouldn’t be worth making the experience for 32- and 64-bit targets worse because of 8 or 16-bit targets.

That said, I don’t see how this discussion relates to this question at all. Regardless of what we decide, Mojo could (theoretically) support 8 and 16-bit targets: Int would be 8- or 16-bits, and everything would work fine.

Furthermore, such targets aren’t going to be programmed with standard library types like List anyway. If they are that constrained, they are going to have a ton of other considerations to worry about, eliminating most of the utility of generic application-level code.

-Chris

I think there are several advantages to using a scalar argument for __geitem__ as Go did.
Smaller bit-widths and unsigned integers can often generate smaller and more efficient code.
For example division can be efficiently optimized to shifts and omitting the REX-Prefix saves 1 extra byte which can reduce pressure on the instruction cache.

– Highly opinionated post –
Just wanted to say that I’m working through all of the implicit UIntInt casts in my fft code. The code now looks way too ugly and bloated, and it becomes a deterrent to using UInt. I still disagree with having all the LayoutTensor APIs only use Int, they should use Some[Indexer] or Scalar[dtype] where dtype.is_integral(). It adds too much visual noise in an already complicated codebase, and I do not agree with allowing users to just pass a potentially negative value or pretend that everyone reads documentation and will obey the “non negative rule” for the APIs that don’t have neg indexing.

IMO we should keep implicit conversions that are virtually harmless except in edge cases, and add some setting to turn assertions for those cases on or off. Being able to make a program segfault instead of giving some sort of way to deal with the errors (when passing negative numbers in places where it creates UB), should absolutely be dealt with. I hope that this will be revisited when we have parametric raises and can enable more or less paranoid versions of different classes of errors (division by zero, OOM, neg Int where not expected (should just use Scalar[dtype] where dtype.is_unsigned() IMO))

Having just done a similar update across two different projects, I really don’t like having to convert from UInt to Int in many nonsensical places. One example is the count argument to memcpy taking an Int. Why would that ever be negative? My own datastructure tracks the capacity as a UInt, because the capacity will never be negative, and I have to convert it to an Int to use memcpy. Nightly by sstadick · Pull Request #38 · ExtraMojo/ExtraMojo · GitHub

It’s an anecdote, but I ran into this all over the place with the removal of implicit conversions.

I’m FOR explicit conversions only, so this is more of a complaint about usage of Int in Mojo stdlib APIs that I don’t see a reason for. I was able to ignore that with implicit conversions, but now that I have to add Int all over the place it’s impossible to sidestep.

Any of the alternative generic types @martinvuyk mentions would also feel more correct.

I agree that arguably memcpy should likely take Some[Indexer] or even Scalar[dtype] (once SIMD unification happens).

This is an interesting and highly discussed topic in programming langue design. An unsigned integral type is not necessarily the best type to use for capacities/sizes. Just because an unsigned type cannot be <0 does not mean that unsigned integrals types should be used to express the invariant that a value should never be negative. Unsigned arithmetic often poses issues and leads to its own slew of bugs.

For example, given a container with size 0, what should happen if you say memcpy(dest, src, len(container) - 1)? What is worse here, having the count be -1 or having the count be UInt.MAX :person_shrugging:. The benefit of signed-ness here is you can add an assert that the value is not negative and help users catch these bugs. In the case of unsigned there is not a simple assert statement you can add. Is UInt.MAX an invalid value? And given len(container) - N where len == 0, is UInt.MAX - N and invalid value? If so - then using unsigned does not help enforce these invariants at the type level.
If you really want to express an invariant over some piece of data - that’s when you should use strong typing - NonZero[T] or NonNegative[T] for example.

While I usually don’t view style-guides as the “end-all-be-all” - there have been discussion on this for a long time. Google’s C++ guidelines say Do not use an unsigned type merely to assert that a variable is non-negative - and - Because of historical accident, the C++ standard also uses unsigned integers to represent the size of containers - many members of the standards body believe this to be a mistake, but it is effectively impossible to fix at this point.

Point being that unsigned integral values are not necessarily superior to signed ones when expressing sizes/capacities.

Was there ever a proposal for this? Would have been nice to bind this all together in a document. I’m getting a similar feeling to @martinvuyk when having to add UInt/Int casts in a large chunk of my code. I think the arguments for Int are strong, but it would be nice to have a proposal doc instead of a long conversation chain as the source of “why did we do it this way”. Having this convo is great, but an official proposal that summarizes this would be nice and educational.

Thank you for the additional context! This is my first time arguing about unsigned ints!

Isn’t the answer to all the subtractions into overflows to use saturating subtraction? And to make the use of saturating arithmetic idiomatic for these sorts of operations?

My background is fully Rust oriented, which is why signed ints for these sorts of things seem wrong to me. Despite a uint possibly not being the correct type to express being “not-negative” it still confers more information about they type the an int does, and making types as specific as possible to their actual allowable values feels important.

Forcing everyone everywhere to handle the possible negative seems worse having people guard against overflows, which they already should be doing, especially when zero is valid (which I agree it may not be

But, I understand where you are coming from. Now that I understand the reasoning behind it that’s a lot more palatable.

(edit - rereading from the start of this thread, I’m rehashing stuff already covered better by others, but the refresher on why Int isn’t entirely inappropriate was very helpful)

Agreed. This forum post initially started as just a spin off from some discord converstaions but a proper proposal would be a good idea. Internally we are hashing out some of this - so given another week or so we should have better clarity!

The big issue with saturating subtraction is performance. These can often cause pretty bit perf issues especially in sensitive GPU kernels. It would also be a pretty big deviation from other systems languages. As all other systems languages have unsigned types perform wrapping at their boundaries.

FWIW - I also have a majority Rust background! Before Modular I was working professionally in Rust for several years :slight_smile: . Rust has specific helper structs for this - Saturating, Wrapping, etc…
It’s just for the raw primitive types figuring out what the best behavior should be is a bit controversial.

Rust has attempted to alleviate this by panicking on over/underflow in debug builds and silently working in release builds. This has its own issues though as most times these bugs are not encountered until production where they silently work.

Could you guys please consider some sort of tiered approach to this in the future? I’m not asking for a solution right now but to really take the idea into consideration in the future.

Following the spirit of progressive exposure to complexity. We could provide several global settings where we enable/disable certain implicit conversions, function raises, etc. I think it will be very useful for people to progressively make their libraries more thorough in dealing with different layers of complexity in the possible errors. And of course we develop the whole of MAX and the stdlib to the strictest standards (like disabling implicit UIntInt conversions). I don’t know how much of the groundwork can/should be done at this stage, but at least design the small pieces with this possibility in mind.

And I really really don’t want us to break so much away from Python as to remove negative indexing entirely. IMO each type should decide what kind of indexing it allows, and they should receive different kinds of integers for indexing.

Thanks again for opening this kind of discussion up :slight_smile:

I am actually concerned about the loss of address space in particular for WASM.

Some Javascript/WASM engines will allocate 4GB of virtual memory to skip bounds checks, an optimization which is not possible with 64bit WASM.

The only reason to use Memory64 is if you actually need more than 4GB of memory.

Memory64 won’t make your code faster or more “modern”. 64-bit pointers in WebAssembly simply allow you to address more memory, at the cost of slower loads and stores.

Reading through this thread, I was thinking of a comtime signed integer (basically a UInt whose sign is given by a parameter). I think this could solve some of the issues mentioned here. Nonetheless, I would be surprised if other people had not been thinking about this already, so I would appreciate comments as to why this is not a good option.

To keep the current thread focused, I would propose to discuss this here, where I also provide some more details and a reference implementation: Pre-Proposal: a comptime signed int for indexing

While I agree with this for the “speed of the hardware” code in kernels, I can think of many situations where the correctness of the program is worth the performance penalty of turning on overflow/underflow checks. This can also be helpful for debugging kernels as a way to help root out unwanted overflow/underflow.

Perhaps we could make the default controlled by how the user chooses to compile the code (O3/release means no checks, O0/1 and debug symbols on means checks), with an explicit switch to set it of course, and then add ways to do explicit wrapping/saturating/checked operations for code that should never do anything else under any circumstances? I have a lot of Rust code where -1 and 2 ^ 64 - 1 are both equally wrong, but being able to have the compiler assist in detecting underflow means that I don’t have to litter my code with manual checks since I can turn on debug mode to figure out where the problem happened. If it’s all time-of-use checks, then I need to go do a “reverse value flow analysis” to trace where the problem may have happened.

I think the current solution of IntLiteral as a dependent type is a better idea, since it should enable us to safely implicitly cast it to any scalar which can store the value, which means that it can safely implicitly cast to UInt for any positive integer.

I agree that 8-bit and 16-bit processors are largely obsolete at this point, and that it’s not worthwhile to sacrifice anything to support them. 32-bit processors, however, represent the majority of ML accelerators on the planet in the form of Intel’s and Qualcomm’s NPUs, which have the majority market shares for laptops and smartphones. Supporting these would mean that DeviceBuffer and friends would need to support indexing using UInt since I don’t think that >2 GB tensors are out of the question for use in the future, likely via batched input data.

Are we sure that converting the likely size_t-typed *_idx equivalents to Int is free on all GPUs?

Other concerns

FFI

I feel like the FFI problem hasn’t been well addressed. C and C++ code that is desirable to interoperate with, such as MPI implementations, both of the popular libraries for RDMA, userspace device drivers, standard POSIX interfaces and more all want to use size_t (UInt), and I think this will add a lot of friction as Mojo attempts to integrate with existing, hard to replace code. Additionally, I think the suggestion that integer literals become Int is a very bad idea because of this, since I could easily see myself needing to construct an array of offsets for scatter/gather or some other operation and writing code like [UInt(0), UInt(1), UInt(3), UInt(5), UInt(7)].

Type correctness

There has been a recent trend in programming of moving towards using the most specific type possible. Using UInt provides a clear signal to the compiler and callers of a function that it is incorrect for the value to ever go below 0. This can be replaced with a copy/pasted comment about the allowed value range for an Int not including any negative values, but that seems silly to me.

Is ecosystem fragmentation solvable with a rename?

Pointing back to the data I gathered much earlier in the thread, Rust still has a perfectly manageable amount of casts despite not even allowing the safe casts to be implicit, which should cause the worst possible version of ecosystem fragmentation to occur. As someone who is currently primarily a Rust developer, I see no such issue in the ecosystem. Based on some old discussions in the Rust discourse forum, Rust ran into issues with people defaulting to ssize_t when they called it Int and decided to change the name to isize, which people do not immediately default to so they are forced to consider what type the integer is best represented by. I think that forcing people to break the habit of defaulting to int for everything helps fix some of the problems that Swift saw with the problem.

My main concern is that using a default integer with a platform-dependent width will cause many incompatibilities and problems. Code could work correctly on Wasm64 but not Wasm32. Full Wasm64 support is a long way off, and Wasm64 could sacrifice performance. Wasm32 has full support for 64-bit arithmetic. Why should the pointer size dictate the default width for arithmetic? This is especially true since vector registers on 64-bit platforms can be up to 512 bits in size, and I expect future 32-bit architectures to efficiently support 64-bit registers and arithmetic. It’s not as if 128-bit computing is on the horizon, and it’s unclear if it will ever exist.

Thus, I recommend defaulting to Int64.