Skip to content

addition: add (u/i)128 primitive types - #28877

Open
HuntedByTheIRS wants to merge 20 commits into
vlang:masterfrom
HuntedByTheIRS:master
Open

HuntedByTheIRS wants to merge 20 commits into
vlang:masterfrom
HuntedByTheIRS:master

Conversation

@HuntedByTheIRS

Copy link
Copy Markdown
Contributor

int128: adding primitives

What this adds

u128 and i128 as builtin types in the fork, usable everywhere a u64 is: 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 program
compiles on gcc, clang, tcc and MSVC. Where __SIZEOF_INT128__ is set the helpers use the
native type for speed; everywhere else a value is a {u64 lo; u64 hi;} struct behind the
same typedef, 16-byte aligned. V_INT128_PORTABLE (or -d v3_no_native_int128) forces the
portable 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

  • u128 and i128 accepted as builtin type names and added to the type table.
  • Rows i128 16 and u128 17 in the numeric promotion ladder, so u128 wins its mix with
    every narrower integer (checker_tail_stmt.v is the live table; the copy in
    checker_tail.v is dead code and was left alone).
  • >>> on a 128-bit operand keeps a 128-bit result type.
  • Literal range checking compares digit strings, because strconv stops at 64 bits; a
    literal wider than u128 is an error rather than a silently truncated value.
  • A mixed-width arithmetic expression is widened to its 128-bit side in a post-check pass.
    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.v plus two checked-in headers (int128_helpers.h, int128_string.h).

  • Arithmetic, bitwise, shifts, comparisons and both divisions lower to helper calls.
  • Division and modulo check for a zero divisor on every path, including compound
    assignment, so they panic like the narrower widths instead of dividing by zero.
  • Wide literals are split into 32-bit limbs in the compiler and reconstructed with
    __v_u128_make. Their digits never reach the C compiler, which would keep the low 64
    bits without a warning.
  • A 128-bit const is rendered through the expression generator. A C cast plus << neither
    compiles portably nor keeps the value on the native path.
  • Statements assemble their own C and each needed its own branch: ++ / -- (a plain
    assignment for a bare identifier, since a block that declares a temporary is illegal in a
    C for post 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 like string.u64().
  • Format specifiers apply to wide values: '${wide:08x}', :b, :o, :c. Widths pad the
    printed text; the rules for what a specifier accepts match u64 exactly, including the
    ones u64 also rejects.
  • Maps and arrays of 128-bit values print their values rather than <map value>.
  • typeof and a method receiver on a mixed-width expression name the wider operand:
    typeof(x + u64(1)) is u128, and (x + u64(1)).str() keeps all of its digits.
  • markused enqueues the new methods so their bodies are emitted when they are referenced.

Docs

doc/docs.md gains a ### 128-bit integers section, plus i128 and u128 in the type
table: 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:

file covers
arithmetic_test.v 12 tests: operators, shifts, comparisons, wrapping, division
literal_test.v wide literals keep their exact value, out-of-range is an error
mixed_width_test.v u64 mixed with u128 on both sides
method_test.v methods and aliases on the new types
struct_test.v 128-bit fields, arrays, generics
str_test.v decimal text, interpolation
typeof_test.v typeof and .str() on mixed-width expressions
surface_test.v text parsing and the hex/bin/char surface
surface_format_test.v format specifiers and map printing
panic_test.v division by zero exits non-zero, via a compiled throwaway program

panic_test.v writes a program, compiles it with @VEXE and requires a non-zero exit,
because v test has no way to assert that something must panic.

Verification

./v -nocache -cc gcc test vlib/v/tests/int128/                # 10 / 10
./v -nocache -cc tcc test vlib/v/tests/int128/                # 10 / 10
./v -nocache -cc clang test vlib/v/tests/int128/              # 10 / 10
./v -nocache -cc gcc -d v3_no_native_int128 test vlib/v/tests/int128/   # 10 / 10
./v vlib/v/compiler_errors_test.v                             # 10 failed / 43 passed / 53 total

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

  • json and json2 have no encoder for either type.
  • A bare literal beyond int is still an error in a file. The REPL types its own literals,
    because it compiles a program it wrote itself.
  • A wide literal inside an array or map literal needs an explicit u128(...); the expected
    element type does not reach the literal.
  • vlib/v/slow_tests/repl/repl_test.v fails on pre-existing stale expectations (several
    cases expect the old error output without the REPL's Compiler output from the default V compiler: header). It fails identically with the pre-change vrepl.v, so it is not a
    usable 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.v and a REPL corpus case: the REPL gives an
    integer literal beyond int the type it needs instead of refusing it or wrapping it.
  • 1914701fda — a .gitignore entry for the .v3cc build folder a self-compile leaves
    behind.

Commits

6a9d0e9f31 types, parser: accept u128 and i128 as builtin type names
9e899e42fb types: give a 128-bit operand of >>> a 128-bit result type
9a83959ece cgen: lower u128 and i128 operations to 128-bit helper calls
17b85b85c2 docs: describe the 128-bit integer primitives
92e35c7b0a cgen: stop emitting a compound assignment target twice
47815bcc84 builtin, transform: print 128-bit values as decimal text
211f93a6fa docs: note that 128-bit values print
3744801c88 docs: fix the formatting of the 128-bit example
c31111233d cgen, types: keep the exact value of a 128-bit literal
390eb18d82 markused, tests: keep the str method of a 128-bit value
87af24405f types, cgen, transform: fix five defects found in review
b236fbe20d types, transform: type a mixed-width expression from its 128-bit side
49e9c3b663 cgen, types, transform, builtin: lower the remaining 128-bit statements
27431d85fe types: format the mixed-width type helpers
f7c5ace945 builtin, transform, cgen, markused: format and print 128-bit values
6289de64b6 transform: name the wider operand in typeof and in a mixed-width .str()
4544694a5b vrepl: give an integer literal beyond int the type it needs
bbaabbc3ca vrepl: cover wide literals with a corpus case
1914701fda gitignore: the C build folder a self compile leaves behind

Files

.gitignore
cmd/tools/vrepl.v
doc/docs.md
vlib/builtin/int.v
vlib/builtin/string.v
vlib/v/gen/c/array.v
vlib/v/gen/c/cleanc.v
vlib/v/gen/c/int128.v
vlib/v/gen/c/int128_helpers.h
vlib/v/gen/c/int128_string.h
vlib/v/gen/c/stmt.v
vlib/v/gen/c/str_intp.v
vlib/v/markused/markused.v
vlib/v/parser/parser.v
vlib/v/slow_tests/repl/wide_integer_literals.repl
vlib/v/tests/int128/arithmetic_test.v
vlib/v/tests/int128/literal_test.v
vlib/v/tests/int128/method_test.v
vlib/v/tests/int128/mixed_width_test.v
vlib/v/tests/int128/panic_test.v
vlib/v/tests/int128/str_test.v
vlib/v/tests/int128/struct_test.v
vlib/v/tests/int128/surface_format_test.v
vlib/v/tests/int128/surface_test.v
vlib/v/tests/int128/typeof_test.v
vlib/v/transform/fn.v
vlib/v/transform/transform.v
vlib/v/types/checker.v
vlib/v/types/checker_comptime.v
vlib/v/types/checker_tail_stmt.v
vlib/v/types/type.v
vlib/v/types/universe.v

@HuntedByTheIRS

Copy link
Copy Markdown
Contributor Author

@codex review pls yo 🥺

@diiviocity diiviocity self-assigned this Sep 23, 2026
`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`.
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.
@JalonSolov

Copy link
Copy Markdown
Collaborator

Local AI Findings

High: Mixed i128/u64 expressions truncate valid u64 values to signed i64. In int128.v, signed expressions convert every narrow operand through i64; therefore i128(0) + u64(0xffffffffffffffff) becomes -1, and i128(0) < u64(0xffffffffffffffff) incorrectly evaluates false. Use zero-extension for unsigned operands while widening.

@HuntedByTheIRS

Copy link
Copy Markdown
Contributor Author

Local AI Findings

High: Mixed i128/u64 expressions truncate valid u64 values to signed i64. In int128.v, signed expressions convert every narrow operand through i64; therefore i128(0) + u64(0xffffffffffffffff) becomes -1, and i128(0) < u64(0xffffffffffffffff) incorrectly evaluates false. Use zero-extension for unsigned operands while widening.

thanks m8

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants