Skip to content

hash/maphash: add a TinyGo version of the package - #5707

Draft
davecheney wants to merge 2 commits into
tinygo-org:devfrom
davecheney:maphash-go127
Draft

davecheney wants to merge 2 commits into
tinygo-org:devfrom
davecheney:maphash-go127

Conversation

@davecheney

@davecheney davecheney commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

On Go 1.27, TinyGo cannot compile any program that imports hash/maphash, directly or through a dependency:

$ tinygo run maphash.go
# internal/runtime/maps
/usr/lib/go/src/internal/runtime/maps/group.go:298:39: undefined: abi.MapType
/usr/lib/go/src/internal/runtime/maps/map.go:290:17: undefined: abi.MapGroupSlots
/usr/lib/go/src/internal/runtime/maps/table.go:1032:32: undefined: abi.MapGroupSlotsBits
...

What was wrong

TinyGo always sets the purego build tag (compileopts/config.go:114). Up to Go 1.26 that silently selected maphash_purego.go, which avoids internal/runtime/maps. Go 1.27 deleted that file, so maphash.go now imports internal/runtime/maps for every build, and that package needs abi.MapType, abi.MapGroupSlots and abi.MapGroupSlotsBits. TinyGo's internal/abi cannot provide them, because abi.Type is deliberately an empty struct: TinyGo represents types in its own way.

How it was fixed

A TinyGo src/hash/maphash override, as is already done for unique and reflect. It keeps the Go 1.27 API and documentation, and reroutes the three places that reach into runtime internals:

upstream Go 1.27 this override
byte hash runtime.memhash, with maps.Use64BitHash runtime.memhash, with a local use64BitHash
seed runtime.rand unchanged
comparableHash abi.TypeOf and abi.MapType.Hasher a new runtime.comparablehash, which wraps hashmapInterfaceHash

hashmapInterfaceHash is the hasher that TinyGo maps already use for interface keys, so this adds no new hash code to the binary. The change also adds the pathsToOverride entry, a trimmed copy of the upstream tests, and hash/maphash in TEST_PACKAGES_FAST.

Two things worth knowing

  1. This also corrects a second defect. internal/abi.EscapeNonString is a stub that calls panic("intrinsic") and has no compiler implementation, and Go 1.26+ maphash.Comparable calls it. Comparable thus panicked on TinyGo also on Go 1.26, where the package did compile. The override does not call it, because TinyGo does not move stacks.
  2. A test failure showed a seed defect. The TinyGo FNV hash seeds with result *= uint32(seed), so a seed of 0 makes the hash 0, and int(0) and struct{}{} then collide. The two seed halves in comparableHash are made odd. The upstream test file thus needs no change to its logic.

Known deviation

TinyGo gives NaN the same hash every time. This is deliberate, see hashmapFloat64Hash in src/runtime/hashmap.go. The upstream cases that require two NaN values to hash differently are thus removed, and the test header says so. To change this would change how TinyGo maps behave.

Verified

check result
tinygo run on the reproducer works
tinygo test hash/maphash, amd64 pass
tinygo test hash/maphash, GOARCH=386 pass, this is the 32-bit use64BitHash == false branch
-target=wasm, -target=wasip1 compile
-target=microbit links, 49 kB flash
hash, hash/adler32, hash/crc64, hash/fnv pass, through the new merge directory
go test ./compileopts ./goenv pass
gofmt clean

The wasm tests did not run, because node, wasmtime and wasmer are not installed here. 386 thus takes the place of the 32-bit run time path.

This is item 1 of the eight gaps listed in #5684. The other seven are not touched here.

@davecheney
davecheney force-pushed the maphash-go127 branch 2 times, most recently from c0da8c7 to 4bb0020 Compare September 19, 2026 02:51
@deadprogram

Copy link
Copy Markdown
Member

Thank you @davecheney for working on this. The following is edited from an automated review.

  1. src/hash/maphash/maphash.go:360 — comparableHash calls runtime.comparablehash, which is hashmapInterfaceHash (src/runtime/hashmap.go:782). That function returns a plain 0 and ignores the seed for a nil interface (line 785), a struct with no fields, and a zero-length array. The | 1 on the seed halves does not help, because those paths never read the seed. So Comparable(seed, struct{}{}) gives 0 for every seed. writeComparable then assigns that result straight to the state, so WriteComparable(&h, struct{}{}) sets h.state.s to 0 and every later write on that Hash uses seed 0:
var h1, h2 maphash.Hash
h1.SetSeed(s1) // two different seeds
h2.SetSeed(s2)
maphash.WriteComparable(&h1, struct{}{})
maphash.WriteComparable(&h2, struct{}{})
h1.WriteString("abc")
h2.WriteString("abc")
// h1.Sum64() == h2.Sum64(), although the seeds differ

