Skip to content

icall: replace map with slice#76

Merged
visualfc merged 3 commits into
goplus:mainfrom
visualfc:icall_slice
May 14, 2026
Merged

icall: replace map with slice#76
visualfc merged 3 commits into
goplus:mainfrom
visualfc:icall_slice

Conversation

@visualfc

Copy link
Copy Markdown
Member

No description provided.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request updates the project to Go 1.21 and refactors the provider struct across multiple packages to use slices instead of maps for managing method information. This change includes the introduction of a usage counter n and the adoption of the clear built-in. Review feedback identifies two significant issues: first, the Remove method lacks idempotency and incorrectly decrements the counter even if a slot is already empty; second, in regabi implementations, the Insert method increments the counter even when no available slot is found (index is -1), which could lead to inconsistent state or panics.

Comment on lines 84 to 87
for _, n := range indexs {
delete(p.used, n)
p.used[n] = nil
p.n--
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The current implementation of Remove decrements the counter p.n for every index in indexs, even if the slot was already empty (nil). This can lead to an incorrect count if duplicate indices are provided or if an index is removed twice. The previous map-based implementation was idempotent because delete on a map is a no-op if the key is missing. Consider checking if the slot is non-nil before decrementing.

	for _, n := range indexs {
		if p.used[n] != nil {
			p.used[n] = nil
			p.n--
		}
	}

Comment thread icall/icall2048/icall.go
Comment on lines 37 to 40
for _, n := range indexs {
delete(p.used, n)
p.used[n] = nil
p.n--
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The current implementation of Remove decrements the counter p.n for every index in indexs, even if the slot was already empty (nil). This can lead to an incorrect count if duplicate indices are provided or if an index is removed twice. The previous map-based implementation was idempotent because delete on a map is a no-op if the key is missing. Consider checking if the slot is non-nil before decrementing.

	for _, n := range indexs {
		if p.used[n] != nil {
			p.used[n] = nil
			p.n--
		}
	}

Comment on lines 84 to 87
for _, n := range indexs {
delete(p.used, n)
p.used[n] = nil
p.n--
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The current implementation of Remove decrements the counter p.n for every index in indexs, even if the slot was already empty (nil). This can lead to an incorrect count if duplicate indices are provided or if an index is removed twice. The previous map-based implementation was idempotent because delete on a map is a no-op if the key is missing. Consider checking if the slot is non-nil before decrementing.

	for _, n := range indexs {
		if p.used[n] != nil {
			p.used[n] = nil
			p.n--
		}
	}

Comment thread icall/icall1024/icall.go
Comment on lines 37 to 40
for _, n := range indexs {
delete(p.used, n)
p.used[n] = nil
p.n--
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The current implementation of Remove decrements the counter p.n for every index in indexs, even if the slot was already empty (nil). This can lead to an incorrect count if duplicate indices are provided or if an index is removed twice. The previous map-based implementation was idempotent because delete on a map is a no-op if the key is missing. Consider checking if the slot is non-nil before decrementing.

	for _, n := range indexs {
		if p.used[n] != nil {
			p.used[n] = nil
			p.n--
		}
	}

Comment on lines 37 to 40
for _, n := range indexs {
delete(p.used, n)
p.used[n] = nil
p.n--
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The current implementation of Remove decrements the counter p.n for every index in indexs, even if the slot was already empty (nil). This can lead to an incorrect count if duplicate indices are provided or if an index is removed twice. The previous map-based implementation was idempotent because delete on a map is a no-op if the key is missing. Consider checking if the slot is non-nil before decrementing.

	for _, n := range indexs {
		if p.used[n] != nil {
			p.used[n] = nil
			p.n--
		}
	}

fun: fn,
ptr: (*struct{ typ, ptr unsafe.Pointer })(unsafe.Pointer(&fn)).ptr,
}
p.n++

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The counter p.n is incremented here regardless of whether a valid slot was found in the initial loop. If index is -1, this increment is incorrect (though the subsequent code will likely panic). Consider incrementing p.n only when a slot is successfully allocated, similar to the implementation in icall.go.

fun: fn,
ptr: (*struct{ typ, ptr unsafe.Pointer })(unsafe.Pointer(&fn)).ptr,
}
p.n++

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The counter p.n is incremented here regardless of whether a valid slot was found in the initial loop. If index is -1, this increment is incorrect (though the subsequent code will likely panic). Consider incrementing p.n only when a slot is successfully allocated, similar to the implementation in icall.go.

fun: fn,
ptr: (*struct{ typ, ptr unsafe.Pointer })(unsafe.Pointer(&fn)).ptr,
}
p.n++

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The counter p.n is incremented here regardless of whether a valid slot was found in the initial loop. If index is -1, this increment is incorrect (though the subsequent code will likely panic). Consider incrementing p.n only when a slot is successfully allocated, similar to the implementation in icall.go.

fun: fn,
ptr: (*struct{ typ, ptr unsafe.Pointer })(unsafe.Pointer(&fn)).ptr,
}
p.n++

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The counter p.n is incremented here regardless of whether a valid slot was found in the initial loop. If index is -1, this increment is incorrect (though the subsequent code will likely panic). Consider incrementing p.n only when a slot is successfully allocated, similar to the implementation in icall.go.

fun: fn,
ptr: (*struct{ typ, ptr unsafe.Pointer })(unsafe.Pointer(&fn)).ptr,
}
p.n++

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The counter p.n is incremented here regardless of whether a valid slot was found in the initial loop. If index is -1, this increment is incorrect (though the subsequent code will likely panic). Consider incrementing p.n only when a slot is successfully allocated, similar to the implementation in icall.go.

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review: icall: replace map with slice

