This commit is contained in:
2026-09-11 06:14:38 +02:00
parent bf64652300
commit e581949946
161 changed files with 31126 additions and 1 deletions
+15
View File
@@ -0,0 +1,15 @@
package hoststats
import "context"
// AMD contains lightweight AMDGPU telemetry. Linux reads the standard amdgpu
// sysfs counters and therefore needs no ROCm library, daemon or CGO.
type AMD struct {
UtilizationPercent float64
MemoryUsedBytes int64
MemoryTotalBytes int64
TemperatureC float64
PowerWatts float64
}
func ReadAMD(ctx context.Context, device string) (AMD, error) { return readAMD(ctx, device) }
+87
View File
@@ -0,0 +1,87 @@
//go:build linux
package hoststats
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
func readAMD(ctx context.Context, device string) (AMD, error) {
if err := ctx.Err(); err != nil {
return AMD{}, err
}
if strings.TrimSpace(device) == "" {
var err error
device, err = findAMDDevice("/sys/class/drm")
if err != nil {
return AMD{}, err
}
}
return readAMDDevice(ctx, device)
}
func findAMDDevice(root string) (string, error) {
cards, err := filepath.Glob(filepath.Join(root, "card*", "device"))
if err != nil {
return "", err
}
for _, device := range cards {
b, err := os.ReadFile(filepath.Join(device, "vendor"))
if err != nil {
continue
}
if strings.EqualFold(strings.TrimSpace(string(b)), "0x1002") {
return device, nil
}
}
return "", fmt.Errorf("no AMDGPU sysfs device found")
}
func readAMDDevice(ctx context.Context, device string) (AMD, error) {
if err := ctx.Err(); err != nil {
return AMD{}, err
}
var out AMD
var found bool
if v, ok := readInt64(filepath.Join(device, "mem_info_vram_total")); ok {
out.MemoryTotalBytes = v
found = true
}
if v, ok := readInt64(filepath.Join(device, "mem_info_vram_used")); ok {
out.MemoryUsedBytes = v
found = true
}
if v, ok := readInt64(filepath.Join(device, "gpu_busy_percent")); ok {
out.UtilizationPercent = float64(v)
found = true
}
hwmons, _ := filepath.Glob(filepath.Join(device, "hwmon", "hwmon*"))
for _, hw := range hwmons {
if v, ok := readInt64(filepath.Join(hw, "temp1_input")); ok && out.TemperatureC == 0 {
out.TemperatureC = float64(v) / 1000
found = true
}
if v, ok := readInt64(filepath.Join(hw, "power1_average")); ok && out.PowerWatts == 0 {
out.PowerWatts = float64(v) / 1_000_000
found = true
}
}
if !found {
return AMD{}, fmt.Errorf("no readable AMDGPU telemetry in %s", device)
}
return out, nil
}
func readInt64(path string) (int64, bool) {
b, err := os.ReadFile(path)
if err != nil {
return 0, false
}
v, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
return v, err == nil
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !linux
package hoststats
import (
"context"
"fmt"
)
func readAMD(context.Context, string) (AMD, error) {
return AMD{}, fmt.Errorf("AMDGPU sysfs telemetry is supported only on Linux")
}
+51
View File
@@ -0,0 +1,51 @@
//go:build linux
package hoststats
import (
"context"
"os"
"path/filepath"
"testing"
)
func writeStat(t *testing.T, path, value string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(value), 0644); err != nil {
t.Fatal(err)
}
}
func TestReadAMDDevice(t *testing.T) {
device := filepath.Join(t.TempDir(), "device")
writeStat(t, filepath.Join(device, "mem_info_vram_total"), "17179869184\n")
writeStat(t, filepath.Join(device, "mem_info_vram_used"), "4294967296\n")
writeStat(t, filepath.Join(device, "gpu_busy_percent"), "73\n")
writeStat(t, filepath.Join(device, "hwmon", "hwmon0", "temp1_input"), "62500\n")
writeStat(t, filepath.Join(device, "hwmon", "hwmon0", "power1_average"), "88000000\n")
got, err := readAMDDevice(context.Background(), device)
if err != nil {
t.Fatal(err)
}
if got.MemoryTotalBytes != 17179869184 || got.MemoryUsedBytes != 4294967296 || got.UtilizationPercent != 73 || got.TemperatureC != 62.5 || got.PowerWatts != 88 {
t.Fatalf("unexpected AMD telemetry: %+v", got)
}
}
func TestFindAMDDevice(t *testing.T) {
root := t.TempDir()
writeStat(t, filepath.Join(root, "card0", "device", "vendor"), "0x10de\n")
writeStat(t, filepath.Join(root, "card1", "device", "vendor"), "0x1002\n")
got, err := findAMDDevice(root)
if err != nil {
t.Fatal(err)
}
want := filepath.Join(root, "card1", "device")
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
+10
View File
@@ -0,0 +1,10 @@
package hoststats
import "context"
type Memory struct {
TotalBytes int64
UsedBytes int64
}
func ReadMemory(ctx context.Context) (Memory, error) { return readMemory(ctx) }
+66
View File
@@ -0,0 +1,66 @@
//go:build darwin
package hoststats
import (
"bufio"
"context"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
)
var pageSizeRE = regexp.MustCompile(`page size of ([0-9]+) bytes`)
func readMemory(ctx context.Context) (Memory, error) {
totalOut, err := exec.CommandContext(ctx, "/usr/sbin/sysctl", "-n", "hw.memsize").Output()
if err != nil {
return Memory{}, err
}
total, err := strconv.ParseInt(strings.TrimSpace(string(totalOut)), 10, 64)
if err != nil || total <= 0 {
return Memory{}, fmt.Errorf("invalid hw.memsize")
}
out, err := exec.CommandContext(ctx, "/usr/bin/vm_stat").Output()
if err != nil {
return Memory{}, err
}
pageSize := int64(4096)
var freePages int64
s := bufio.NewScanner(strings.NewReader(string(out)))
first := true
for s.Scan() {
line := s.Text()
if first {
first = false
if m := pageSizeRE.FindStringSubmatch(line); len(m) == 2 {
if v, e := strconv.ParseInt(m[1], 10, 64); e == nil {
pageSize = v
}
}
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
name := strings.TrimSpace(parts[0])
v := strings.TrimSpace(strings.TrimSuffix(parts[1], "."))
n, _ := strconv.ParseInt(v, 10, 64)
switch name {
case "Pages free", "Pages inactive", "Pages speculative":
freePages += n
}
}
available := freePages * pageSize
used := total - available
if used < 0 {
used = 0
}
if used > total {
used = total
}
return Memory{TotalBytes: total, UsedBytes: used}, nil
}
+46
View File
@@ -0,0 +1,46 @@
//go:build linux
package hoststats
import (
"bufio"
"context"
"fmt"
"os"
"strconv"
"strings"
)
func readMemory(context.Context) (Memory, error) {
f, err := os.Open("/proc/meminfo")
if err != nil {
return Memory{}, err
}
defer f.Close()
var total, available int64
s := bufio.NewScanner(f)
for s.Scan() {
fields := strings.Fields(s.Text())
if len(fields) < 2 {
continue
}
v, _ := strconv.ParseInt(fields[1], 10, 64)
switch strings.TrimSuffix(fields[0], ":") {
case "MemTotal":
total = v * 1024
case "MemAvailable":
available = v * 1024
}
}
if err := s.Err(); err != nil {
return Memory{}, err
}
if total <= 0 {
return Memory{}, fmt.Errorf("MemTotal unavailable")
}
used := total - available
if used < 0 {
used = 0
}
return Memory{TotalBytes: total, UsedBytes: used}, nil
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !linux && !darwin && !windows
package hoststats
import (
"context"
"fmt"
)
func readMemory(context.Context) (Memory, error) {
return Memory{}, fmt.Errorf("host memory telemetry unsupported on this OS")
}
+41
View File
@@ -0,0 +1,41 @@
//go:build windows
package hoststats
import (
"context"
"fmt"
"syscall"
"unsafe"
)
type memoryStatusEx struct {
Length uint32
MemoryLoad uint32
TotalPhys uint64
AvailPhys uint64
TotalPageFile uint64
AvailPageFile uint64
TotalVirtual uint64
AvailVirtual uint64
AvailExtendedVirtual uint64
}
func readMemory(ctx context.Context) (Memory, error) {
if err := ctx.Err(); err != nil {
return Memory{}, err
}
var st memoryStatusEx
st.Length = uint32(unsafe.Sizeof(st))
kernel32 := syscall.NewLazyDLL("kernel32.dll")
proc := kernel32.NewProc("GlobalMemoryStatusEx")
r1, _, callErr := proc.Call(uintptr(unsafe.Pointer(&st)))
if r1 == 0 {
if callErr != syscall.Errno(0) {
return Memory{}, fmt.Errorf("GlobalMemoryStatusEx: %w", callErr)
}
return Memory{}, fmt.Errorf("GlobalMemoryStatusEx failed")
}
used := int64(st.TotalPhys - st.AvailPhys)
return Memory{TotalBytes: int64(st.TotalPhys), UsedBytes: used}, nil
}
+69
View File
@@ -0,0 +1,69 @@
package hoststats
import (
"context"
"encoding/csv"
"fmt"
"os/exec"
"strconv"
"strings"
)
// NVIDIA contains lightweight local GPU telemetry obtained from nvidia-smi.
// Values are intentionally limited to stable fields supported by the selective
// --query-gpu interface; the gateway does not require NVML or CGO.
type NVIDIA struct {
UtilizationPercent float64
MemoryUsedBytes int64
MemoryTotalBytes int64
TemperatureC float64
PowerWatts float64
}
func ReadNVIDIA(ctx context.Context, gpu string) (NVIDIA, error) {
args := []string{"--query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw", "--format=csv,noheader,nounits"}
if strings.TrimSpace(gpu) != "" {
args = append(args, "-i", strings.TrimSpace(gpu))
}
out, err := exec.CommandContext(ctx, "nvidia-smi", args...).Output()
if err != nil {
if ctx.Err() != nil {
return NVIDIA{}, ctx.Err()
}
return NVIDIA{}, fmt.Errorf("nvidia-smi: %w", err)
}
r := csv.NewReader(strings.NewReader(strings.TrimSpace(string(out))))
rec, err := r.Read()
if err != nil || len(rec) < 5 {
return NVIDIA{}, fmt.Errorf("nvidia-smi returned an unexpected row")
}
parse := func(s string) (float64, error) {
s = strings.TrimSpace(s)
if s == "" || strings.EqualFold(s, "N/A") || strings.EqualFold(s, "[Not Supported]") {
return 0, nil
}
return strconv.ParseFloat(s, 64)
}
util, err := parse(rec[0])
if err != nil {
return NVIDIA{}, fmt.Errorf("parse gpu utilization: %w", err)
}
usedMiB, err := parse(rec[1])
if err != nil {
return NVIDIA{}, fmt.Errorf("parse gpu memory used: %w", err)
}
totalMiB, err := parse(rec[2])
if err != nil {
return NVIDIA{}, fmt.Errorf("parse gpu memory total: %w", err)
}
temp, err := parse(rec[3])
if err != nil {
return NVIDIA{}, fmt.Errorf("parse gpu temperature: %w", err)
}
power, err := parse(rec[4])
if err != nil {
return NVIDIA{}, fmt.Errorf("parse gpu power: %w", err)
}
const mib = int64(1024 * 1024)
return NVIDIA{UtilizationPercent: util, MemoryUsedBytes: int64(usedMiB * float64(mib)), MemoryTotalBytes: int64(totalMiB * float64(mib)), TemperatureC: temp, PowerWatts: power}, nil
}
+12
View File
@@ -0,0 +1,12 @@
package hoststats
import "testing"
// Parsing is exercised indirectly through the command-backed function in
// integration environments with nvidia-smi. Keep this package dependency-free.
func TestNVIDIAStruct(t *testing.T) {
n := NVIDIA{UtilizationPercent: 50, MemoryUsedBytes: 1, MemoryTotalBytes: 2}
if n.MemoryTotalBytes != 2 {
t.Fatal("unexpected struct value")
}
}