addition: add (u/i)128 primitive types - #28877
Open
HuntedByTheIRS wants to merge 20 commits into
Open
HuntedByTheIRS wants to merge 20 commits into
HuntedByTheIRS wants to merge 20 commits into
Conversation
Contributor
Author
|
@codex review pls yo 🥺 |
`u128(5)` failed with "unknown function: u128" because the name never reached the type tables. The parser keeps its own list of castable type names, so both lists need the entry: `is_builtin_type` decides whether `u128(x)` parses as a cast at all, and `is_builtin_type_name` / `builtin_type_value` decide what the name means. `prim_name_from` and `prim_c_type` already fall through by size, so a `Primitive` of size 128 reports itself as `i128`/`u128` without further changes, and `size` being a u8 has room for 128. Verified: `./v self` rebuilds, and a probe that failed with "unknown function: u128" before now checks and reaches cgen as `(u128)(5)`. The C type behind that name is the next commit.
The unsigned-shift result table answered u32 for a 128-bit left side, so the shift of an i128 was typed as a 32-bit value and got truncated on the way out. The table now has a 128 entry, which makes the type of >>> on i128 the unsigned counterpart u128, the same rule the narrower widths follow.
The new primitives need a C representation, and not every C compiler has one: gcc and clang carry __int128, while tcc, MSVC and every 32-bit target do not. So each 128-bit operation is emitted as a call into a small helper block rather than as a raw C operator, and that block has two implementations -- the compiler own 128-bit type where it exists, and one built from 64-bit limbs everywhere else. A single helper set means both representations answer the same way, which is what the test file checks by running the same cases under gcc, clang and tcc. Arithmetic, bitwise, shifts, comparisons, casts, compound assignment and the decimal text the assert printer needs all route through those helpers. Division and modulo by zero panic like the other integer widths, a shift count of 128 or more answers 0 (or -1 for an arithmetic shift of a negative value), and overflow wraps. Pass -d v3_no_native_int128 to force the portable representation on a compiler that has the native type.
The type list no longer promises i128 and u128 as coming soon, and a short section covers what the two types do: the operators that work on them, the wrap and shift behaviour, and why the compiler does not need a 128-bit C type. It also names what is still missing -- literals wider than 64 bits, str(), and a promotion ladder row -- so readers do not expect those yet.
A compound assignment on something that is not a plain name, such as a struct field, takes the address of the target and works through it. The dereference of that address was written once when the temporary was created and again when the assignment was written, so the generated C read `*t*t = ...` and only the non-field cases were being exercised. The new struct test covers both shapes: a field target and a plain name.
Stringification had no entry for the two new types, so println and string interpolation handed the raw value to a function expecting a string, which the C compiler rejected. Both types now answer str() the way the narrower integers do, and the stringify table calls it. The digit loop runs on the 128-bit helpers, and the signed version takes its magnitude from the unsigned negation, so the minimum value prints too rather than overflowing on negation.
str() works now, so println and string interpolation show the decimal value, and the list of what is still missing no longer claims otherwise.
The comment alignment in the example block did not match vfmt, which check-md reports as an unformatted example. The block itself already compiled.
A literal wider than 64 bits reached the C compiler as digits, and C keeps the low 64 bits of an oversized constant without a word about it, so u128(31732946804115296442105984367) compiled and printed 4047906774079501679. The digits are now split into halves in the compiler and emitted through __v_u128_make. That helper was missing from the native representation, where only the portable struct had it, so both paths build the value the same way. A literal outside the range of the target type is an error now instead of a wrapped value, and the cast check compares digits for 128-bit targets because strconv stops at 64 bits and reports a different code past it. The comment about tuple access in int128.v records why the helper returns a struct: `.0` and `.1` on a multi-return value compile to C that assigns the whole multi_return struct to a u64.
The stringification table ends in `else {}`, so a 128-bit type never marked
u128.str or i128.str as used. Interpolating the result of a generic call
instantiated with a 128-bit type then emitted a call to a method that was never
defined, which the C compiler rejected:
fn identity[T](v T) T { return v }
x := u128(1) << 100
println("x ${identity(x)}")
The same shape with u64 works, and that is what pointed at this table.
method_test.v covers struct methods with 128-bit fields, methods on aliases of
the new types, and the generic case above.
A review of the 128-bit work found several silent wrong values. Fixed here: - A literal with the top bit set was widened as signed, so `u128(0x8000000000000000)` came out as 2^128 - 2^63. A literal carries no sign of its own, so it widens as unsigned now. A minus prefix keeps the signed path, because `u128(-1)` is meant to wrap to the maximum. - Narrowing casts went through `(int)`, so a target below 64 bits kept values that fit in 32 bits whole: `u8(u128(300))` was 300 instead of 44. The cast names the target type now, and `isize`, `usize`, `rune` and `char` are handled as cast targets instead of falling through to a C cast the struct representation rejects. - The promotion ladder had no 128-bit row, so its index was -1, no widening was offered, and a mixed expression was typed `int`. Both types have a row now. - `/= 0` and `%= 0` divided by zero in the helper instead of panicking, which gave a wrong value on the native path and undefined behaviour in the portable one. - The printer for a println argument was chosen from the narrower operand, so `println(wide + u64(1))` showed only the low bits. mixed_width_test.v asserts every fixed value exactly.
An arithmetic expression that mixes a 128-bit type with a narrower one was recorded with the narrower operand type, so interpolating it printed only the low 64 bits. The transform now prefers the 128-bit operand when it picks a printer, and the checker serves the 128-bit type from the recorded node types. Assigning the same expression to a variable was already correct. Two readers still resolve it narrow during checking: typeof names the narrower type, and calling .str() on the expression picks the narrower printer. Both are written down in doc/docs.md.
The review left four statements that produced C the portable representation cannot express, and the library surface had no text, hex or binary conversions. Statements: - `x++` and `x--` emitted a plain C increment, so a 128-bit local only compiled where the compiler has __int128. They go through the add and sub helpers now, with a plain assignment in an ident case so the post slot of a C for loop stays valid, and the address form where the target has to be evaluated once. - A range loop over 128-bit bounds typed its counter `int`, which printed a wide counter through the 32-bit printer and broke the increment. The transform integer-name list now knows both new names. - A compound assignment to an array element used the raw C operator. - A 128-bit constant reached C as a cast to the struct type, which does not compile and truncated a shift on the native path. Library: - `string.u128()` and `string.i128()` read decimal text, the shape `string.u64()` and `string.i64()` have. - `u128.hex()`, `u128.hex_full()`, `u128.bin()` and `i128.hex()`, four bits at a time so the portable representation needs no 128-bit arithmetic. panic_test.v compiles a program that divides by zero with the compiler under test and requires a non-zero exit, since the test runner cannot express a panic.
A 128-bit value could be interpolated, but the format specifier beside it was accepted and then ignored, and a map with 128-bit values printed `<map value>` for every entry. - The interpolation formatter lowers a specifier in the transform, and its type lists only spoke of 64-bit integers, so a specifier fell through to the plain printer. A 128-bit value now formats through `u128.str_base` for `x`, `X`, `o` and `b`, through the decimal printers for `u` and `d`, and through `char_str` for `c`. Widths pad the printed text; the base arms cast the value to the unsigned bit pattern, which is what the 64-bit arms print for a signed value. - Map printing goes through the typed lowering in the transform, which asks `map_str_type_has_transform_conversion` whether a value can be stringified. `i128` and `u128` were not in that list, so the entry fell to the shared C helper and its `<map value>` default. - `str_base` and `char_str` are new members on the 128-bit types, enqueued in markused like `str` so their bodies exist when code generation reaches for them. - The map C helper keeps a kind of its own for both types. It is only emitted for a program that carries a 128-bit value in an expression type, because the printers it calls do not exist otherwise, and it copies the decimal helpers output out of their shared buffer. `typeof` and a method call on a mixed-width expression still name the narrower operand: the checker bakes that type into the node, so `typeof(x + u64(1))` says `u64` and `(x + u64(1)).str()` prints 6, or fails to compile on the struct representation. Neither the readers in code generation nor the transform hooks that were tried along the way are on that path, and the change belongs in the checker, so both are documented rather than half-fixed.
The checker records an arithmetic expression that mixes a 128-bit type with a narrower one under the narrower operand's type. Both readers of that record live in the transform, so `typeof(x + u64(1))` answered `u64` and `(x + u64(1)).str()` handed a 128-bit value to the 64-bit printer. On the native representation that printed the low bits (6); where a 128-bit value is a struct it did not compile at all. Both now ask a shallow operand question first: an arithmetic node with a 128-bit child is itself 128 bits wide. The question stays shallow on purpose, because `rune(nn).str()` is a narrow value with a 128-bit operand under the cast, and reporting that operand would print the code point instead of the character.
A bare integer literal is typed `int`, so typing a value over the 64-bit range into the REPL either failed to compile or, under `-repl run`, kept its low bits and printed a different number: `9223372036854775808` came back as `-9223372036854775808`, and `18446744073709551616` was a hard `overflows int` error. The REPL compiles a program it wrote itself, so it can name the type the literal needs. Each line now goes through a widening pass on the way in: a decimal literal over `i64` becomes `u64` when it fits and `u128` beyond that, `i128` when a minus precedes it, and the prefixed forms are sized by digit count. A literal that is already the argument of a conversion is left alone, so `u64(...)` keeps its own type, and so are strings, comments, floats and tokens that merely start with a digit. The line as typed is what the REPL shows; the widened form is what it accumulates and runs.
Nine inputs: a value past `i64`, one just over it, a negated one over it, the `u128` maximum, a value that still fits `int`, one already wrapped in `u64(...)`, a hex literal, a float, and a string that only looks numeric. The REPL gives each literal the type it needs, so the expected output is the value itself instead of an overflow error or a wrapped low half. Checked with vlib/v/slow_tests/repl/runner directly: the full corpus aborts earlier on pre-existing expectations of its own, so this case is verified on its own.
A self compile parks its generated C in .<binary>.v3cc.<token> next to the binary and does not always remove it, so a 73 MB folder shows up as untracked after `./v self`.
HuntedByTheIRS
force-pushed
the
master
branch
from
September 23, 2026 19:56
2300cd9 to
499192d
Compare
Upstream is 37 commits ahead, and the move conflicts in one place: the cast_expr branch of the C backend. That branch now reads its argument as g.a.nodes[int(cast_arg_id)] instead of going through g.a.child_node, so the 128-bit cast hook sits directly under the new binding and passes the child id the way its neighbours do.
Collaborator
|
Local AI Findings High: Mixed |
Contributor
Author
thanks m8 |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
int128: adding primitives
What this adds
u128andi128as builtin types in the fork, usable everywhere au64is: literals,arithmetic, shifts, comparisons, constants, statements, string conversion, formatting,
maps, methods and generics.
Nothing in the generated C depends on the C compiler having a wide type. Every operation
becomes a call to a checked-in helper (
__v_u128_*/__v_i128_*), so the same programcompiles on gcc, clang, tcc and MSVC. Where
__SIZEOF_INT128__is set the helpers use thenative type for speed; everywhere else a value is a
{u64 lo; u64 hi;}struct behind thesame typedef, 16-byte aligned.
V_INT128_PORTABLE(or-d v3_no_native_int128) forces theportable representation, which is how the second path is tested.
Branch shape: 19 commits on top of
c9b806b294, 32 files, +2831 / -21, 57 test functions.Type system
u128andi128accepted as builtin type names and added to the type table.i128 16andu128 17in the numeric promotion ladder, sou128wins its mix withevery narrower integer (
checker_tail_stmt.vis the live table; the copy inchecker_tail.vis dead code and was left alone).>>>on a 128-bit operand keeps a 128-bit result type.strconvstops at 64 bits; aliteral wider than
u128is an error rather than a silently truncated value.Widening inside the parallel checker is unreliable, and this pass is what the readers of
a recorded type end up consulting.
Code generation
vlib/v/gen/c/int128.vplus two checked-in headers (int128_helpers.h,int128_string.h).assignment, so they panic like the narrower widths instead of dividing by zero.
__v_u128_make. Their digits never reach the C compiler, which would keep the low 64bits without a warning.
constis rendered through the expression generator. A C cast plus<<neithercompiles portably nor keeps the value on the native path.
++/--(a plainassignment for a bare identifier, since a block that declares a temporary is illegal in a
C
forpost clause), range-loop counters, and compound assignment to an array element.Standard library
u128:str,str_base(base),hex,hex_full,bin,char_str.i128:str,hex,char_str.string.u128(),string.i128()for text to value, zero on garbage likestring.u64().'${wide:08x}',:b,:o,:c. Widths pad theprinted text; the rules for what a specifier accepts match
u64exactly, including theones
u64also rejects.<map value>.typeofand a method receiver on a mixed-width expression name the wider operand:typeof(x + u64(1))isu128, and(x + u64(1)).str()keeps all of its digits.markusedenqueues the new methods so their bodies are emitted when they are referenced.Docs
doc/docs.mdgains a### 128-bit integerssection, plusi128andu128in the typetable: the operators, the two representations, the promotion ladder, wide literals, text
parsing, format specifiers and the map example.
Tests
vlib/v/tests/int128/, 10 files and 57 test functions:arithmetic_test.vliteral_test.vmixed_width_test.vu64mixed withu128on both sidesmethod_test.vstruct_test.vstr_test.vtypeof_test.vtypeofand.str()on mixed-width expressionssurface_test.vsurface_format_test.vpanic_test.vpanic_test.vwrites a program, compiles it with@VEXEand requires a non-zero exit,because
v testhas no way to assert that something must panic.Verification
The last line is the regression floor and it is unchanged from before this work, so compare
those three counts rather than the exit status. The clang leg, the forced-portable leg and a
mingw + wine cross build were run during the work; gcc and tcc were re-run on the final tree.
Limits worth writing down
jsonandjson2have no encoder for either type.intis still an error in a file. The REPL types its own literals,because it compiles a program it wrote itself.
u128(...); the expectedelement type does not reach the literal.
vlib/v/slow_tests/repl/repl_test.vfails on pre-existing stale expectations (severalcases expect the old error output without the REPL's
Compiler output from the default V compiler:header). It fails identically with the pre-changevrepl.v, so it is not ausable gate for this branch.
Not part of the feature
Two of the 19 commits are unrelated and can be dropped or split into their own PR:
4544694a5b,bbaabbc3ca—cmd/tools/vrepl.vand a REPL corpus case: the REPL gives aninteger literal beyond
intthe type it needs instead of refusing it or wrapping it.1914701fda— a.gitignoreentry for the.v3ccbuild folder a self-compile leavesbehind.
Commits
Files