The overall direction — replacing map[int]*T with a pre-allocated []*T slice and an n counter — is sound and improves cache locality and removes map overhead for the common case. A few issues across the generated files are worth addressing.

Summary of findings:

  • CI matrix not fully updated (Linux/Windows still test pre-Go-1.21 versions → compile failure)
  • Remove does not guard against double-remove → p.n counter underflow
  • Remove has no bounds check → runtime panic on OOB index (regression from map which was a silent no-op)
  • Insert O(capacity) linear scan from index 0 with no free-slot hint; notable at large capacities
  • Large providers pre-allocate full capacity upfront (e.g. icall20480 = 160 KB at init)
  • clear(p.used) vs clear(p.used[:]) inconsistency between template files
  • README ABI section still documents Go 1.18/1.19 behaviour below the new minimum

Comment thread .github/workflows/go.yml
strategy:
matrix:
go-version: [1.18.x, 1.19.x, 1.20.x, 1.21.x, 1.22.x, 1.23.x, 1.24.x, 1.25.x, 1.26.x]
go-version: [1.21.x, 1.22.x, 1.23.x, 1.24.x, 1.25.x, 1.26.x]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

CI breakage: Linux matrix not updated. This job still includes 1.18.x, 1.19.x, 1.20.x, but go.mod now requires Go 1.21 and clear() (added in 1.21) is used throughout — these versions will fail at compile time. The MacOS matrix was updated in this PR but Linux (here) and Windows (line ~54) were not. Both should be updated to match: [1.21.x, 1.22.x, 1.23.x, 1.24.x, 1.25.x, 1.26.x].

Comment thread .github/workflows/go.yml
strategy:
matrix:
go-version: [1.18.x, 1.19.x, 1.20.x, 1.21.x, 1.22.x, 1.23.x, 1.24.x, 1.25.x, 1.26.x]
go-version: [1.21.x, 1.22.x, 1.23.x, 1.24.x, 1.25.x, 1.26.x]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

CI breakage: Windows matrix not updated. Same issue as the Linux job above — still includes Go 1.18/1.19/1.20 which will fail to compile this module.

