feat: initial release
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,493 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package linalg
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"slices"
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
||||
)
|
||||
|
||||
// Fill-reducing orderings for the sparse factorisations. The
|
||||
// symmetrised adjacency builder and the orderings live here; the
|
||||
// factorisation consumes nothing but a permutation, so a new ordering
|
||||
// slots in without touching the numeric code.
|
||||
|
||||
// symmetrisedAdjacency returns the adjacency lists of the pattern of
|
||||
// A + Aᵀ with the diagonal dropped: for every stored entry (i, j),
|
||||
// i ≠ j, i sits on j's list and j on i's. Every list is sorted by
|
||||
// ascending degree and then index, which fixes the visiting order the
|
||||
// breadth first search uses. The lists slice out of one flat arena
|
||||
// sized by a counting pass, so the construction allocates twice
|
||||
// whatever the vertex count; the degree snapshot counts the entries a
|
||||
// list received, duplicates included, exactly as the incremental build
|
||||
// measured them, and the degree-then-index sort is a total order, so
|
||||
// the fill order inside a list cannot reach the result.
|
||||
func symmetrisedAdjacency(c *SparseCSC) ([][]int, error) {
|
||||
adj, counts, err := symmetrisedAdjacencyBuild(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range adj {
|
||||
slices.SortStableFunc(adj[i], func(x, y int) int {
|
||||
if d := cmp.Compare(counts[x], counts[y]); d != 0 {
|
||||
return d
|
||||
}
|
||||
return cmp.Compare(x, y)
|
||||
})
|
||||
}
|
||||
return adj, nil
|
||||
}
|
||||
|
||||
// adjacencyIndexOrdered returns the same adjacency lists in plain
|
||||
// ascending index order, the order the minimum-degree absorption
|
||||
// merges: it skips the degree-then-index sort the breadth first
|
||||
// search needs and no index-ordered consumer does.
|
||||
func adjacencyIndexOrdered(c *SparseCSC) ([][]int, error) {
|
||||
adj, _, err := symmetrisedAdjacencyBuild(c)
|
||||
return adj, err
|
||||
}
|
||||
|
||||
// symmetrisedAdjacencyBuild builds the adjacency lists in ascending
|
||||
// index order: one flat arena sized by a counting pass, every list
|
||||
// sorted and compacted. The counts it also returns are the raw
|
||||
// duplicate-inclusive neighbour counts the build measured, the values
|
||||
// the degree-then-index ordering sorts by.
|
||||
func symmetrisedAdjacencyBuild(c *SparseCSC) ([][]int, []int, error) {
|
||||
counts := make([]int, c.Cols)
|
||||
for j := range c.Cols {
|
||||
for p := c.ColStart[j]; p < c.ColStart[j+1]; p++ {
|
||||
i := c.RowIdx[p]
|
||||
if i == j {
|
||||
continue
|
||||
}
|
||||
if i < 0 || i >= c.Cols {
|
||||
return nil, nil, base.Errf("adjacency: row index %d out of range for %d columns", i, c.Cols)
|
||||
}
|
||||
counts[j]++
|
||||
counts[i]++
|
||||
}
|
||||
}
|
||||
total := 0
|
||||
for i := range c.Cols {
|
||||
total += counts[i]
|
||||
}
|
||||
arena := make([]int, total)
|
||||
adj := make([][]int, c.Cols)
|
||||
next := make([]int, c.Cols)
|
||||
off := 0
|
||||
for i := range c.Cols {
|
||||
adj[i] = arena[off : off+counts[i]]
|
||||
next[i] = off
|
||||
off += counts[i]
|
||||
}
|
||||
for j := range c.Cols {
|
||||
for p := c.ColStart[j]; p < c.ColStart[j+1]; p++ {
|
||||
i := c.RowIdx[p]
|
||||
if i == j {
|
||||
continue
|
||||
}
|
||||
arena[next[j]] = i
|
||||
next[j]++
|
||||
arena[next[i]] = j
|
||||
next[i]++
|
||||
}
|
||||
}
|
||||
for i := range c.Cols {
|
||||
slices.Sort(adj[i])
|
||||
adj[i] = slices.Compact(adj[i])
|
||||
}
|
||||
return adj, counts, nil
|
||||
}
|
||||
|
||||
// breadthFirstSearch walks the component of start and returns the
|
||||
// visit order, labelling level for every vertex it reaches. It
|
||||
// initialises level to the unvisited state itself, so the caller can
|
||||
// pass a scratch slice.
|
||||
func breadthFirstSearch(adj [][]int, level []int, start int) []int {
|
||||
for i := range level {
|
||||
level[i] = -1
|
||||
}
|
||||
order := []int{start}
|
||||
level[start] = 0
|
||||
for head := 0; head < len(order); head++ {
|
||||
v := order[head]
|
||||
for _, u := range adj[v] {
|
||||
if level[u] < 0 {
|
||||
level[u] = level[v] + 1
|
||||
order = append(order, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// deepestLevel returns the vertices of the largest level in visit
|
||||
// order.
|
||||
func deepestLevel(order []int, level []int) []int {
|
||||
max := 0
|
||||
for _, v := range order {
|
||||
if level[v] > max {
|
||||
max = level[v]
|
||||
}
|
||||
}
|
||||
last := make([]int, 0, len(order))
|
||||
for _, v := range order {
|
||||
if level[v] == max {
|
||||
last = append(last, v)
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
// pseudoPeripheralStart finds a start vertex whose eccentricity is
|
||||
// close to the component's diameter: two sweeps of taking the
|
||||
// shallowest-degree vertex of the deepest level, the standard
|
||||
// pseudo-peripheral heuristic. Every choice is deterministic, so the
|
||||
// ordering is a pure function of the pattern.
|
||||
func pseudoPeripheralStart(adj [][]int, level []int, guess int) int {
|
||||
start := guess
|
||||
for range 2 {
|
||||
order := breadthFirstSearch(adj, level, start)
|
||||
last := deepestLevel(order, level)
|
||||
best := last[0]
|
||||
for _, v := range last[1:] {
|
||||
if len(adj[v]) < len(adj[best]) {
|
||||
best = v
|
||||
}
|
||||
}
|
||||
start = best
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
||||
// reverseCuthillMcKee returns the elimination order of the symmetric
|
||||
// pattern: the vertices of every component in reverse breadth first
|
||||
// order from a pseudo-peripheral start. The reverse of the search
|
||||
// order is what shrinks the bandwidth, and with it the fill a
|
||||
// triangular factorisation produces. Components are taken in
|
||||
// ascending order of their smallest unreached vertex, so the result
|
||||
// is a pure function of the pattern: position k holds the original
|
||||
// index of the row eliminated k-th.
|
||||
func reverseCuthillMcKee(c *SparseCSC) ([]int, error) {
|
||||
adj, err := symmetrisedAdjacency(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
level := make([]int, c.Cols)
|
||||
visited := make([]bool, c.Cols)
|
||||
order := make([]int, 0, c.Cols)
|
||||
for s := range c.Cols {
|
||||
if visited[s] {
|
||||
continue
|
||||
}
|
||||
start := pseudoPeripheralStart(adj, level, s)
|
||||
component := breadthFirstSearch(adj, level, start)
|
||||
for _, v := range component {
|
||||
visited[v] = true
|
||||
}
|
||||
for _, c := range slices.Backward(component) {
|
||||
order = append(order, c)
|
||||
}
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
// minimumDegree returns the elimination order of the symmetric
|
||||
// pattern by minimum degree: at every step the uneliminated vertex
|
||||
// with the fewest remaining neighbours is eliminated, its neighbours
|
||||
// absorb its pattern (the element the elimination creates), and
|
||||
// degree ties break to the smallest index, so the order is a pure
|
||||
// function of the pattern. On irregular patterns it shrinks the fill
|
||||
// well below what a bandwidth ordering reaches; on structured meshes
|
||||
// the reverse Cuthill-McKee order is its match.
|
||||
//
|
||||
// The selection runs on a lazy frontier instead of a scan over the
|
||||
// surviving vertices: a binary heap of (degree, vertex) pairs where a
|
||||
// vertex enters when its degree is first known and its pair is rewritten
|
||||
// in place whenever the degree moves, so the heap holds every surviving
|
||||
// vertex exactly once and hands out the smallest (degree, index) among
|
||||
// them, which is the first vertex of the smallest degree the scan would
|
||||
// pick, at a logarithmic price per entry instead of a full sweep per
|
||||
// elimination.
|
||||
func minimumDegree(c *SparseCSC) ([]int, error) {
|
||||
adj, err := adjacencyIndexOrdered(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := c.Cols
|
||||
eliminated := make([]bool, n)
|
||||
degree := make([]int, n)
|
||||
frontier := °reeFrontier{entries: make([]degreeEntry, 0, n)}
|
||||
arena := &intArena{}
|
||||
for i := range n {
|
||||
degree[i] = len(adj[i])
|
||||
frontier.push(degreeEntry{degree[i], i})
|
||||
}
|
||||
order := make([]int, 0, n)
|
||||
for range n {
|
||||
if frontier.len() == 0 {
|
||||
return nil, base.Errf("minimumDegree: no vertex left to eliminate")
|
||||
}
|
||||
p := frontier.pop().vertex
|
||||
order = append(order, p)
|
||||
eliminated[p] = true
|
||||
absorbElement(adj, degree, eliminated, p, frontier, arena)
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
// degreeEntry pairs a vertex with the degree it carried when it
|
||||
// entered the frontier.
|
||||
type degreeEntry struct {
|
||||
degree int
|
||||
vertex int
|
||||
}
|
||||
|
||||
// before orders the pairs by degree, then index: the heap's top is
|
||||
// the vertex the selection would take.
|
||||
func (e degreeEntry) before(other degreeEntry) bool {
|
||||
if e.degree != other.degree {
|
||||
return e.degree < other.degree
|
||||
}
|
||||
return e.vertex < other.vertex
|
||||
}
|
||||
|
||||
// degreeFrontier is a binary min-heap of degreeEntry pairs holding
|
||||
// every vertex at most once: push rewrites the vertex's pair in place
|
||||
// when one is already queued, so the heap never carries a stale pair
|
||||
// and its top is always the vertex the selection takes. The absence of
|
||||
// stale pairs keeps the heap at the vertex count instead of one entry
|
||||
// per degree movement, which is what a sift-down per pop would
|
||||
// otherwise charge.
|
||||
type degreeFrontier struct {
|
||||
entries []degreeEntry
|
||||
pos []int
|
||||
}
|
||||
|
||||
// len reports how many pairs the frontier holds.
|
||||
func (f *degreeFrontier) len() int { return len(f.entries) }
|
||||
|
||||
// push records the vertex's current degree: the pair of a vertex
|
||||
// already queued moves to its new place, a vertex without a pair
|
||||
// enters at the end. pos grows to cover the vertex and fills its new
|
||||
// slots with -1, so an absent vertex reads -1 and a queued one reads
|
||||
// its heap index.
|
||||
func (f *degreeFrontier) push(e degreeEntry) {
|
||||
for len(f.pos) <= e.vertex {
|
||||
f.pos = append(f.pos, -1)
|
||||
}
|
||||
if at := f.pos[e.vertex]; at != -1 {
|
||||
f.entries[at] = e
|
||||
f.fix(at)
|
||||
return
|
||||
}
|
||||
f.pos[e.vertex] = len(f.entries)
|
||||
f.entries = append(f.entries, e)
|
||||
f.up(len(f.entries) - 1)
|
||||
}
|
||||
|
||||
// pop removes and returns the smallest pair. It is the caller's job to
|
||||
// check len first; the returned pair is never stale.
|
||||
func (f *degreeFrontier) pop() degreeEntry {
|
||||
entries := f.entries
|
||||
top := entries[0]
|
||||
f.pos[top.vertex] = -1
|
||||
last := len(entries) - 1
|
||||
entries[0] = entries[last]
|
||||
f.entries = entries[:last]
|
||||
if last > 0 {
|
||||
f.pos[entries[0].vertex] = 0
|
||||
f.down(0)
|
||||
}
|
||||
return top
|
||||
}
|
||||
|
||||
// fix restores the heap order around i after the pair at i changed.
|
||||
func (f *degreeFrontier) fix(i int) {
|
||||
if !f.up(i) {
|
||||
f.down(i)
|
||||
}
|
||||
}
|
||||
|
||||
// up lifts the pair at i until its parent stops outranking it, and
|
||||
// reports whether it moved at all.
|
||||
func (f *degreeFrontier) up(i int) bool {
|
||||
entries := f.entries
|
||||
moved := false
|
||||
for i > 0 {
|
||||
parent := (i - 1) / 2
|
||||
if !entries[i].before(entries[parent]) {
|
||||
break
|
||||
}
|
||||
entries[i], entries[parent] = entries[parent], entries[i]
|
||||
f.pos[entries[i].vertex] = i
|
||||
i = parent
|
||||
moved = true
|
||||
}
|
||||
f.pos[entries[i].vertex] = i
|
||||
return moved
|
||||
}
|
||||
|
||||
// down drops the pair at i until both children stop outranking it.
|
||||
func (f *degreeFrontier) down(i int) {
|
||||
entries := f.entries
|
||||
for {
|
||||
child := 2*i + 1
|
||||
if child >= len(entries) {
|
||||
break
|
||||
}
|
||||
if right := child + 1; right < len(entries) && entries[right].before(entries[child]) {
|
||||
child = right
|
||||
}
|
||||
if !entries[child].before(entries[i]) {
|
||||
break
|
||||
}
|
||||
entries[i], entries[child] = entries[child], entries[i]
|
||||
f.pos[entries[i].vertex] = i
|
||||
i = child
|
||||
}
|
||||
f.pos[entries[i].vertex] = i
|
||||
}
|
||||
|
||||
// intArena hands out int slices from one bump-allocated backing block,
|
||||
// so the minimum-degree absorption merges cost an allocation per block
|
||||
// instead of one per merge. A returned slice owns its range: the merge
|
||||
// appends into a zero-length view whose capacity is its exact upper
|
||||
// bound, and the arena never hands the same range out twice. The
|
||||
// capacity a merge leaves over returns to the bump space through
|
||||
// release, which must reach the arena before its next alloc, while the
|
||||
// released slice is still the most recent one. Backing blocks are kept
|
||||
// alive by the adjacency lists that point into them, not by the arena.
|
||||
type intArena struct {
|
||||
buf []int
|
||||
// bump is the offset of the next free entry in buf; the block is
|
||||
// replaced once the space behind bump no longer fits a request.
|
||||
bump int
|
||||
}
|
||||
|
||||
// arenaBlockInts is the smallest backing block: big enough that the
|
||||
// small early merges share one allocation, small enough that a block
|
||||
// full of dead lists retires without pinning much memory.
|
||||
const arenaBlockInts = 16384
|
||||
|
||||
// alloc returns a zero-length slice with capacity n. The arena works
|
||||
// from an empty buffer as well as a partially consumed one: the bump
|
||||
// offset starts at zero and the block check reads it against the block
|
||||
// length, so a zero-value arena allocates rather than panics.
|
||||
func (a *intArena) alloc(n int) []int {
|
||||
if len(a.buf)-a.bump < n {
|
||||
a.buf = make([]int, max(arenaBlockInts, n))
|
||||
a.bump = 0
|
||||
}
|
||||
out := a.buf[a.bump : a.bump : a.bump+n]
|
||||
a.bump += n
|
||||
return out
|
||||
}
|
||||
|
||||
// release folds the capacity a merge left over back into the bump
|
||||
// space, so the next alloc reuses it instead of moving past it. The
|
||||
// slice must be the arena's most recent allocation and must not be
|
||||
// released twice.
|
||||
func (a *intArena) release(out []int) {
|
||||
a.bump -= cap(out) - len(out)
|
||||
}
|
||||
|
||||
// absorbElement merges the element the eliminated vertex p leaves
|
||||
// behind into every surviving neighbour's adjacency list, the fill
|
||||
// the elimination creates: adj(j) becomes adj(j) ∪ adj(p) minus j and
|
||||
// minus every eliminated vertex. The set difference matters: p sits on
|
||||
// j's list and j on p's, so the plain union hands j its own index
|
||||
// back, and a self entry both inflates the degree the selection reads
|
||||
// and spreads to other lists through later absorptions. A neighbour's
|
||||
// degree is the count of surviving entries in the merged list, the
|
||||
// number a scan would recount at the same step, kept here where the
|
||||
// list changes; a neighbour whose degree moved re-enters the frontier.
|
||||
func absorbElement(adj [][]int, degree []int, eliminated []bool, p int, frontier *degreeFrontier, arena *intArena) {
|
||||
// The element list is compacted once, in place, before any merge:
|
||||
// p is eliminated and its list is dead, no vertex becomes
|
||||
// eliminated while the absorb runs, and every neighbour's merge
|
||||
// walks the same list, so one filter pass here replaces one per
|
||||
// merged element. The compaction is stable, so the merge order is
|
||||
// the list's own.
|
||||
b := adj[p]
|
||||
w := 0
|
||||
for _, u := range b {
|
||||
if !eliminated[u] {
|
||||
b[w] = u
|
||||
w++
|
||||
}
|
||||
}
|
||||
b = b[:w]
|
||||
for _, j := range b {
|
||||
before := degree[j]
|
||||
adj[j] = mergedElement(adj[j], b, j, eliminated, degree, arena)
|
||||
if degree[j] != before {
|
||||
frontier.push(degreeEntry{degree[j], j})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mergedElement merges two sorted unique slices into one sorted unique
|
||||
// slice without the self entry and without the eliminated entries, and
|
||||
// stores the surviving count into degree[self]. The b side arrives
|
||||
// already free of eliminated entries, so only the entries that come
|
||||
// from a (and the merged equal pairs) pay the filter; b still carries
|
||||
// self exactly once, which its own check drops. The degree the
|
||||
// selection reads is the count of surviving entries either way, so the
|
||||
// elimination order the frontier produces cannot move. The result lands
|
||||
// in an arena slice capped at len(a)+len(b), which the merge cannot
|
||||
// exceed: every entry of both lists is emitted at most once and the
|
||||
// self entry never is.
|
||||
func mergedElement(a, b []int, self int, eliminated []bool, degree []int, arena *intArena) []int {
|
||||
n := len(a) + len(b)
|
||||
out := arena.alloc(n)
|
||||
// The arena reserved exactly n entries, so the merge writes through
|
||||
// the stretched view and cuts the result back at the end.
|
||||
out = out[:n:n]
|
||||
w := 0
|
||||
i, j := 0, 0
|
||||
for i < len(a) && j < len(b) {
|
||||
u := a[i]
|
||||
switch v := b[j]; {
|
||||
case u < v:
|
||||
i++
|
||||
if u != self && !eliminated[u] {
|
||||
out[w] = u
|
||||
w++
|
||||
}
|
||||
case v < u:
|
||||
j++
|
||||
if v != self {
|
||||
out[w] = v
|
||||
w++
|
||||
}
|
||||
default:
|
||||
i++
|
||||
j++
|
||||
if u != self && !eliminated[u] {
|
||||
out[w] = u
|
||||
w++
|
||||
}
|
||||
}
|
||||
}
|
||||
for ; i < len(a); i++ {
|
||||
if u := a[i]; u != self && !eliminated[u] {
|
||||
out[w] = u
|
||||
w++
|
||||
}
|
||||
}
|
||||
for ; j < len(b); j++ {
|
||||
if v := b[j]; v != self {
|
||||
out[w] = v
|
||||
w++
|
||||
}
|
||||
}
|
||||
out = out[:w]
|
||||
degree[self] = w
|
||||
// The merge filled w of the capacity it reserved: the tail goes
|
||||
// back to the arena before the next merge carves past it.
|
||||
arena.release(out)
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user