voice_audio_multi/tests/unittests/test_backend_device_resolution.cpp
Fabio c36510c21e
Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
Initial import: audio.cpp with Orange Pi documentation
2026-11-04 19:05:22 +01:00

476 lines
20 KiB
C++

#include "engine/framework/core/backend.h"
#include "engine/framework/io/json.h"
#include "engine/framework/debug/profiler.h"
#include "engine/framework/debug/trace.h"
#include "test_assert.h"
#include "ggml-alloc.h"
#include <cstdint>
#include <cstring>
#include <iostream>
#include <memory>
#include <optional>
#include <sstream>
#include <string>
#include <vector>
namespace {
using engine::core::BackendConfig;
using engine::core::BackendType;
using engine::test::require;
using engine::test::require_eq;
struct BackendHandle {
ggml_backend_t handle = nullptr;
explicit BackendHandle(ggml_backend_t backend) : handle(backend) {}
BackendHandle(const BackendHandle &) = delete;
BackendHandle & operator=(const BackendHandle &) = delete;
~BackendHandle() {
if (handle != nullptr) {
ggml_backend_free(handle);
}
}
};
const char * backend_label(BackendType type) {
switch (type) {
case BackendType::Cpu: return "cpu";
case BackendType::Cuda: return "cuda";
case BackendType::Hip: return "hip";
case BackendType::Vulkan: return "vulkan";
case BackendType::Metal: return "metal";
case BackendType::WebGPU: return "webgpu";
case BackendType::BestAvailable: return "best";
}
return "unknown";
}
// Mirrors the registry-name mapping in src/framework/core/backend.cpp. Duplicated on purpose:
// the test pins the contract rather than reusing the implementation's table.
std::optional<BackendType> backend_type_for_reg_name(const char * reg_name) {
if (reg_name == nullptr) {
return std::nullopt;
}
// CUDA's registry name is "CUDA" or "MUSA" depending on the ggml build.
if (std::strcmp(reg_name, "CUDA") == 0 || std::strcmp(reg_name, "MUSA") == 0) {
return BackendType::Cuda;
}
if (std::strcmp(reg_name, "ROCm") == 0) {
return BackendType::Hip;
}
if (std::strcmp(reg_name, "Vulkan") == 0) {
return BackendType::Vulkan;
}
if (std::strcmp(reg_name, "MTL") == 0) {
return BackendType::Metal;
}
if (std::strcmp(reg_name, "WebGPU") == 0) {
return BackendType::WebGPU;
}
return std::nullopt;
}
void ensure_backends_loaded() {
if (ggml_backend_reg_count() == 0) {
ggml_backend_load_all();
}
}
std::string init_backend_error(const BackendConfig & config) {
try {
BackendHandle backend(engine::core::init_backend(config));
return {};
} catch (const std::exception & error) {
return error.what();
}
}
// Every device ggml enumerates for an accelerator registry must be selectable by index, and
// the resulting handle must classify back to the requested backend. This is the regression
// guard for identifying backends by device type (Metal reports GPU, not ACCEL; Vulkan reports
// IGPU on integrated GPUs) or by a single CUDA registry name (ROCm/MUSA builds differ).
void test_every_enumerated_device_resolves() {
ensure_backends_loaded();
size_t checked_registries = 0;
for (size_t i = 0; i < ggml_backend_reg_count(); ++i) {
ggml_backend_reg_t reg = ggml_backend_reg_get(i);
require(reg != nullptr, "registry " + std::to_string(i) + " is null");
const char * reg_name = ggml_backend_reg_name(reg);
const auto type = backend_type_for_reg_name(reg_name);
if (!type.has_value()) {
continue;
}
const size_t dev_count = ggml_backend_reg_dev_count(reg);
const std::string label = std::string(reg_name) + "/" + backend_label(*type);
require(dev_count > 0, label + " registry exposes no devices");
++checked_registries;
for (size_t device = 0; device < dev_count; ++device) {
BackendConfig config;
config.type = *type;
config.device = static_cast<int>(device);
BackendHandle backend(engine::core::init_backend(config));
require(
backend.handle != nullptr,
label + " device " + std::to_string(device) + " returned a null backend");
require_eq(
static_cast<int>(engine::core::backend_type(backend.handle)),
static_cast<int>(*type),
label + " device " + std::to_string(device) + " backend_type round trip");
require(
!engine::core::is_host_backend(backend.handle),
label + " device " + std::to_string(device) + " must not be a host backend");
require(
!engine::core::uses_host_graph_plan(backend.handle),
label + " device " + std::to_string(device) + " must not use a host graph plan");
// Shares the device lookup with init_backend, so it must agree that the device exists.
// Drivers are free to not report memory, but a reported snapshot must be consistent.
const auto memory = engine::core::query_backend_memory(config);
if (memory.available) {
require_eq(
memory.used_bytes,
memory.total_bytes - memory.free_bytes,
label + " device " + std::to_string(device) + " memory snapshot");
}
}
BackendConfig out_of_range;
out_of_range.type = *type;
out_of_range.device = static_cast<int>(dev_count);
const std::string out_of_range_error = init_backend_error(out_of_range);
require(
!out_of_range_error.empty(),
label + " accepted out-of-range device index " + std::to_string(dev_count));
require(
out_of_range_error.find("available:") != std::string::npos,
label + " out-of-range error must list the available devices, got: " + out_of_range_error);
BackendConfig negative;
negative.type = *type;
negative.device = -1;
require(
!init_backend_error(negative).empty(),
label + " accepted a negative device index");
}
std::cout << " checked " << checked_registries << " accelerator registr"
<< (checked_registries == 1 ? "y" : "ies") << '\n';
}
// A backend must never be satisfied by a device from a registry it does not own, so requesting
// one that is not registered in this build has to fail rather than pick up a stray device.
void test_unregistered_backends_are_rejected() {
ensure_backends_loaded();
for (const BackendType type : {
BackendType::Cuda,
BackendType::Hip,
BackendType::Vulkan,
BackendType::Metal,
BackendType::WebGPU,
}) {
bool registered = false;
for (size_t i = 0; i < ggml_backend_reg_count(); ++i) {
ggml_backend_reg_t reg = ggml_backend_reg_get(i);
if (reg == nullptr) {
continue;
}
const auto reg_type = backend_type_for_reg_name(ggml_backend_reg_name(reg));
if (reg_type.has_value() && *reg_type == type) {
registered = true;
break;
}
}
if (registered) {
continue;
}
BackendConfig config;
config.type = type;
const std::string error = init_backend_error(config);
require(
!error.empty(),
std::string(backend_label(type)) + " is not registered but init_backend succeeded");
require(
error.find("not registered in this build") != std::string::npos,
std::string(backend_label(type)) +
" should report that it is not registered, got: " + error);
}
}
// Parsers depend on the text form of --list-devices, so pin it byte for byte against ggml.
void test_text_listing_format_is_stable() {
ensure_backends_loaded();
std::string expected;
size_t count = 0;
for (size_t i = 0; i < ggml_backend_reg_count(); ++i) {
ggml_backend_reg_t reg = ggml_backend_reg_get(i);
for (size_t j = 0; reg != nullptr && j < ggml_backend_reg_dev_count(reg); ++j) {
ggml_backend_dev_t dev = ggml_backend_reg_dev_get(reg, j);
const char * description = ggml_backend_dev_description(dev);
const std::string name = description != nullptr && description[0] != '\0' ? description : ggml_backend_dev_name(dev);
const char * types[] = {"CPU", "GPU", "IGPU", "ACCEL", "META"};
expected += std::string(ggml_backend_reg_name(reg)) + ":" + std::to_string(j) +
(name.empty() ? "" : " \"" + name + "\"") + " [" + types[ggml_backend_dev_type(dev)] + "]\n";
++count;
}
}
expected = "available_devices=" + std::to_string(count) + "\n" + expected +
"select with: --backend <cuda|hip|vulkan|metal|webgpu|cpu> --device <index>\n";
std::ostringstream text;
engine::core::print_backend_devices(text);
require_eq(text.str(), expected, "--list-devices text output");
}
std::optional<BackendType> backend_type_for_option(const std::string & option) {
for (const BackendType type : {BackendType::Cpu, BackendType::Cuda, BackendType::Hip, BackendType::Vulkan, BackendType::Metal, BackendType::WebGPU}) {
if (option == backend_label(type)) {
return type;
}
}
return std::nullopt;
}
// The --backend value for a ggml registry name, spelled out independently of backend.cpp.
std::string expected_backend_option(const std::string & registry) {
if (registry == "CPU") {
return "cpu";
}
if (registry == "CUDA" || registry == "MUSA") {
return "cuda";
}
if (registry == "ROCm") {
return "hip";
}
if (registry == "Vulkan") {
return "vulkan";
}
if (registry == "MTL") {
return "metal";
}
if (registry == "WebGPU") {
return "webgpu";
}
return {};
}
// --list-devices --json lists the text listing's devices in the same order, and each
// "backend"/"index" pair, fed to init_backend as --backend/--device are, opens that device.
void test_json_listing_matches_selection() {
namespace json = engine::io::json;
std::ostringstream text;
engine::core::print_backend_devices_json(text);
const json::Value root = json::parse(text.str());
require_eq(root.require("schema_version").as_i64(), int64_t{1}, "schema_version");
const auto listed = engine::core::list_backend_devices();
const auto & devices = root.require("devices").as_array();
require_eq(devices.size(), listed.size(), "JSON and text list the same number of devices");
for (size_t i = 0; i < devices.size(); ++i) {
const json::Value & device = devices[i];
const std::string label = listed[i].backend + ":" + std::to_string(listed[i].index);
require_eq(device.require("registry").as_string(), listed[i].backend, label + " registry");
require_eq(device.require("index").as_i64(), int64_t{listed[i].index}, label + " index");
ggml_backend_reg_t reg = ggml_backend_reg_by_name(listed[i].backend.c_str());
require(reg != nullptr, label + " registry not found");
ggml_backend_dev_t dev = ggml_backend_reg_dev_get(reg, static_cast<size_t>(listed[i].index));
require_eq(device.require("name").as_string(), std::string(ggml_backend_dev_name(dev)), label + " name");
ggml_backend_dev_props props{};
ggml_backend_dev_get_props(dev, &props);
const json::Value & pci_bus_id = device.require("pci_bus_id");
if (props.device_id == nullptr || props.device_id[0] == '\0') {
require(pci_bus_id.is_null(), label + " pci_bus_id must be null when ggml has none");
} else {
require_eq(pci_bus_id.as_string(), std::string(props.device_id), label + " pci_bus_id");
}
const json::Value & memory = device.require("memory");
if (!memory.is_null()) {
size_t free_bytes = 0;
size_t total_bytes = 0;
ggml_backend_dev_memory(dev, &free_bytes, &total_bytes);
require_eq(memory.require("total_bytes").as_i64(), static_cast<int64_t>(total_bytes), label + " total_bytes");
const int64_t free = memory.require("free_bytes").as_i64();
require(free >= 0 && free <= static_cast<int64_t>(total_bytes), label + " free_bytes out of range");
}
// Every registry --backend can select must name its option; the rest (BLAS, RPC) must be null.
const std::string expected_backend = expected_backend_option(listed[i].backend);
const json::Value & backend = device.require("backend");
if (expected_backend.empty()) {
require(backend.is_null(), label + " backend must be null for a registry --backend cannot select");
continue;
}
require(!backend.is_null(), label + " backend must not be null");
require_eq(backend.as_string(), expected_backend, label + " backend");
if (listed[i].backend == "CPU") {
require(!memory.is_null(), label + " memory must not be null for the CPU");
}
const auto type = backend_type_for_option(backend.as_string());
require(type.has_value(), label + " unknown backend " + backend.as_string());
BackendConfig config;
config.type = *type;
config.device = listed[i].index;
BackendHandle handle(engine::core::init_backend(config));
require(
ggml_backend_get_device(handle.handle) == dev,
label + " --backend " + backend.as_string() + " --device " + std::to_string(config.device) +
" opened a different device");
}
}
// Byte counts must print as exact integers, and unknown values as null.
void test_json_writer_exact_integers_and_nulls() {
namespace json = engine::io::json;
engine::core::BackendDeviceInfo gpu;
gpu.backend = "CUDA";
gpu.index = 1;
gpu.type = "GPU";
gpu.backend_option = "cuda";
gpu.device_name = "CUDA1";
gpu.description = "Card \"A\" \\ B";
gpu.pci_bus_id = "0000:01:00.0";
gpu.memory.available = true;
gpu.memory.free_bytes = 9007199254740993; // 2^53 + 1: not representable as a double
gpu.memory.total_bytes = 9223372036854775807;
gpu.caps.async = true;
engine::core::BackendDeviceInfo blas;
blas.backend = "BLAS";
blas.type = "ACCEL";
blas.device_name = "BLAS";
std::ostringstream text;
engine::core::print_backend_devices_json(text, {gpu, blas});
const std::string out = text.str();
require(
out.find("\"memory\":{\"free_bytes\":9007199254740993,\"total_bytes\":9223372036854775807}") != std::string::npos,
"byte counts must be exact integers, got: " + out);
const json::Value root = json::parse(out);
const auto & devices = root.require("devices").as_array();
require_eq(devices.size(), size_t{2}, "device count");
require_eq(devices[0].require("backend").as_string(), std::string("cuda"), "backend");
require_eq(devices[0].require("description").as_string(), gpu.description, "escaped description");
require_eq(devices[0].require("type").as_string(), std::string("gpu"), "type");
require(devices[0].require("caps").require("async").as_bool(), "caps.async");
require(!devices[0].require("caps").require("events").as_bool(), "caps.events");
for (const char * key : {"backend", "description", "pci_bus_id", "memory"}) {
require(devices[1].require(key).is_null(), std::string("unknown ") + key + " must be null");
}
}
void test_backend_impls() {
for (size_t i = 0; i < ggml_backend_dev_count(); ++i) {
auto dev = ggml_backend_dev_get(i);
const char * const * names = ggml_backend_dev_get_impls(dev);
require(names && names[0] && std::string(names[0]) == "default", "default implementation is discoverable");
BackendHandle original(ggml_backend_dev_init(dev, nullptr));
require(original.handle != nullptr, "original initialization");
require_eq(std::string(ggml_backend_get_impl(original.handle)), std::string("default"), "default identity");
require(ggml_backend_dev_init_with_impl(dev, "invalid-implementation", nullptr) == nullptr,
"unknown implementation must not be ignored");
for (size_t j = 0; names[j]; ++j) {
BackendHandle selected(ggml_backend_dev_init_with_impl(dev, names[j], nullptr));
require(selected.handle != nullptr, "advertised implementation initializes");
require_eq(std::string(ggml_backend_get_impl(selected.handle)), std::string(names[j]), "selected identity");
require_eq(std::string(ggml_backend_get_impl(original.handle)), std::string("default"), "instance isolation");
}
BackendHandle empty(ggml_backend_dev_init_with_impl(dev, "", nullptr));
require(empty.handle != nullptr, "empty implementation initializes the default");
require_eq(std::string(ggml_backend_get_impl(empty.handle)), std::string("default"), "empty identity");
}
}
void test_host_and_best_backends_still_resolve() {
BackendConfig cpu;
cpu.type = BackendType::Cpu;
BackendHandle cpu_backend(engine::core::init_backend(cpu));
require(cpu_backend.handle != nullptr, "cpu backend is null");
require_eq(
static_cast<int>(engine::core::backend_type(cpu_backend.handle)),
static_cast<int>(BackendType::Cpu),
"cpu backend_type round trip");
require(engine::core::is_host_backend(cpu_backend.handle), "cpu backend must be a host backend");
require(
engine::core::uses_host_graph_plan(cpu_backend.handle),
"cpu backend must use a host graph plan");
BackendConfig best;
best.type = BackendType::BestAvailable;
BackendHandle best_backend(engine::core::init_backend(best));
require(best_backend.handle != nullptr, "best backend is null");
}
void test_webgpu_backend_instances_share_a_stable_device() {
auto reg = ggml_backend_reg_by_name("WebGPU");
if (reg == nullptr || ggml_backend_reg_dev_count(reg) == 0) return;
BackendConfig config;
config.type = BackendType::WebGPU;
BackendHandle first(engine::core::init_backend(config));
std::unique_ptr<ggml_context, decltype(&ggml_free)> ctx(
ggml_init({1024 * 1024, nullptr, true}), ggml_free);
require(ctx != nullptr, "WebGPU lifetime graph context allocation");
auto * input = ggml_new_tensor_1d(ctx.get(), GGML_TYPE_F32, 4);
auto * output = ggml_scale(ctx.get(), input, 2.0F);
ggml_set_input(input);
ggml_set_output(output);
auto * graph = ggml_new_graph_custom(ctx.get(), 16, false);
ggml_build_forward_expand(graph, output);
std::unique_ptr<ggml_gallocr, decltype(&ggml_gallocr_free)> alloc(
ggml_gallocr_new(ggml_backend_get_default_buffer_type(first.handle)), ggml_gallocr_free);
require(ggml_gallocr_alloc_graph(alloc.get(), graph), "WebGPU lifetime graph allocation");
const std::vector<float> values{1.0F, 2.0F, 3.0F, 4.0F};
BackendHandle second(nullptr);
for (int phase = 0; phase < 3; ++phase) {
if (phase == 1) second.handle = engine::core::init_backend(config);
if (phase == 2) {
ggml_backend_free(second.handle);
second.handle = nullptr;
}
ggml_backend_tensor_set(input, values.data(), 0, values.size() * sizeof(float));
require(ggml_backend_graph_compute(first.handle, graph) == GGML_STATUS_SUCCESS,
"WebGPU original backend remains executable");
std::vector<float> actual(values.size());
ggml_backend_tensor_get(output, actual.data(), 0, actual.size() * sizeof(float));
for (size_t i = 0; i < actual.size(); ++i) {
require_eq(actual[i], values[i] * 2.0F,
"WebGPU original buffers/pipeline, phase " + std::to_string(phase));
}
}
}
} // namespace
int main() {
engine::debug::configure_logging({true});
const auto started = std::chrono::steady_clock::now();
try {
test_every_enumerated_device_resolves();
test_unregistered_backends_are_rejected();
test_host_and_best_backends_still_resolve();
test_backend_impls();
test_text_listing_format_is_stable();
test_json_listing_matches_selection();
test_json_writer_exact_integers_and_nulls();
test_webgpu_backend_instances_share_a_stable_device();
} catch (const std::exception & error) {
std::cerr << "backend_device_resolution_test failed: " << error.what() << '\n';
return 1;
}
engine::debug::timing_log_scalar("session.wall_ms", engine::debug::elapsed_ms(started));
std::cout << "backend_device_resolution_test passed\n";
return 0;
}