TestComparable and TestWriteComparable cannot catch this, because they only compare a value against itself, so both sides change in the same way. One possible repair is to mix the seed on the zero-field paths in runtime.comparablehash. This changes map iteration order for those key types only, not map behavior.

  1. src/hash/maphash/maphash.go, rthash has the same zero-seed defect. The 32-bit branch calls runtime_memhash with uintptr(uint32(seed)) and uintptr(seed>>32). hash32 starts with result *= uint32(seed) (src/runtime/memhash_fnv.go:14), so a zero half gives 0 for all data, and the next state half comes from that same half, which keeps it at 0. The 64-bit branch has the same property with hash64. The probability is low, but the fix is the same one that comparableHash already uses.

  2. src/hash/maphash/maphash.go, comparableHash the value goes into any, so TinyGo boxes it. Upstream passes abi.NoEscape(unsafe.Pointer(&v)) and does not allocate. The runtime side goes through reflectlite, so escape analysis cannot remove the allocation. This gives Comparable and WriteComparable one heap allocation per call on microcontrollers. TinyGo maps do not pay this, because they use hashmapInterfacePtrHash with a pointer (src/runtime/hashmap.go:840). Is a pointer-based runtime entry possible here?

@jakebailey

Copy link
Copy Markdown
Member

I think you're still working, but point 3 still applies at the moment. (I think point 2 was wrong.)

@davecheney

Copy link
Copy Markdown
Contributor Author

I think you're still working, but point 3 still applies at the moment. (I think point 2 was wrong.)

yup, just looking at this now. give me a second to compose a reasonable reply

@davecheney
davecheney marked this pull request as draft September 23, 2026 05:16
@dgryski

dgryski commented Sep 23, 2026

Copy link
Copy Markdown
Member

For the record, I don't think anybody would object to making hash(NaN) give a random value.

@jakebailey

Copy link
Copy Markdown
Member

For stability it's probably not good to do that?

@dgryski

dgryski commented Sep 25, 2026

Copy link
Copy Markdown
Member

@jakebailey I don't think maphash has the same stability guarantees that they applied to the stream of values from rand. For example, golang/go#74285

@jakebailey

Copy link
Copy Markdown
Member

I mean stability in the build, such that two compiles that hash NaN through interp don't produce different binaries.

@jakebailey

Copy link
Copy Markdown
Member

At least, that's what was on my mind when I replied that, but now I'm looking at which PR this is and I don't think it's a problem?

@dgryski

dgryski commented Sep 25, 2026

Copy link
Copy Markdown
Member

Oh right, forgot about interp. And we can't solve that with #5722 because we want a unique hash seed per process.

Comment thread src/runtime/algorithm.go
func comparablehash(v any, seed uintptr) uint32 {
// hashmapInterfaceHash ignores the seed for a value with no bytes, such as
// an empty struct, so mix in the hash of the empty byte sequence.
return hashmapInterfaceHash(v, seed) ^ hash32(nil, 0, seed)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to cancel the seed for empty strings. I reproduced both Comparable(seed, "") == 0 and WriteComparable(&h, "") resetting the accumulated state.

s1, s2 := maphash.MakeSeed(), maphash.MakeSeed()
println(maphash.Comparable(s1, ""), maphash.Comparable(s2, "")) // 0 0

}
lo := runtime_memhash(unsafe.Pointer(&buf[0]), uintptr(uint32(seed)), uintptr(len))
hi := runtime_memhash(unsafe.Pointer(&buf[0]), uintptr(seed>>32), uintptr(len))
return uint64(hi)<<32 | uint64(lo)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On AVR, uintptr is 16 bits. The generated IR truncates both hashes before combining them, leaving bits 16–31 and 48–63 always zero.

}

