Skip to content
Open
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
2 changes: 2 additions & 0 deletions internal/hal/npu.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ func npuVendorFromDriver(driver string) string {
return "intel"
case strings.HasPrefix(driver, "qcom"):
return "qualcomm"
case strings.HasPrefix(driver, "houmo,"):
return "houmo"
default:
return driver
}
Expand Down
43 changes: 41 additions & 2 deletions internal/hal/npu_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,17 @@ import (

const accelSysfsDir = "/sys/class/accel"

const xh2aDriver = "houmo,xh2a"

func detectNPU() *NPUInfo {
entries, err := os.ReadDir(accelSysfsDir)
if npu := detectAccelNPU(accelSysfsDir); npu != nil {
return npu
}
return detectXH2ANPU("/sys/bus/pci/devices")
}

func detectAccelNPU(sysfsDir string) *NPUInfo {
entries, err := os.ReadDir(sysfsDir)
if err != nil {
return nil
}
Expand All @@ -22,7 +31,7 @@ func detectNPU() *NPUInfo {
if !strings.HasPrefix(entry.Name(), "accel") {
continue
}
devDir := filepath.Join(accelSysfsDir, entry.Name(), "device")
devDir := filepath.Join(sysfsDir, entry.Name(), "device")

uevent, err := os.ReadFile(filepath.Join(devDir, "uevent"))
if err != nil {
Expand Down Expand Up @@ -53,6 +62,36 @@ func detectNPU() *NPUInfo {
return npu
}

// detectXH2ANPU detects Houmo XH2A IPUs. The vendor kernel driver exposes the
// accelerator as a PCI memory controller instead of the generic /sys/class/accel
// interface, so the standard NPU probe cannot see it.
func detectXH2ANPU(pciDevicesDir string) *NPUInfo {
entries, err := os.ReadDir(pciDevicesDir)
if err != nil {
return nil
}

count := 0
for _, entry := range entries {
driverLink := filepath.Join(pciDevicesDir, entry.Name(), "driver")
target, err := os.Readlink(driverLink)
if err != nil || filepath.Base(target) != xh2aDriver {
continue
}
count++
}
if count == 0 {
return nil
}

return &NPUInfo{
Vendor: "houmo",
Name: "Houmo XH2A IPU",
Driver: xh2aDriver,
Count: count,
}
}

func readTrimmedFile(path string) string {
data, err := os.ReadFile(path)
if err != nil {
Expand Down
40 changes: 40 additions & 0 deletions internal/hal/npu_linux_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//go:build linux

package hal

import (
"os"
"path/filepath"
"testing"
)

func TestDetectXH2ANPU(t *testing.T) {
root := t.TempDir()
for name, driver := range map[string]string{
"0000:01:00.0": xh2aDriver,
"0000:02:00.0": xh2aDriver,
"0000:03:00.0": "unrelated-driver",
} {
deviceDir := filepath.Join(root, name)
if err := os.Mkdir(deviceDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join("..", "drivers", driver), filepath.Join(deviceDir, "driver")); err != nil {
t.Fatal(err)
}
}

npu := detectXH2ANPU(root)
if npu == nil {
t.Fatal("expected XH2A NPU")
}
if npu.Vendor != "houmo" || npu.Name != "Houmo XH2A IPU" || npu.Driver != xh2aDriver || npu.Count != 2 {
t.Fatalf("unexpected NPU: %+v", npu)
}
}

func TestDetectXH2ANPUAbsent(t *testing.T) {
if npu := detectXH2ANPU(t.TempDir()); npu != nil {
t.Fatalf("expected no XH2A NPU, got %+v", npu)
}
}
8 changes: 8 additions & 0 deletions internal/onboarding/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@ func buildHardware(ctx context.Context, deps *Deps, hw *hal.HardwareInfo) Hardwa
}}
result.UnifiedMemory = hw.GPU.UnifiedMemory
}
if hw.NPU != nil {
result.NPU = &NPU{
Vendor: hw.NPU.Vendor,
Name: hw.NPU.Name,
Driver: hw.NPU.Driver,
Count: hw.NPU.Count,
}
}

result.CPU = CPU{
Model: hw.CPU.Model,
Expand Down
18 changes: 18 additions & 0 deletions internal/onboarding/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"testing"
"time"

"github.com/jguan/aima/internal/hal"
"github.com/jguan/aima/internal/mcp"
)

Expand Down Expand Up @@ -41,6 +42,23 @@ func TestBuildStatus_NoConfig(t *testing.T) {
}
}

func TestBuildHardwareIncludesNPU(t *testing.T) {
hardware := buildHardware(context.Background(), nil, &hal.HardwareInfo{
NPU: &hal.NPUInfo{
Vendor: "houmo",
Name: "Houmo XH2A IPU",
Driver: "houmo,xh2a",
Count: 1,
},
})
if hardware.NPU == nil {
t.Fatal("expected NPU in onboarding hardware")
}
if hardware.NPU.Name != "Houmo XH2A IPU" || hardware.NPU.Driver != "houmo,xh2a" || hardware.NPU.Count != 1 {
t.Fatalf("unexpected NPU: %+v", hardware.NPU)
}
}

func TestBuildVersion_CachesFailedLookup(t *testing.T) {
orig := FetchLatestRelease
defer func() { FetchLatestRelease = orig }()
Expand Down
9 changes: 9 additions & 0 deletions internal/onboarding/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ type GPU struct {
UnifiedMemory bool `json:"unified_memory,omitempty"`
}

// NPU is a single neural processing unit entry in the onboarding status response.
type NPU struct {
Vendor string `json:"vendor"`
Name string `json:"name"`
Driver string `json:"driver"`
Count int `json:"count"`
}

// CPU describes the host CPU in the onboarding status response.
type CPU struct {
Model string `json:"model"`
Expand All @@ -38,6 +46,7 @@ type CPU struct {
// Hardware aggregates hardware info for the onboarding status response.
type Hardware struct {
GPU []GPU `json:"gpu"`
NPU *NPU `json:"npu,omitempty"`
CPU CPU `json:"cpu"`
RAMMiB int `json:"ram_mib"`
OS string `json:"os"`
Expand Down
72 changes: 68 additions & 4 deletions internal/ui/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4312,6 +4312,11 @@ <h2 x-text="t('wiz_title')"></h2>
<div class="label" x-text="t('wiz_gpu')"></div>
<div class="value" x-text="wizHwGpu()"></div>
</div>
<!-- NPU / IPU -->
<div class="wiz-hw-item" x-show="onboardingData && onboardingData.hardware && onboardingData.hardware.npu">
<div class="label" x-text="t('wiz_npu')"></div>
<div class="value" x-text="wizHwNpu()"></div>
</div>
<!-- VRAM -->
<div class="wiz-hw-item">
<div class="label" x-text="wizHwVramLabel()"></div>
Expand Down Expand Up @@ -4842,7 +4847,25 @@ <h3 x-text="t('wiz_complete_title')"></h3>
</template>
</div>
</template>
<template x-if="!hw.gpu">
<template x-if="hw.npu">
<div class="gauge">
<div class="gauge-label">
<span x-text="hw.npu.countLabel + ' ' + hw.npu.name"></span>
<span x-text="hw.npu.memTotalMiB ? (hw.npu.memUsed + '/' + hw.npu.memTotal) : 'NPU / IPU'"></span>
</div>
<template x-if="hw.npu.memTotalMiB">
<div class="gauge-bar">
<div class="gauge-fill" :class="hw.npu.memPct > 90 ? 'crit' : hw.npu.memPct > 70 ? 'warn' : ''" :style="'width:' + hw.npu.memPct + '%'"></div>
</div>
</template>
<div style="font-size:11px;color:var(--text-dim);margin-top:2px" x-show="hw.npu.temp || hw.npu.util">
<span x-show="hw.npu.util" x-text="hw.npu.util + '%'"></span>
<span x-show="hw.npu.temp" x-text="(hw.npu.util ? ' / ' : '') + hw.npu.temp + '\u00B0C'"></span>
</div>
<div style="font-size:11px;color:var(--text-dim);margin-top:2px" x-text="hw.npu.driver"></div>
</div>
</template>
<template x-if="!hw.gpu && !hw.npu">
<div class="empty" x-text="t('no_gpu')"></div>
</template>
</div>
Expand Down Expand Up @@ -5707,7 +5730,15 @@ <h3 x-text="t('wiz_complete_title')"></h3>
</template>
</div>
</template>
<template x-if="!hw.gpu">
<template x-if="hw.npu">
<div class="gauge">
<div class="gauge-label"><span x-text="hw.npu.countLabel + ' ' + hw.npu.name"></span><span x-text="hw.npu.memTotalMiB ? (hw.npu.memUsed + '/' + hw.npu.memTotal) : 'NPU / IPU'"></span></div>
<template x-if="hw.npu.memTotalMiB"><div class="gauge-bar"><div class="gauge-fill" :class="hw.npu.memPct > 90 ? 'crit' : hw.npu.memPct > 70 ? 'warn' : ''" :style="'width:' + hw.npu.memPct + '%'"></div></div></template>
<div style="font-size:11px;color:var(--text-dim);margin-top:2px" x-show="hw.npu.temp || hw.npu.util"><span x-show="hw.npu.util" x-text="hw.npu.util + '%'"></span><span x-show="hw.npu.temp" x-text="(hw.npu.util ? ' / ' : '') + hw.npu.temp + '\u00B0C'"></span></div>
<div style="font-size:11px;color:var(--text-dim);margin-top:2px" x-text="hw.npu.driver"></div>
</div>
</template>
<template x-if="!hw.gpu && !hw.npu">
<div class="empty" x-text="t('no_gpu')"></div>
</template>
</div>
Expand Down Expand Up @@ -6997,6 +7028,7 @@ <h3 class="onboarding-drawer-title" x-text="onboardingText(((onboardingLocaleDat
wiz_deploying: 'Deploying',
wiz_complete_title: 'Ready to Go!',
wiz_gpu: 'GPU',
wiz_npu: 'NPU / IPU',
wiz_cpu: 'CPU',
wiz_ram: 'Memory',
wiz_os: 'OS / Arch',
Expand Down Expand Up @@ -7417,6 +7449,7 @@ <h3 class="onboarding-drawer-title" x-text="onboardingText(((onboardingLocaleDat
wiz_deploying: '\u90E8\u7F72\u4E2D',
wiz_complete_title: '\u4E00\u5207\u5C31\u7EEA\uFF01',
wiz_gpu: 'GPU',
wiz_npu: 'NPU / IPU',
wiz_cpu: 'CPU',
wiz_ram: '\u5185\u5B58',
wiz_os: '\u7CFB\u7EDF / \u67B6\u6784',
Expand Down Expand Up @@ -8012,6 +8045,7 @@ <h3 class="onboarding-drawer-title" x-text="onboardingText(((onboardingLocaleDat
// Dashboard data
hw: {
gpu: null,
npu: null,
cpu: { pct: 0, model: '', label: 'CPU', coreInfo: '' },
mem: { pct: 0, used: '0', total: '0' }
},
Expand Down Expand Up @@ -8635,8 +8669,8 @@ <h3 class="onboarding-drawer-title" x-text="onboardingText(((onboardingLocaleDat
},

// Hardware data helpers — backend returns:
// hardware.gpu: [{name, vram_mib, count, arch}], hardware.cpu: {model, cores},
// hardware.ram_mib, hardware.os, hardware.arch, hardware.profile_match
// hardware.gpu: [{name, vram_mib, count, arch}], hardware.npu: {name, count, driver},
// hardware.cpu: {model, cores}, hardware.ram_mib, hardware.os, hardware.arch, hardware.profile_match
wizHwGpu() {
if (!this.onboardingData || !this.onboardingData.hardware) return '-';
var gpus = this.onboardingData.hardware.gpu;
Expand All @@ -8647,6 +8681,14 @@ <h3 class="onboarding-drawer-title" x-text="onboardingText(((onboardingLocaleDat
if (this.wizHwUnifiedMemory() && !/\bSoC\b/i.test(txt)) txt += ' SoC';
return txt;
},
wizHwNpu() {
if (!this.onboardingData || !this.onboardingData.hardware) return '-';
var npu = this.onboardingData.hardware.npu;
if (!npu) return '-';
var txt = npu.name || 'NPU';
if (npu.count && npu.count > 1) txt = npu.count + 'x ' + txt;
return txt;
},
wizHwUnifiedMemory() {
if (!this.onboardingData || !this.onboardingData.hardware) return false;
var hw = this.onboardingData.hardware;
Expand Down Expand Up @@ -9473,6 +9515,28 @@ <h3 class="onboarding-drawer-title" x-text="onboardingText(((onboardingLocaleDat
this.hw.gpu = null;
}

if (hw && hw.npu) {
const n = hw.npu;
const nm = (met && met.npu) || {};
const count = n.count || 1;
const totalMiB = nm.memory_total_mib || 0;
const usedMiB = nm.memory_used_mib || 0;
this.hw.npu = {
name: n.name || 'NPU',
driver: n.driver || n.vendor || '',
count: count,
countLabel: count > 1 ? (count + '\u00D7') : '',
util: nm.utilization_percent || 0,
memUsed: this.fmtMiB(usedMiB),
memTotal: this.fmtMiB(totalMiB),
memTotalMiB: totalMiB,
memPct: totalMiB > 0 ? Math.round(usedMiB / totalMiB * 100) : 0,
temp: nm.temperature_celsius || 0
};
} else {
this.hw.npu = null;
}

if (hw && hw.cpu) {
const c = hw.cpu;
this.hw.cpu.model = c.model || '';
Expand Down
Loading