Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ jobs:
echo "total coverage: ${total}%"
awk -v total="$total" 'BEGIN { if (total + 0 < 85.0) { printf("coverage %.1f%% is below 85.0%%\n", total); exit 1 } }'

- name: Test portable SIMD and scalar fallback
if: ${{ matrix.os == 'ubuntu-latest' }}
run: |
# Release binaries are built with this experiment, so test everything they ship.
GOEXPERIMENT=simd go test ./...
GOEXPERIMENT=simd GODEBUG=simd=0 go test ./internal/vector ./internal/cli

- name: Build
run: go build -ldflags "-X github.com/openclaw/gitcrawl/internal/cli.version=${GITHUB_SHA:0:7}" -o bin/gitcrawl ./cmd/gitcrawl

Expand Down
2 changes: 2 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ builds:
binary: gitcrawl
env:
- CGO_ENABLED=0
- GOEXPERIMENT=simd
ldflags:
- -s -w -X github.com/openclaw/gitcrawl/internal/cli.version={{ .Version }}
targets:
Expand All @@ -23,6 +24,7 @@ builds:
binary: gitcrawl
env:
- CGO_ENABLED=0
- GOEXPERIMENT=simd
ldflags:
- -s -w -X github.com/openclaw/gitcrawl/internal/cli.version={{ .Version }}
targets:
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Prepare vectors once for 2.6× faster cluster scoring and 2.0× faster exact neighbors; enable `GOEXPERIMENT=simd` in release builds for a further 2.6×/1.7× on Apple M3 Ultra (1,024 dimensions).
- Disable automatic Git maintenance during portable-refresh fixture setup so temporary repositories do not launch detached cleanup work.

## 0.12.0 - 2026-09-24
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath \
RUN CGO_ENABLED=0 GOEXPERIMENT=simd GOOS=linux go build -trimpath \
-ldflags="-s -w -X github.com/openclaw/gitcrawl/internal/cli.version=${VERSION}" \
-o /out/gitcrawl ./cmd/gitcrawl

Expand Down
6 changes: 6 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ go build \

Symlink or copy `bin/gitcrawl` somewhere on your `PATH` (`~/bin`, `/usr/local/bin`, `~/.local/bin`).

Source builds use scalar vector scoring by default. Set `GOEXPERIMENT=simd` on
`go build` to enable Go 1.27's experimental portable SIMD kernels for clustering
and exact neighbors. The API may change in Go 1.28; unsupported hardware and
`GODEBUG=simd=0` use the scalar kernels. Release archives and Docker builds enable
this SIMD path.

## GitHub CLI shim migration

`gitcrawl gh` moved to Octopool:
Expand Down
46 changes: 28 additions & 18 deletions internal/cli/cluster_graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,24 +81,8 @@ func buildDurableClusterInputs(ctx context.Context, st *store.Store, repoID int6
}
nodes = append(nodes, clusterer.Node{ThreadID: stored.ThreadID, Number: thread.Number, Title: thread.Title})
}
candidateByPair := map[string]clusterer.Edge{}
for left := 0; left < len(nodes); left++ {
for right := left + 1; right < len(nodes); right++ {
leftID := nodes[left].ThreadID
rightID := nodes[right].ThreadID
score := vector.Cosine(vectorByThreadID[leftID], vectorByThreadID[rightID])
if score < options.Threshold {
continue
}
if score < highConfidenceEdgeScore && titleTokenOverlap(threads[leftID].Title, threads[rightID].Title) < weakEdgeMinTitleOverlap {
continue
}
if threads[leftID].Kind != threads[rightID].Kind && score < options.CrossKindThreshold {
continue
}
upsertClusterEdge(candidateByPair, leftID, rightID, score)
}
}
candidateByPair := scoreClusterEdges(nodes, threads, vectorByThreadID, options)

