Files
2026-07-13 12:22:50 +08:00

476 lines
20 KiB
TypeScript

/**
* Unit tests for the pure functions in `src/__automation__/logSignals.ts`:
* - deriveLogSignals(lines): parses native log lines into a structured payload.
* - deriveEffectiveBackend(signals): maps the payload to a 4-state enum.
*
* Fixtures are modelled on real native-log excerpts from llama.rn's
* `cpp/ggml-opencl/ggml-opencl.cpp` init/load paths. BenchmarkRunnerScreen
* captures the same lines in-process via addNativeLogListener.
*/
import {
deriveEffectiveBackend,
deriveLogSignals,
} from '../../src/__automation__/logSignals';
// -----------------------------------------------------------------------------
// Fixture builders
// -----------------------------------------------------------------------------
/**
* Canonical OpenCL init + full GPU offload (the happy path on S26 Ultra /
* Adreno A8X when a supported quant is selected). 28/28 layers on GPU,
* large-buffer feature enabled, no regressions.
*/
const GPU_FULL_OFFLOAD_LINES = [
'I/lm_ggml_opencl: Initializing OpenCL backend',
'I/lm_ggml_opencl: device Adreno (TM) 830',
'I/lm_ggml_opencl: adreno_gen: A8X',
'I/lm_ggml_opencl: Adreno large buffer enabled',
'I/llama_model_load: load_tensors: offloaded 28/28 layers to GPU',
'I/ggml_backend_opencl: buffer allocated',
];
/**
* CPU-only path: llama.rn never hits the OpenCL init tag at all.
* Captured lines are mostly backend/load tags from the CPU path.
*/
const CPU_ONLY_LINES = [
'I/ggml_backend_cpu: using CPU backend',
'I/llama_model_load: load_tensors: tensors loaded',
];
/**
* Silent-fallback case (the one this infrastructure exists to catch):
* OpenCL init succeeds, the Adreno large buffer feature is requested but
* the driver rejects it, so llama.rn silently reassigns layers back to CPU.
* deriveEffectiveBackend must report `cpu+opencl-partial` even though the
* offloaded count string might still say "28/28".
*/
const LARGE_BUFFER_UNSUPPORTED_LINES = [
'I/lm_ggml_opencl: Initializing OpenCL backend',
'I/lm_ggml_opencl: device Adreno (TM) 830',
'I/lm_ggml_opencl: adreno_gen: A8X',
'W/lm_ggml_opencl: Adreno large buffer requested but not supported by driver',
'I/llama_model_load: load_tensors: offloaded 28/28 layers to GPU',
];
/**
* Partial offload: OpenCL initialized but only some layers landed on GPU
* (e.g. memory pressure pushed the final few back to CPU).
*/
const PARTIAL_OFFLOAD_LINES = [
'I/lm_ggml_opencl: Initializing OpenCL backend',
'I/lm_ggml_opencl: device Adreno (TM) 830',
'I/llama_model_load: load_tensors: offloaded 22/28 layers to GPU',
];
// -----------------------------------------------------------------------------
// deriveLogSignals
// -----------------------------------------------------------------------------
describe('deriveLogSignals', () => {
it('returns all-default signals for an empty input', () => {
const signals = deriveLogSignals([]);
expect(signals).toEqual({
opencl_init: false,
opencl_device_name: null,
adreno_gen: null,
large_buffer_enabled: false,
large_buffer_unsupported: false,
hexagon_init: false,
hexagon_device_name: null,
offloaded_layers: null,
total_layers: null,
memory_buffers: {
weights_mib: {},
weights_total_mib: 0,
kv_cache_mib: {},
kv_cache_total_mib: 0,
compute_mib: {},
compute_total_mib: 0,
total_mib: 0,
},
raw_matches: [],
});
});
it('parses the happy-path GPU init with full offload', () => {
const signals = deriveLogSignals(GPU_FULL_OFFLOAD_LINES);
expect(signals.opencl_init).toBe(true);
expect(signals.opencl_device_name).toBe('Adreno (TM) 830');
expect(signals.adreno_gen).toBe('A8X');
expect(signals.large_buffer_enabled).toBe(true);
expect(signals.large_buffer_unsupported).toBe(false);
expect(signals.offloaded_layers).toBe(28);
expect(signals.total_layers).toBe(28);
});
it('returns opencl_init=false when no init line is present (CPU path)', () => {
const signals = deriveLogSignals(CPU_ONLY_LINES);
expect(signals.opencl_init).toBe(false);
expect(signals.opencl_device_name).toBeNull();
expect(signals.offloaded_layers).toBeNull();
expect(signals.total_layers).toBeNull();
});
it('flags large_buffer_unsupported on the silent-fallback regression', () => {
const signals = deriveLogSignals(LARGE_BUFFER_UNSUPPORTED_LINES);
expect(signals.opencl_init).toBe(true);
expect(signals.large_buffer_unsupported).toBe(true);
// The "enabled" line is NOT present in this case, by construction.
expect(signals.large_buffer_enabled).toBe(false);
});
it('parses llama.rn 0.12.x "using device GPUOpenCL" format (POCO live capture)', () => {
const lines = [
'llama_model_load_from_file_impl: using device GPUOpenCL (QUALCOMM Adreno(TM) 840) (unknown id) - 0 MiB free',
'llama_model_loader: loaded meta data with 45 key-value pairs',
'load_tensors: offloaded 25/25 layers to GPU',
];
const signals = deriveLogSignals(lines);
expect(signals.opencl_init).toBe(true);
expect(signals.opencl_device_name).toBe('QUALCOMM Adreno(TM) 840');
// Generation derived from device-number pattern when no adreno_gen: line
// is logged (8XX → A8X).
expect(signals.adreno_gen).toBe('A8X');
expect(signals.offloaded_layers).toBe(25);
expect(signals.total_layers).toBe(25);
});
it('parses partial-offload layer counts', () => {
const signals = deriveLogSignals(PARTIAL_OFFLOAD_LINES);
expect(signals.opencl_init).toBe(true);
expect(signals.offloaded_layers).toBe(22);
expect(signals.total_layers).toBe(28);
});
it('captures the FIRST device_name when multiple init passes are logged', () => {
const lines = [
'I/lm_ggml_opencl: device Adreno (TM) 830',
'I/lm_ggml_opencl: device Adreno (TM) 740',
];
const signals = deriveLogSignals(lines);
expect(signals.opencl_device_name).toBe('Adreno (TM) 830');
});
it('strips trailing commas from device_name (regex tolerates both anchors)', () => {
const signals = deriveLogSignals([
'I/lm_ggml_opencl: device Adreno (TM) 830, driver v1.2.3',
]);
expect(signals.opencl_device_name).toBe('Adreno (TM) 830');
});
it('caps raw_matches at 200 lines (debug-only, not primary data)', () => {
const lines: string[] = [];
for (let i = 0; i < 250; i++) {
lines.push(`I/lm_ggml_opencl: synthetic line ${i}`);
}
const signals = deriveLogSignals(lines);
expect(signals.raw_matches).toHaveLength(200);
expect(signals.raw_matches[0]).toContain('synthetic line 0');
expect(signals.raw_matches[199]).toContain('synthetic line 199');
});
it('tolerates malformed / unrelated lines interleaved with good data', () => {
const lines = [
'',
'random garbage line without any matching tokens',
'I/lm_ggml_opencl: Initializing OpenCL backend',
'\x00\x01\x02 corrupt binary junk',
'I/llama_model_load: load_tensors: offloaded 16/28 layers to GPU',
'malformed: offloaded XX/YY layers to GPU', // regex demands digits; no match
];
const signals = deriveLogSignals(lines);
expect(signals.opencl_init).toBe(true);
expect(signals.offloaded_layers).toBe(16);
expect(signals.total_layers).toBe(28);
});
it('is case-insensitive for the "requested but not supported" anchor', () => {
const signals = deriveLogSignals([
'W/lm_ggml_opencl: Adreno large buffer REQUESTED BUT NOT SUPPORTED by driver',
]);
expect(signals.large_buffer_unsupported).toBe(true);
});
it('matches the alternate "unsupported" short form', () => {
const signals = deriveLogSignals([
'W/lm_ggml_opencl: Adreno large buffer unsupported on this GPU',
]);
expect(signals.large_buffer_unsupported).toBe(true);
});
});
// -----------------------------------------------------------------------------
// deriveEffectiveBackend
// -----------------------------------------------------------------------------
describe('deriveEffectiveBackend', () => {
it('returns "cpu" when opencl_init is false (no OpenCL init seen)', () => {
expect(deriveEffectiveBackend(deriveLogSignals(CPU_ONLY_LINES))).toBe(
'cpu',
);
});
it('returns "opencl" on the full-offload happy path', () => {
expect(
deriveEffectiveBackend(deriveLogSignals(GPU_FULL_OFFLOAD_LINES)),
).toBe('opencl');
});
it('returns "cpu+opencl-partial" on the silent-fallback regression', () => {
// This is the primary motivation for effective_backend vs requested_backend:
// without this detection, a regression shows up as "opencl" when the user
// asked for GPU but the driver silently reassigned to CPU.
expect(
deriveEffectiveBackend(deriveLogSignals(LARGE_BUFFER_UNSUPPORTED_LINES)),
).toBe('cpu+opencl-partial');
});
it('returns "cpu+opencl-partial" when offloaded < total', () => {
expect(
deriveEffectiveBackend(deriveLogSignals(PARTIAL_OFFLOAD_LINES)),
).toBe('cpu+opencl-partial');
});
it('returns "unknown" when opencl initialized but no layer counts were seen', () => {
// e.g. a truncated logcat tail that missed the load_tensors line.
const signals = deriveLogSignals([
'I/lm_ggml_opencl: Initializing OpenCL backend',
'I/lm_ggml_opencl: device Adreno (TM) 830',
]);
expect(deriveEffectiveBackend(signals)).toBe('unknown');
});
it('prioritises large_buffer_unsupported over matching layer counts', () => {
// Explicit: when the regression flag fires but counts still say 28/28,
// we must trust the flag and report partial — matching the v2.0 resolution
// comment in deriveEffectiveBackend.
const signals = deriveLogSignals([
'I/lm_ggml_opencl: Initializing OpenCL backend',
'W/lm_ggml_opencl: Adreno large buffer requested but not supported',
'I/llama_model_load: load_tensors: offloaded 28/28 layers to GPU',
]);
expect(signals.offloaded_layers).toBe(28);
expect(signals.total_layers).toBe(28);
expect(signals.large_buffer_unsupported).toBe(true);
expect(deriveEffectiveBackend(signals)).toBe('cpu+opencl-partial');
});
it('returns "cpu" when only non-OpenCL ggml backend tags are seen', () => {
// Regression guard: the BENCH_LOG_RE capture filter matches
// ggml_backend_* lines (broad), but that alone must NOT imply opencl.
const signals = deriveLogSignals([
'I/ggml_backend_cpu: CPU backend selected',
'I/ggml_backend_cpu: alloc 512 MB',
]);
expect(signals.opencl_init).toBe(false);
expect(deriveEffectiveBackend(signals)).toBe('cpu');
});
});
// -----------------------------------------------------------------------------
// Hexagon parses + effective-backend arms (WHAT 1d, 6.D, 8 D2)
// -----------------------------------------------------------------------------
/**
* Canonical Hexagon init + full-offload happy path. Three load-bearing
* lines (literals verified against llama.rn 0.12.0-rc.9):
* - registry-allocation marker (sets hexagon_init=true)
* - new session: HTP0 (sets hexagon_device_name)
* - offloaded N/M layers to GPU (backend-agnostic counter — same line
* used for OpenCL; partial-vs-full classification reuses the existing
* offload regex).
*/
const HEXAGON_FULL_OFFLOAD_LINES = [
'ggml-hex: Hexagon backend (experimental) : allocating new registry : ndev 1',
'ggml-hex: Hexagon Arch version v75',
'ggml-hex: new session: HTP0 : default',
// load_tensors entries are the ground truth that the model actually
// ended up on Hexagon — without them the registry alloc alone could
// fire even on a CPU-routed model (see Snapdragon 8 Elite Gen 5).
'load_tensors: CPU model buffer size = 189.42 MiB',
'load_tensors: HTP0-REPACK model buffer size = 980.00 MiB',
'load_tensors: offloaded 28/28 layers to GPU',
];
const HEXAGON_PARTIAL_OFFLOAD_LINES = [
'ggml-hex: Hexagon backend (experimental) : allocating new registry : ndev 1',
'ggml-hex: new session: HTP0 : default',
'load_tensors: CPU model buffer size = 400.00 MiB',
'load_tensors: HTP0-REPACK model buffer size = 600.00 MiB',
'load_tensors: offloaded 22/28 layers to GPU',
];
describe('deriveLogSignals (Hexagon)', () => {
it('parses the Hexagon happy-path init with full offload', () => {
const signals = deriveLogSignals(HEXAGON_FULL_OFFLOAD_LINES);
expect(signals.hexagon_init).toBe(true);
expect(signals.hexagon_device_name).toBe('HTP0');
expect(signals.offloaded_layers).toBe(28);
expect(signals.total_layers).toBe(28);
// Hexagon path must NOT set opencl_init by mistake.
expect(signals.opencl_init).toBe(false);
expect(signals.opencl_device_name).toBeNull();
});
it('captures only the FIRST HTP device when multiple sessions are logged', () => {
// ndev=2 (fused) emits two `new session:` lines in sequence; the
// structured field keeps the first to mirror opencl_device_name's
// first-match-wins semantic. raw_matches still contains both lines.
const signals = deriveLogSignals([
'ggml-hex: Hexagon backend (experimental) : allocating new registry : ndev 2',
'ggml-hex: new session: HTP0 : default',
'ggml-hex: new session: HTP1 : default',
]);
expect(signals.hexagon_init).toBe(true);
expect(signals.hexagon_device_name).toBe('HTP0');
});
it('returns hexagon_init=false on OpenCL-only output (no cross-contamination)', () => {
const signals = deriveLogSignals(GPU_FULL_OFFLOAD_LINES);
expect(signals.hexagon_init).toBe(false);
expect(signals.hexagon_device_name).toBeNull();
});
});
describe('deriveEffectiveBackend (Hexagon)', () => {
it('returns "hexagon" on full Hexagon offload', () => {
expect(
deriveEffectiveBackend(deriveLogSignals(HEXAGON_FULL_OFFLOAD_LINES)),
).toBe('hexagon');
});
it('returns "cpu+hexagon-partial" when offloaded < total under hexagon_init', () => {
expect(
deriveEffectiveBackend(deriveLogSignals(HEXAGON_PARTIAL_OFFLOAD_LINES)),
).toBe('cpu+hexagon-partial');
});
it('returns "unknown" when hexagon_init seen but no layer counts (init aborted before offload line)', () => {
// Symmetric with the OpenCL path's `unknown` fallback (WHAT 8 D2).
const signals = deriveLogSignals([
'ggml-hex: Hexagon backend (experimental) : allocating new registry : ndev 1',
'ggml-hex: new session: HTP0 : default',
]);
expect(deriveEffectiveBackend(signals)).toBe('unknown');
});
it('hexagon takes precedence over opencl when both init lines fire and HTP buffers exist (defense)', () => {
// By construction only one device set is dispatched per cell, so
// both inits firing is hypothetical. memory_buffers ground truth:
// if HTP* keys are present, the model ran on Hexagon regardless of
// which init lines were observed.
const signals = deriveLogSignals([
'I/lm_ggml_opencl: Initializing OpenCL backend',
'ggml-hex: Hexagon backend (experimental) : allocating new registry : ndev 1',
'load_tensors: HTP0-REPACK model buffer size = 980.00 MiB',
'load_tensors: offloaded 28/28 layers to GPU',
]);
expect(signals.opencl_init).toBe(true);
expect(signals.hexagon_init).toBe(true);
expect(deriveEffectiveBackend(signals)).toBe('hexagon');
});
it('returns "cpu" when hexagon_init fires but only CPU weight buffers landed (registry-alloc false-positive)', () => {
// Real-world Snapdragon 8 Elite Gen 5 case: getDeviceOptions()
// enumeration triggers ggml-hex registry allocation (hexagon_init
// becomes true), but devices=['CPU'] keeps the model on CPU. The
// weights_mib ground truth dominates.
const signals = deriveLogSignals([
'ggml-hex: Hexagon backend (experimental) : allocating new registry : ndev 1',
'load_tensors: CPU model buffer size = 1169.07 MiB',
'load_tensors: offloaded 29/29 layers to GPU',
]);
expect(signals.hexagon_init).toBe(true);
expect(deriveEffectiveBackend(signals)).toBe('cpu');
});
});
// -----------------------------------------------------------------------------
// Memory buffers
// -----------------------------------------------------------------------------
describe('deriveLogSignals — memory_buffers', () => {
it('parses CPU-only weights from load_tensors lines', () => {
const signals = deriveLogSignals([
'load_tensors: CPU model buffer size = 1169.07 MiB',
'llama_kv_cache: CPU KV buffer size = 8.50 MiB',
'llama_context: CPU compute buffer size = 296.05 MiB',
]);
expect(signals.memory_buffers.weights_mib).toEqual({CPU: 1169.07});
expect(signals.memory_buffers.kv_cache_mib).toEqual({CPU: 8.5});
expect(signals.memory_buffers.compute_mib).toEqual({CPU: 296.05});
});
it('parses split CPU + OpenCL weights (Adreno path)', () => {
const signals = deriveLogSignals([
'load_tensors: CPU model buffer size = 166.92 MiB',
'load_tensors: OpenCL model buffer size = 1002.15 MiB',
]);
expect(signals.memory_buffers.weights_mib).toEqual({
CPU: 166.92,
OpenCL: 1002.15,
});
});
it('parses Hexagon multi-session split with REPACK overhead', () => {
const signals = deriveLogSignals([
'load_tensors: CPU model buffer size = 189.42 MiB',
'load_tensors: CPU_REPACK model buffer size = 243.43 MiB',
'load_tensors: HTP0 model buffer size = 0.08 MiB',
'load_tensors: HTP0-REPACK model buffer size = 114.75 MiB',
'load_tensors: HTP1 model buffer size = 0.08 MiB',
'load_tensors: HTP1-REPACK model buffer size = 135.00 MiB',
]);
expect(signals.memory_buffers.weights_mib).toEqual({
CPU: 189.42,
CPU_REPACK: 243.43,
HTP0: 0.08,
'HTP0-REPACK': 114.75,
HTP1: 0.08,
'HTP1-REPACK': 135.0,
});
});
it('starts empty when no buffer lines are seen (failure path)', () => {
const signals = deriveLogSignals([
'I/RNLlama: loadModel:240 Using n_parallel: 1',
'load_tensors: offloaded 0/0 layers to GPU',
]);
expect(signals.memory_buffers.weights_mib).toEqual({});
expect(signals.memory_buffers.kv_cache_mib).toEqual({});
expect(signals.memory_buffers.compute_mib).toEqual({});
});
it('last-write-wins on duplicate (kind, device) keys', () => {
// llama.cpp normally prints each buffer once, but if the listener
// window straddles a re-init the second value is what was actually
// loaded for the bench reps.
const signals = deriveLogSignals([
'load_tensors: CPU model buffer size = 100.00 MiB',
'load_tensors: CPU model buffer size = 200.00 MiB',
]);
expect(signals.memory_buffers.weights_mib).toEqual({CPU: 200});
// Total reflects the post-deduplication value, not the sum of all writes.
expect(signals.memory_buffers.weights_total_mib).toBe(200);
});
it('totals equal the sum of their records', () => {
const signals = deriveLogSignals([
'load_tensors: CPU model buffer size = 189.42 MiB',
'load_tensors: CPU_REPACK model buffer size = 243.43 MiB',
'load_tensors: HTP0-REPACK model buffer size = 114.75 MiB',
'load_tensors: HTP1-REPACK model buffer size = 135.00 MiB',
'llama_kv_cache: CPU KV buffer size = 8.50 MiB',
'llama_context: CPU compute buffer size = 296.05 MiB',
]);
expect(signals.memory_buffers.weights_total_mib).toBeCloseTo(682.6, 2);
expect(signals.memory_buffers.kv_cache_total_mib).toBeCloseTo(8.5, 2);
expect(signals.memory_buffers.compute_total_mib).toBeCloseTo(296.05, 2);
expect(signals.memory_buffers.total_mib).toBeCloseTo(987.15, 2);
});
});