// WriteComparable adds x to the data hashed by h.
func WriteComparable[T comparable](h *Hash, x T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this needs h.initSeed() before updating the state. Otherwise, later initialization discards the input. I reproduced this here and in Go 1.27, and I’m going to file an upstream bug.

var h, empty maphash.Hash
maphash.WriteComparable(&h, 123)
empty.SetSeed(h.Seed())
println(h.Sum64() == empty.Sum64()) // true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@neomantra

Copy link
Copy Markdown
Contributor

I came upon this PR when bumping up against the underlying issue when pinning NTCharts3D to Go 1.27. I noted that you didn't have WASM coverage, which is what I was doing, so I exercised it:


I ran the wasm checks that the PR description says could not be run locally. Everything passed.

Setup: this branch rebased onto dev at the 0.43.0 release commit (no conflicts), built on macOS arm64 against LLVM 22.1.8, Go 1.27.2 and Go 1.27.1, node 26, wasmtime 49.

Check Result
tinygo test -target=wasm hash/maphash pass, 16 tests
tinygo test -target=wasip1 hash/maphash pass
tinygo test -target=wasm -gc=boehm hash/maphash pass
GOTOOLCHAIN=go1.27.1 tinygo test -target=wasm hash/maphash pass
tinygo test hash/maphash on darwin/arm64 pass
tinygo run of a small maphash program on wasm and wasip1 correct output

Since wasm is 32 bit this also covers the use64BitHash == false path at run time, not only on 386.

As a baseline, plain 0.43.0 with Go 1.27 fails on the same program with the undefined: abi.MapType errors from the description.

Real world check: I built the ntcharts3d gallery (a charmbracelet/ultraviolet terminal app) for wasm with this branch, -gc=boehm -opt=z, under Go 1.27.1. Ultraviolet hashes every rendered line through maphash.Hash. The app loaded in headless Chrome with WebGPU, rendered every chart, and handled chart and projection switching over about 35 seconds with no console errors.

0pcom added a commit to 0magnet/tinygo that referenced this pull request Oct 10, 2026
0pcom added a commit to 0magnet/desk that referenced this pull request Oct 11, 2026
TinyGo 0.42 could not build against Go 1.27, which is why Go was pinned
to 1.26. The fork's toolchain release can, including hash/maphash
(tinygo-org/tinygo#5707).
0pcom added a commit to 0magnet/pisano that referenced this pull request Oct 11, 2026
TinyGo 0.42 could not build against Go 1.27, which is why Go was pinned
to 1.26. The fork's toolchain release can, including hash/maphash
(tinygo-org/tinygo#5707).
0pcom added a commit to 0magnet/xterm-go that referenced this pull request Oct 11, 2026
TinyGo 0.42 could not build against Go 1.27, which is why Go was pinned
to 1.26. The fork's toolchain release can, including hash/maphash
(tinygo-org/tinygo#5707).
## What was wrong

TinyGo always sets the `purego` build tag (`compileopts/config.go:114`). Up to Go 1.26 that silently selected `maphash_purego.go`, which avoids `internal/runtime/maps`. Go 1.27 deleted that file, so `maphash.go` now imports `internal/runtime/maps` for every build, and that package needs `abi.MapType`, `abi.MapGroupSlots` and `abi.MapGroupSlotsBits`, which TinyGo's `internal/abi` cannot provide.

## How it was fixed

A TinyGo `src/hash/maphash` override, as is already done for `unique`. It keeps the Go 1.27 API and documentation, and reroutes three things:

- `rthash` to the existing `runtime.memhash`
- the seed to `runtime.rand`
- `comparableHash` to a new five line `runtime.comparablehash`, which wraps `hashmapInterfaceHash`, the hasher that TinyGo maps already use for interface keys

This change also adds the `pathsToOverride` entry, a trimmed copy of the upstream tests, and `hash/maphash` in `TEST_PACKAGES_FAST`.

## Two things worth knowing

1. This also corrects a second defect. `internal/abi.EscapeNonString` is a stub that calls `panic("intrinsic")` and has no compiler implementation, and Go 1.26+ `maphash.Comparable` calls it. `Comparable` thus panicked on TinyGo also on Go 1.26, where the package did compile. The override does not call it, because TinyGo does not move stacks.
2. A test failure showed that the TinyGo FNV hash seeds with `result *= uint32(seed)`, so a seed of `0` makes the hash `0`. `int(0)` and `struct{}{}` then collide. The two seed halves in `comparableHash` are made odd, instead of a change to the test, so the upstream test file needs no change to its logic.

## Known deviation

TinyGo gives `NaN` the same hash every time. This is deliberate, see `hashmapFloat64Hash` in `src/runtime/hashmap.go`. The upstream cases that require two `NaN` values to hash differently are thus removed, and the test header says so. To change this would change how TinyGo maps behave.

## Verified

- `tinygo run` works on the reproducer
- `tinygo test hash/maphash` passes on `amd64` and on `GOARCH=386`, which is the 32-bit `use64BitHash == false` branch
- `-target=wasm` and `-target=wasip1` compile, and `-target=microbit` links at 49 kB flash
- `hash`, `hash/adler32`, `hash/crc64` and `hash/fnv` still pass through the new merge directory
- `go test ./compileopts ./goenv` pass, and `gofmt` is clean
- the `wasm` tests did not run because `node`, `wasmtime` and `wasmer` are not installed, so `386` takes the place of the 32-bit run time path

Updates tinygo-org#5684
hashmapInterfaceHash has no bytes to give to hash32 for a nil interface, an
empty struct, a zero length array, or any nesting of those. It returned 0 and
did not read the seed, so hash/maphash gave one result for all seeds.

Comparable(seed, struct{}{}) was 0 for every seed. WriteComparable was worse,
because it puts the result directly into h.state.s. A Hash that got an empty
struct continued with seed 0. Two Hashes with different seeds then agreed on
all data written after that point.

comparablehash now mixes in the hash of the empty byte sequence, which all
three hash32 implementations calculate from the seed. The mix is in
comparablehash and not in hashmapInterfaceHash, so the hash of a map key does
not change and map iteration order stays the same.

The new test file has no upstream equivalent. The upstream tests cannot find
this defect, because they compare a value only with itself, which moves both
sides of the comparison in the same way.

Verified:
- tinygo test hash/maphash passes on darwin/arm64 with the default FNV hash,
  and also with runtime_memhash_tsip and runtime_memhash_leveldb
- the new tests fail if the change to algorithm.go is removed
- testdata/map.go gives the same output as testdata/map.txt
- GOOS=linux GOARCH=386 builds, which is the 32 bit path
- gofmt is clean

Updates tinygo-org#5684
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.

5 participants