Repository navigation
hash/maphash: add a TinyGo version of the package - #5707
davecheney wants to merge 2 commits into
Conversation
c0da8c7 to
4bb0020
Compare
|
Thank you @davecheney for working on this. The following is edited from an automated review.
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
|
|
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 |
|
For the record, I don't think anybody would object to making |
|
For stability it's probably not good to do that? |
|
@jakebailey I don't think |
|
I mean stability in the build, such that two compiles that hash NaN through interp don't produce different binaries. |
|
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? |
|
Oh right, forgot about |
| 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) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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|
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.
Since wasm is 32 bit this also covers the 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. |
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).
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).
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
c11989c to
42f18b9
Compare
On Go 1.27, TinyGo cannot compile any program that imports
hash/maphash, directly or through a dependency:What was wrong
TinyGo always sets the
puregobuild tag (compileopts/config.go:114). Up to Go 1.26 that silently selectedmaphash_purego.go, which avoidsinternal/runtime/maps. Go 1.27 deleted that file, somaphash.gonow importsinternal/runtime/mapsfor every build, and that package needsabi.MapType,abi.MapGroupSlotsandabi.MapGroupSlotsBits. TinyGo'sinternal/abicannot provide them, becauseabi.Typeis deliberately an empty struct: TinyGo represents types in its own way.How it was fixed
A TinyGo
src/hash/maphashoverride, as is already done foruniqueandreflect. It keeps the Go 1.27 API and documentation, and reroutes the three places that reach into runtime internals:runtime.memhash, withmaps.Use64BitHashruntime.memhash, with a localuse64BitHashruntime.randcomparableHashabi.TypeOfandabi.MapType.Hasherruntime.comparablehash, which wrapshashmapInterfaceHashhashmapInterfaceHashis the hasher that TinyGo maps already use for interface keys, so this adds no new hash code to the binary. The change also adds thepathsToOverrideentry, a trimmed copy of the upstream tests, andhash/maphashinTEST_PACKAGES_FAST.Two things worth knowing
internal/abi.EscapeNonStringis a stub that callspanic("intrinsic")and has no compiler implementation, and Go 1.26+maphash.Comparablecalls it.Comparablethus 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.result *= uint32(seed), so a seed of0makes the hash0, andint(0)andstruct{}{}then collide. The two seed halves incomparableHashare made odd. The upstream test file thus needs no change to its logic.Known deviation
TinyGo gives
NaNthe same hash every time. This is deliberate, seehashmapFloat64Hashinsrc/runtime/hashmap.go. The upstream cases that require twoNaNvalues to hash differently are thus removed, and the test header says so. To change this would change how TinyGo maps behave.Verified
tinygo runon the reproducertinygo test hash/maphash,amd64tinygo test hash/maphash,GOARCH=386use64BitHash == falsebranch-target=wasm,-target=wasip1-target=microbithash,hash/adler32,hash/crc64,hash/fnvgo test ./compileopts ./goenvgofmtThe
wasmtests did not run, becausenode,wasmtimeandwasmerare not installed here.386thus 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.