-
This commit is contained in:
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user