Comment thread cmd/icall_gen/_data/icall.go Outdated
Comment on lines +36 to +39
func (p *provider) Remove(indexs []int) {
for _, n := range indexs {
delete(p.used, n)
p.used[n] = nil
p.n--

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Remove: two correctness issues introduced by the map→slice change (applies to all generated files).

  1. No nil-guard → counter underflow. p.n-- runs unconditionally even if p.used[n] is already nil (double-remove). This makes p.n go negative, causing Available() to exceed capacity and Used() to return a negative value. The map version was implicitly safe (delete on a missing key was a no-op).

  2. No bounds check → runtime panic. An n value outside [0, capacity) panics with index out of range. The map version was a silent no-op for any key.

Suggested fix:

func (p *provider) Remove(indexs []int) {
    for _, n := range indexs {
        if n >= 0 && n < capacity && p.used[n] != nil {
            p.used[n] = nil
            p.n--
        }
    }
}

This fix needs to be applied in the template (cmd/icall_gen/_data/icall.go) and regenerated, or applied uniformly across all generated files.

Comment on lines 83 to 87
func (p *provider) Remove(indexs []int) {
for _, n := range indexs {
delete(p.used, n)
p.used[n] = nil
p.n--
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same Remove issues as in icall.go — no nil-guard and no bounds check. Both need the same fix in this template file.

Comment on lines 20 to 26
func (p *provider) Insert(info *abi.MethodInfo) (ifn unsafe.Pointer, index int) {
for i := 0; i < capacity; i++ {
if _, ok := p.used[i]; !ok {
if p.used[i] == nil {
p.n++
p.used[i] = info
fn := icall_array[i]
return unsafe.Pointer(reflect.ValueOf(fn).Pointer()), i

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Insert is O(capacity) with no free-slot hint. Every call scans from index 0, so if the first free slot is near the end (e.g. after many removes), performance degrades to O(capacity). For the 20480-slot provider this is up to 20480 pointer comparisons per insertion. A single nextFree int field that advances past occupied slots (with wrap-around) would give O(1) amortized Insert without complicating the rest of the implementation.

Comment thread icall/icall20480/icall.go
Comment on lines 57 to 61
mp = &provider{
used: make(map[int]*abi.MethodInfo),
used: make([]*abi.MethodInfo, capacity),
}
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Full-capacity pre-allocation at init time. make([]*abi.MethodInfo, 20480) commits ~160 KB of heap immediately at program startup, regardless of how many methods are actually registered. The old make(map[int]*T) was near-zero until populated. Programs that import large-capacity providers but register few methods now pay this cost unconditionally. Consider lazy initialization or a smaller initial allocation.


func (p *provider) Clear() {
p.used = make(map[int]*abi.MethodInfo)
clear(p.used)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

clear(p.used) vs clear(p.used[:]) inconsistency. icall.go template uses clear(p.used) while icall_regabi.go uses clear(p.used[:]). Both are semantically identical for a slice, but the inconsistency suggests independent edits rather than uniform code generation. Standardising to one form in the templates avoids confusion.

Comment thread README.md
### Go Version

- Go1.18 ~ Go1.26
- Go1.21 ~ Go1.26

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The top-level Go version line is correctly updated, but the ABI table a few lines below still documents per-platform behaviour starting at Go 1.18 and Go 1.19 — both below the new minimum. Those rows will never apply to users of this module now. Consider removing or collapsing them.

@visualfc

Copy link
Copy Markdown
Member Author

@fennoai

@fennoai

fennoai Bot commented May 14, 2026

Copy link
Copy Markdown
Contributor

PR #76 Review: icall: replace map with slice

This PR makes two independent sets of changes:

  1. map → slice refactor across all provider structs (replacing map[int]*T with []*T + an n counter)
  2. Assembly/linkname consolidation: removes per-architecture spillArgs/unspillArgs implementations and delegates to runtime·spillArgs/runtime·unspillArgs; removes large go121_*.s files; introduces linkname/nolinkname split for Go 1.23 compatibility

Issues Found

1. Remove: bounds check is incomplete — missing n >= 0

In cmd/icall_gen/_data/icall.go and all generated files, Remove now guards with n < capacity but not n >= 0:

if n < capacity && p.used[n] != nil {

A negative index passed by a buggy/malicious caller would still panic with index out of range. The guard should be n >= 0 && n < capacity. The template (cmd/icall_gen/_data/icall.go and cmd/icall_gen/_data/icall_regabi.go) and all generated files need this fix.

2. p.n++ placement in icall_regabi.go — confirmed correct in generated files, but template is ambiguous

In cmd/icall_gen/_data/icall_regabi.go (the template), p.n++ is placed at line 78 before the template knows whether the check is correct. However, in the actual generated files (e.g. icall/icall512/icall_regabi.go line 78), p.n++ correctly comes after the early-return guard for index == -1. The generated code is correct; the template placement should be verified to always generate the post-guard form.

3. CI workflow: duplicate Test-MacOS job definition

cmd/icall_gen/_data/icall_regabi.go is not the problem here — but looking at .github/workflows/go.yml, the diff adds a new Test-MacOS (amd64) job using macos-latest-large, which is fine. However the hunk shows the original Test-MacOS job definition is duplicated in the file — the workflow now contains two Test-MacOS: jobs at top level, which will cause a YAML parse error or silent override in GitHub Actions. One of the two blocks needs to be removed.

4. clear(p.used) vs clear(p.used[:]) inconsistency between templates

cmd/icall_gen/_data/icall.go uses clear(p.used) while cmd/icall_gen/_data/icall_regabi.go uses clear(p.used[:]). Both are semantically equivalent, but the inconsistency between the two template files should be resolved for uniformity.

5. Cap() changed from len(icall_array) to capacity in icall.go

icall.go template changes Cap() to return capacity (the constant) rather than len(icall_array). This is consistent since capacity == len(icall_array), but if the two were ever to diverge (e.g. due to a generation bug), the method would silently return a wrong value. Minor, but worth noting.

6. Build tag comment inconsistency in icall_regabi_nolinkname.go

The // +build legacy comment in icall_regabi_nolinkname.go does not fully reflect the //go:build constraint:

//go:build ... && go1.23 && !linknamefix
// +build goexperiment.regabiargs amd64 arm64 ppc64 ppc64le riscv64 go1.23,loong64
// +build go1.23
// +build !linknamefix

The // +build lines don't include the (go1.23 && loong64) compound term from the new simplified build tag. Since Go 1.17+ uses //go:build as authoritative, this is not a functional issue, but it creates a misleading legacy comment.


Positive aspects

  • The Remove nil-guard (p.used[n] != nil) correctly makes Remove idempotent — matching the old map delete semantics. This directly addresses the counter-underflow issue flagged in other reviews.
  • p.n++ in the regabi path is correctly placed after the index == -1 early return, so the counter is not corrupted on capacity-full conditions.
  • Replacing per-architecture spillArgs/unspillArgs with runtime·spillArgs/runtime·unspillArgs removes ~500+ lines of duplicated assembly per architecture across all capacity variants. This is a meaningful reduction in maintenance surface.
  • Replacing go118/go123 split with a linkname/nolinkname split is a cleaner model for Go 1.23+ compatibility.

View job run

@visualfc visualfc force-pushed the icall_slice branch 2 times, most recently from c0a2665 to cdf7fd8 Compare May 14, 2026 09:17
@visualfc

Copy link
Copy Markdown
Member Author

@fennoai

@fennoai

fennoai Bot commented May 14, 2026

Copy link
Copy Markdown
Contributor

PR #76 Review (updated): icall: replace map with slice

The PR has been updated since the previous review. The CI matrix issues (all three jobs — MacOS, Linux, Windows — now correctly drop Go 1.18/1.19/1.20) and the .DS_Store gitignore entry are addressed. The remaining issues are:


1. Remove: bounds check still missing n >= 0

In all files (cmd/icall_gen/_data/icall.go, cmd/icall_gen/_data/icall_regabi.go, and all generated copies), the guard is:

if n < capacity && p.used[n] != nil {

A negative index would bypass the upper-bound check and panic with index out of range. The original map implementation was a silent no-op for any integer key including negative ones. The fix is to add n >= 0 && before n < capacity in the template files and regenerate (or update all generated copies uniformly).

2. clear(p.used) vs clear(p.used[:]) inconsistency between templates

cmd/icall_gen/_data/icall.go uses clear(p.used) while cmd/icall_gen/_data/icall_regabi.go uses clear(p.used[:]). Both are functionally identical for a slice. Standardising to one form in the templates would prevent this inconsistency from being regenerated into every capacity variant.

3. Build tag legacy comment incomplete in icall_regabi_nolinkname.go

//go:build (goexperiment.regabiargs || amd64 || arm64 || ppc64 || ppc64le || riscv64 || (go1.23 && loong64)) && go1.23 && !linknamefix
// +build goexperiment.regabiargs amd64 arm64 ppc64 ppc64le riscv64 go1.23,loong64
// +build go1.23
// +build !linknamefix

The // +build lines don't encode the (go1.23 && loong64) compound condition from //go:build. Since Go 1.17+ treats //go:build as authoritative, this has no functional impact, but the legacy comment is misleading. If the // +build lines are kept at all (they are only required for compatibility with Go < 1.17, which is no longer supported by this module), they should either accurately reflect the constraint or be removed entirely.

4. Cap() semantic change in icall.go template

Cap() previously returned len(icall_array) (the length of the trampoline array) and now returns capacity (the constant). Since capacity == len(icall_array) by construction, this is equivalent. However, the previous form was a cross-check: if the generator ever produced an icall_array with a different length than capacity, the old Cap() would have exposed the mismatch. The new form removes that check. Minor, but worth noting.

View job run

@visualfc visualfc merged commit 6759824 into goplus:main May 14, 2026
18 checks passed
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.

1 participant