repoFullName, err := repositoryFullNameByID(ctx, st, repoID)
if err != nil {
return nil, 0, err
Expand Down Expand Up @@ -155,6 +139,32 @@ func buildDurableClusterInputs(ctx context.Context, st *store.Store, repoID int6
return inputs, len(edges), nil
}

func scoreClusterEdges(nodes []clusterer.Node, threads map[int64]store.Thread, vectorByThreadID map[int64][]float64, options clusterBuildOptions) map[string]clusterer.Edge {
prepared := make([]vector.Prepared, len(nodes))
for i, node := range nodes {
prepared[i] = vector.Prepare(vectorByThreadID[node.ThreadID])
}
candidateByPair := map[string]clusterer.Edge{}
for left := 0; left < len(nodes); left++ {
for right := left + 1; right < len(nodes); right++ {
leftID := nodes[left].ThreadID
rightID := nodes[right].ThreadID
score := prepared[left].Cosine(prepared[right])
if score < options.Threshold {
continue
}
if score < highConfidenceEdgeScore && titleTokenOverlap(threads[leftID].Title, threads[rightID].Title) < weakEdgeMinTitleOverlap {
continue
}
if threads[leftID].Kind != threads[rightID].Kind && score < options.CrossKindThreshold {
continue
}
upsertClusterEdge(candidateByPair, leftID, rightID, score)
}
}
return candidateByPair
}

func upsertClusterEdge(edges map[string]clusterer.Edge, leftID, rightID int64, score float64) {
if leftID == rightID {
return
Expand Down
133 changes: 133 additions & 0 deletions internal/cli/cluster_graph_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package cli

import (
"context"
"encoding/json"
"fmt"
"io"
"math/rand/v2"
"path/filepath"
"testing"
"time"

clusterer "github.com/openclaw/gitcrawl/internal/cluster"
"github.com/openclaw/gitcrawl/internal/config"
"github.com/openclaw/gitcrawl/internal/store"
)

func clusterBenchmarkData(count int) ([]clusterer.Node, map[int64]store.Thread, map[int64][]float64) {
rng := rand.New(rand.NewPCG(7, 11))
dims := config.Default().OpenAI.EmbedDimensions
nodes := make([]clusterer.Node, count)
threads := make(map[int64]store.Thread, count)
vectors := make(map[int64][]float64, count)
var center []float64
for i := range nodes {
if i%8 == 0 {
center = make([]float64, dims)
for j := range center {
center[j] = rng.NormFloat64()
}
}
values := make([]float64, dims)
for j := range values {
values[j] = center[j] + 0.15*rng.NormFloat64()
}
id := int64(i + 1)
title := fmt.Sprintf("Synthetic embedding group %d", i/8)
nodes[i] = clusterer.Node{ThreadID: id, Number: i + 1, Title: title}
kind := "issue"
if i%3 == 0 {
kind = "pull_request"
}
threads[id] = store.Thread{Number: i + 1, Kind: kind, State: "open", Title: title}
vectors[id] = values
}
return nodes, threads, vectors
}

var benchmarkEdges map[string]clusterer.Edge

func BenchmarkClusterEdges2000(b *testing.B) {
nodes, threads, vectors := clusterBenchmarkData(2000)
opts := clusterBuildOptions{Threshold: 0.90, CrossKindThreshold: defaultCrossKindMinScore}
b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
benchmarkEdges = scoreClusterEdges(nodes, threads, vectors, opts)
}
}

func BenchmarkClusterCommand2000(b *testing.B) {
ctx := context.Background()
b.Setenv("GITCRAWL_NO_UPDATE_CHECK", "1")
dir := b.TempDir()
configPath, dbPath := filepath.Join(dir, "config.toml"), filepath.Join(dir, "gitcrawl.db")
app := New()
app.Stdout, app.Stderr = io.Discard, io.Discard
if err := app.Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {
b.Fatal(err)
}
st, err := store.Open(ctx, dbPath)
if err != nil {
b.Fatal(err)
}
defer st.Close()
now := time.Now().UTC().Format(time.RFC3339Nano)
repoID, err := st.UpsertRepository(ctx, store.Repository{Owner: "synthetic", Name: "bench", FullName: "synthetic/bench", UpdatedAt: now})
if err != nil {
b.Fatal(err)
}
nodes, threads, vectors := clusterBenchmarkData(2000)
if err := st.WithTx(ctx, func(st *store.Store) error {
for _, node := range nodes {
thread := threads[node.ThreadID]
thread.RepoID, thread.GitHubID = repoID, fmt.Sprint(node.ThreadID)
thread.LabelsJSON, thread.AssigneesJSON, thread.RawJSON = "[]", "[]", "{}"
thread.ContentHash, thread.UpdatedAt = fmt.Sprint(node.ThreadID), now
if _, err := st.UpsertThread(ctx, thread); err != nil {
return err
}
}
return nil
}); err != nil {
b.Fatal(err)
}
tasks, err := st.ListEmbeddingTasks(ctx, store.EmbeddingTaskOptions{RepoID: repoID, Basis: "title_original", Model: "text-embedding-3-small", Force: true})
if err != nil {
b.Fatal(err)
}
if len(tasks) != len(nodes) {
b.Fatalf("tasks: %d want %d", len(tasks), len(nodes))
}
// Bulk fixture insertion stays outside the timed command and avoids per-row fsync.
tx, err := st.DB().BeginTx(ctx, nil)
if err != nil {
b.Fatal(err)
}
defer tx.Rollback()
for _, task := range tasks {
values := vectors[task.ThreadID]
data, err := json.Marshal(values)
if err != nil {
b.Fatal(err)
}
if _, err := tx.ExecContext(ctx, `insert into thread_vectors(thread_id,basis,model,dimensions,content_hash,vector_json,vector_backend,created_at,updated_at) values(?,?,?,?,?,?,?,?,?)`, task.ThreadID, "title_original", "text-embedding-3-small", len(values), task.ContentHash, string(data), "exact", now, now); err != nil {
b.Fatal(err)
}
}
if err := tx.Commit(); err != nil {
b.Fatal(err)
}

if err := st.Close(); err != nil {
b.Fatal(err)
}
b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
if err := app.Run(ctx, []string{"--config", configPath, "cluster", "synthetic/bench", "--threshold", "0.90", "--json"}); err != nil {
b.Fatal(err)
}
}
}
58 changes: 58 additions & 0 deletions internal/cli/cluster_graph_scoring_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package cli

import (
clusterer "github.com/openclaw/gitcrawl/internal/cluster"
"github.com/openclaw/gitcrawl/internal/store"
"github.com/openclaw/gitcrawl/internal/vector"
"math"
"testing"
)

func TestPreparedClusterEdgesMatchOriginal(t *testing.T) {
nodes, threads, vectors := clusterBenchmarkData(96)
vectors[1] = []float64{math.NaN()}
vectors[2] = nil
vectors[3] = []float64{0}
vectors[4] = []float64{1, 2}
vectors[5] = []float64{math.MaxFloat64, math.MaxFloat64}
vectors[6] = []float64{math.SmallestNonzeroFloat64, math.SmallestNonzeroFloat64}
var maxDeviation float64
for _, threshold := range []float64{-1, 0, 0.3, 0.9, 1} {
opts := clusterBuildOptions{Threshold: threshold, CrossKindThreshold: defaultCrossKindMinScore}
got, want := scoreClusterEdges(nodes, threads, vectors, opts), originalClusterEdges(nodes, threads, vectors, opts)
if len(got) != len(want) {
t.Fatalf("threshold %g: %d edges want %d", threshold, len(got), len(want))
}
for pair, expected := range want {
actual, ok := got[pair]
deviation := math.Abs(actual.Score - expected.Score)
maxDeviation = max(maxDeviation, deviation)
if !ok || actual.LeftThreadID != expected.LeftThreadID || actual.RightThreadID != expected.RightThreadID || deviation > 1e-12 {
t.Fatalf("threshold %g pair %s: %+v want %+v", threshold, pair, actual, expected)
}
}
}
t.Logf("max cluster-edge score deviation: %.17g", maxDeviation)
}

func originalClusterEdges(nodes []clusterer.Node, threads map[int64]store.Thread, vectorByThreadID map[int64][]float64, options clusterBuildOptions) map[string]clusterer.Edge {
candidateByPair := map[string]clusterer.Edge{}
for left := 0; left < len(nodes); left++ {
for right := left + 1; right < len(nodes); right++ {
leftID := nodes[left].ThreadID
rightID := nodes[right].ThreadID
score := vector.Cosine(vectorByThreadID[leftID], vectorByThreadID[rightID])
if score < options.Threshold {
continue
}
if score < highConfidenceEdgeScore && titleTokenOverlap(threads[leftID].Title, threads[rightID].Title) < weakEdgeMinTitleOverlap {
continue
}
if threads[leftID].Kind != threads[rightID].Kind && score < options.CrossKindThreshold {
continue
}
upsertClusterEdge(candidateByPair, leftID, rightID, score)
}
}
return candidateByPair
}
7 changes: 6 additions & 1 deletion internal/vector/exact.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ func queryExact(ctx context.Context, items []Item, query []float64, limit int, e
if err := validateExactQuery(query); err != nil {
return nil, err
}
preparedQuery := Prepare(query)
scratch := make([]float64, len(query))
scored := make([]crawlvector.Scored[Neighbor], 0, len(items))
for _, item := range items {
if err := ctx.Err(); err != nil {
Expand All @@ -64,7 +66,10 @@ func queryExact(ctx context.Context, items []Item, query []float64, limit int, e
if item.ThreadID == excludeThreadID {
continue
}
score := Cosine(query, item.Vector)
if len(item.Vector) != len(query) {
continue
}
score := preparedQuery.Cosine(prepareInto(scratch, item.Vector))
if math.IsNaN(score) || math.IsInf(score, 0) || score <= 0 {
continue
}
Expand Down
52 changes: 52 additions & 0 deletions internal/vector/prepared.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package vector

import "math"

// Prepared holds an immutable, validated copy of a vector for repeated scoring.
// Its zero value scores zero against every vector.
type Prepared struct {
values []float64
magnitude float64
}

// Prepare scales by max-abs before computing the magnitude, so even very large
// or subnormal finite inputs are safe. Empty, zero and non-finite inputs are invalid.
func Prepare(values []float64) Prepared {
return prepareInto(make([]float64, len(values)), values)
}

func prepareScalar(dst, values []float64) Prepared {
var maxAbs float64
for _, value := range values {
if math.IsNaN(value) || math.IsInf(value, 0) {
return Prepared{}
}
maxAbs = max(maxAbs, math.Abs(value))
}
if maxAbs == 0 {
return Prepared{}
}
var magnitude float64
for i, value := range values {
scaled := value / maxAbs
dst[i] = scaled
magnitude += scaled * scaled
}
return Prepared{values: dst[:len(values)], magnitude: math.Sqrt(magnitude)}
}

// Cosine returns zero for invalid or mismatched vectors and clamps to [-1, 1].
func (left Prepared) Cosine(right Prepared) float64 {
if len(left.values) == 0 || len(left.values) != len(right.values) {
return 0
}
return max(-1, min(1, preparedDot(left.values, right.values)/(left.magnitude*right.magnitude)))
}

func dotScalar(left, right []float64) float64 {
var dot float64
for i, value := range left {
dot += value * right[i]
}
return dot
}
11 changes: 11 additions & 0 deletions internal/vector/prepared_nosimd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//go:build !goexperiment.simd

package vector

func preparedDot(left, right []float64) float64 {
return dotScalar(left, right)
}

func prepareInto(dst, values []float64) Prepared {
return prepareScalar(dst, values)
}
Loading
Loading