Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
824f941
npu-pack: read a component's per-layer embedding table, beside its to…
Nov 10, 2026
e0001a2
npu-pack: write a component's per-layer embedding into the container …
Nov 10, 2026
7f7dada
npu-pack: pin the per-layer embedding read, with the fixture mistakes…
Nov 10, 2026
31e283d
npu-pack/forward: the per-layer gather the decoder will call, pinned …
Nov 10, 2026
b17aea5
npu-pack: dequantise the per-layer CONTEXT projection, and split ok()…
Nov 10, 2026
83c323b
npu-pack: write the per-layer PLE tensors under the names the forward…
Nov 10, 2026
fbe8861
forward: read the per-layer token table as raw BF16 bands (engine#1725)
Nov 10, 2026
6514cb1
npu-pack: retire the table-times-norm primitive -- it is not this mod…
Nov 10, 2026
f026d68
npu-pack: tell a variable-stride 1.9 fconst from "not a 1.9 fconst" (…
Nov 10, 2026
3814c1d
forward: decide the per-layer table's layout by its DECLARED dtype, n…
Nov 10, 2026
111ba73
npu-pack: a container that carries the PLE tensors now also says how …
Nov 10, 2026
b5bc2c6
npu: the Whisper front-end four f32 host kernels, known-answer tested…
Nov 10, 2026
f23723f
npu: read an audio pack graph whole -- and the two facts that change …
Nov 10, 2026
4bc1d6e
npu: run an audio pack encoder on the host, verified against onnxrunt…
Nov 10, 2026
28a6c62
npu: run the Whisper decoder too -- 1233 nodes, both graphs, verified…
Nov 10, 2026
351123f
npu: the log-mel front-end, checked against the reference extractor (…
Nov 10, 2026
324d57d
npu: whisper_transcribe -- wav to token ids on the host, matching the…
Nov 10, 2026
56c35aa
npu: whisper_transcribe reports where the host time goes (engine#1931)
Nov 10, 2026
61426f5
npu: split independent MatMul rows across threads, bit-identical, mea…
Nov 10, 2026
2d63df9
build: `--target 1bit` builds the CLI instead of finding a stale file…
Nov 10, 2026
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
28 changes: 27 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ file(WRITE ${CMAKE_BINARY_DIR}/generated/hrx_archs.h
"static const char* const kHrxGgufArchs[] = {\n${_onebit_archs}};\n")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/registry/architectures.json)
set_target_properties(onebit PROPERTIES OUTPUT_NAME 1bit)
# engine#1993: the CLI's *name* is a build target too. `cmake --build <dir> --target 1bit` otherwise finds the 1bit file
# already in the build tree, treats it as an up-to-date file target and exits 0 having built nothing -- so a binary from an
# older checkout looks like a fresh build. With this target both spellings build the CLI.
add_custom_target(1bit DEPENDS onebit)
target_link_libraries(onebit PRIVATE nlohmann_json::nlohmann_json httplib::httplib Threads::Threads)

# ── The chat-template renderer (engine #963, docs/npu-chat-template.md) ──────
Expand Down Expand Up @@ -566,7 +570,7 @@ if(ONEBIT_NPU_HOST)
find_package(PkgConfig REQUIRED)
pkg_check_modules(PCRE2 REQUIRED IMPORTED_TARGET libpcre2-8)
add_library(onebit_npu STATIC npu/file_map.cpp npu/flm_head_import.cpp npu/full_elf.cpp npu/model.cpp
npu/moe.cpp npu/pack.cpp npu/pack_import.cpp npu/private_route.cpp
npu/audio_exec.cpp npu/moe.cpp npu/pack.cpp npu/pack_import.cpp npu/private_route.cpp
npu/q4nx.cpp npu/q4nx_device.cpp npu/tokenizer.cpp)
target_include_directories(onebit_npu PUBLIC npu)
target_link_libraries(onebit_npu PUBLIC nlohmann_json::nlohmann_json PRIVATE PkgConfig::PCRE2)
Expand Down Expand Up @@ -1076,6 +1080,21 @@ if(ONEBIT_OGA_PACK)
COMMAND npu_pack_import_test ${CMAKE_SOURCE_DIR}/tests/golden ${ONEBIT_OGA_PACK})
endif()
add_executable(npu_pack_test tests/npu_pack_test.cpp)
# The Whisper encoder front-end's four f32 host kernels: no container, no device, so it runs wherever this file is built
# (the same reason #1830/#1863 put their arms in a binary CI already compiles). Values come from the encoder reference.
add_executable(audio_ops_test tests/audio_ops_test.cpp)
target_include_directories(audio_ops_test PRIVATE npu/forward/include)
# The audio pack's graph, read through the engine: pins the topological-order assumption the executor needs and the op
# census the host reference was written against. Skips (77) without a path.
add_executable(onnx_graph_test tests/onnx_graph_test.cpp)
target_link_libraries(onnx_graph_test PRIVATE onebit_npu)
add_executable(whisper_transcribe tools/whisper_transcribe.cpp)
target_include_directories(whisper_transcribe PRIVATE npu/forward/include)
target_link_libraries(whisper_transcribe PRIVATE onebit_npu)
add_executable(audio_mel_test tests/audio_mel_test.cpp)
target_include_directories(audio_mel_test PRIVATE npu/forward/include)
add_executable(audio_exec_test tests/audio_exec_test.cpp)
target_link_libraries(audio_exec_test PRIVATE onebit_npu)
add_executable(flm_head_import tools/flm_head_import.cpp)
target_link_libraries(flm_head_import PRIVATE onebit_npu)
add_executable(npu_flm_head_import_test tests/npu_flm_head_import_test.cpp)
Expand Down Expand Up @@ -1299,6 +1318,13 @@ endif()
if(ONEBIT_NPU_MODEL AND ONEBIT_NPU_BO_DUMP)
add_test(NAME npu_pack_model COMMAND npu_pack_test ${ONEBIT_NPU_MODEL} ${ONEBIT_NPU_BO_DUMP})
endif()
add_test(NAME audio_ops COMMAND audio_ops_test)
add_test(NAME audio_mel COMMAND audio_mel_test)
set_tests_properties(audio_ops PROPERTIES SKIP_RETURN_CODE 77)
add_test(NAME onnx_graph COMMAND onnx_graph_test ${ONEBIT_AUDIO_ONNX})
add_test(NAME audio_exec COMMAND audio_exec_test ${ONEBIT_AUDIO_ONNX})
set_tests_properties(audio_exec PROPERTIES SKIP_RETURN_CODE 77)
set_tests_properties(onnx_graph PROPERTIES SKIP_RETURN_CODE 77)
# The private NPU route registry (npu/private_route.h), without an add-on.
add_executable(npu_private_route_test tests/npu_private_route_test.cpp)
target_link_libraries(npu_private_route_test PRIVATE onebit_npu)
Expand Down
602 changes: 602 additions & 0 deletions npu/audio_exec.cpp

Large diffs are not rendered by default.

43 changes: 43 additions & 0 deletions npu/audio_exec.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright 2026 bong-water-water-bong
// SPDX-License-Identifier: Apache-2.0
//
// audio_exec.h -- run an audio pack's graph on the host in f32. Not a general ONNX runtime: it covers exactly the ops a
// Whisper encoder uses (measured on both exports -- 447 nodes / 11 op types for whisper-small, 1031 / 10 for large-v3), and
// it refuses an op it does not implement *by name* rather than producing a plausible activation from a silently skipped node.
//
// The four f32 kernels this stands on are in npu/forward/include/audio_ops.h, which is device-free and known-answer tested;
// this file adds the shape bookkeeping and the wiring. The caller compares its output against the reference, and against
// onnxruntime, before anything here is used to argue a device number.

#pragma once

#include <string>
#include <vector>

#include "onnx_graph.h"

namespace onebit::npu::pack_import {

struct AudioRunResult {
std::vector<int64_t> dims; // dims of the requested output
std::vector<float> data; // row-major f32 payload
std::vector<int64_t> i64; // int64 payload, when the requested tensor is an index/token tensor
std::vector<uint8_t> b; // bool payload, when it is a mask
};

// Two entry points, because a Whisper pack has two graphs: the encoder consumes the mel window as f32, the decoder consumes
// a token window as int64 plus the encoder's output. Everything else -- node order, weights, refusals -- is shared.
//
// mel: (mel_bins, frames) row-major f32, the encoder's graph input in the layout its own first Conv consumes.
// out_name: the graph output to return (empty = the graph's declared output).
// Returns true and fills `out`; on failure returns false and sets `why` to the node and the reason.
bool run_audio_graph(const OnnxGraphInfo& g, const float* mel, int64_t mel_bins, int64_t frames,
const std::string& out_name, AudioRunResult* out, std::string* why);

// The decoder: `tokens` is the int64 token window for the graph's first input; `xa` (the encoder output, row-major f32) is
// bound to the graph's second input when the graph has one. `xa_dims` may be empty when there is no cross-attention input.
bool run_decoder_graph(const OnnxGraphInfo& g, const int64_t* tokens, int64_t n_tokens, const float* xa,
const std::vector<int64_t>& xa_dims, const std::string& out_name, AudioRunResult* out,
std::string* why);

} // namespace onebit::npu::pack_import
126 changes: 126 additions & 0 deletions npu/forward/include/audio_mel.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright 2026 bong-water-water-bong
// SPDX-License-Identifier: Apache-2.0
//
// audio_mel.h -- the log-mel front-end: hann-windowed STFT (n_fft 400, hop 160, reflect padding), power spectrum, a
// slaney-normalised mel filterbank, then log10 and the (max - 8, +4)/4 scaling a Whisper encoder's input expects.
//
// This is the one part of the pipeline that is *not* in the pack: the pack's graph starts at the mel window, so the numbers
// here have to match the reference extractor's, and tests/audio_mel_test.cpp checks them against it.
//
// The filterbank is built, not shipped: librosa's slaney mel scale (f_sp = 200/3 Hz, linear below 1 kHz, log above,
// logstep = ln(6.4)/27) with slaney normalisation, which is what Whisper's feature extractor uses.

#pragma once

#include <cmath>
#include <cstddef>
#include <vector>

namespace onebit::audio {

inline double hz_to_mel(double hz) {
const double f_sp = 200.0 / 3.0;
const double min_log_hz = 1000.0, min_log_mel = min_log_hz / f_sp, logstep = std::log(6.4) / 27.0;
if (hz < min_log_hz) return hz / f_sp;
return min_log_mel + std::log(hz / min_log_hz) / logstep;
}

inline double mel_to_hz(double mel) {
const double f_sp = 200.0 / 3.0;
const double min_log_hz = 1000.0, min_log_mel = min_log_hz / f_sp, logstep = std::log(6.4) / 27.0;
if (mel < min_log_mel) return mel * f_sp;
return min_log_hz * std::exp(logstep * (mel - min_log_mel));
}

// (bins, 1 + n_fft/2): triangular filters, slaney-normalised, spanning 0..nyquist.
inline std::vector<float> mel_filterbank(int bins, int n_fft, int sample_rate) {
const int nfreqs = n_fft / 2 + 1;
const double nyq = sample_rate / 2.0;
const double mlo = hz_to_mel(0.0), mhi = hz_to_mel(nyq);
std::vector<double> f(bins + 2);
for (int i = 0; i < bins + 2; ++i) f[i] = mel_to_hz(mlo + (mhi - mlo) * double(i) / double(bins + 1));
// FFT bin centres in Hz, and the per-filter bandwidth normalisation.
std::vector<double> hz(nfreqs);
for (int k = 0; k < nfreqs; ++k) hz[k] = double(k) * nyq / double(nfreqs - 1);
std::vector<double> enorm(bins);
for (int i = 0; i < bins; ++i) {
const double w = f[i + 2] - f[i];
enorm[i] = w > 0 ? 2.0 / w : 0.0;
}
std::vector<float> m(size_t(bins) * size_t(nfreqs), 0.0f);
for (int i = 0; i < bins; ++i) {
const double lo = f[i], ctr = f[i + 1], hi = f[i + 2];
for (int k = 0; k < nfreqs; ++k) {
const double x = hz[k];
double v = 0.0;
if (x >= lo && x <= ctr && ctr > lo) v = (x - lo) / (ctr - lo);
else if (x > ctr && x <= hi && hi > ctr) v = (hi - x) / (hi - ctr);
m[size_t(i) * nfreqs + k] = float(v * enorm[i]);
}
}
return m;
}

struct MelConfig {
int sample_rate = 16000;
int n_fft = 400;
int hop = 160;
int bins = 80;
int frames = 3000; // the encoder's window: 30 s
};

// audio: `samples` mono f32 in [-1,1]. out: (bins, frames) row-major. Returns false only on a size the config cannot fill.
inline bool log_mel_spectrogram(const float* audio, size_t samples, const MelConfig& cfg, std::vector<float>* out) {
const int pad = cfg.n_fft / 2;
const int nfreqs = cfg.n_fft / 2 + 1;
if (samples + 2 * size_t(pad) < size_t(cfg.n_fft)) return false;

// reflect padding, both sides (torch's center=True default for stft)
std::vector<double> x(samples + 2 * size_t(pad));
for (int i = 0; i < pad; ++i) x[size_t(i)] = audio[pad - i];
for (size_t i = 0; i < samples; ++i) x[size_t(pad) + i] = audio[i];
for (int i = 0; i < pad; ++i) x[size_t(pad) + samples + size_t(i)] = audio[samples - 2 - size_t(i)];

std::vector<double> win(cfg.n_fft);
for (int i = 0; i < cfg.n_fft; ++i) // periodic hann, as torch.hann_window(w) is by default
win[size_t(i)] = 0.5 - 0.5 * std::cos(2.0 * M_PI * double(i) / double(cfg.n_fft));

const std::vector<float> fb = mel_filterbank(cfg.bins, cfg.n_fft, cfg.sample_rate);
out->assign(size_t(cfg.bins) * size_t(cfg.frames), 0.0f);
std::vector<double> re(nfreqs), im(nfreqs), power(nfreqs);
for (int t = 0; t < cfg.frames; ++t) {
const size_t base = size_t(t) * size_t(cfg.hop);
for (int k = 0; k < nfreqs; ++k) {
double sr = 0.0, si = 0.0;
for (int n = 0; n < cfg.n_fft; ++n) {
const double v = x[base + size_t(n)] * win[size_t(n)];
const double ang = -2.0 * M_PI * double(k) * double(n) / double(cfg.n_fft);
sr += v * std::cos(ang);
si += v * std::sin(ang);
}
re[size_t(k)] = sr;
im[size_t(k)] = si;
power[size_t(k)] = sr * sr + si * si;
}
for (int b = 0; b < cfg.bins; ++b) {
const float* row = fb.data() + size_t(b) * nfreqs;
double acc = 0.0;
for (int k = 0; k < nfreqs; ++k) acc += double(row[k]) * power[size_t(k)];
out->at(size_t(b) * size_t(cfg.frames) + size_t(t)) = float(acc);
}
}
// log10 with a floor, then Whisper's dynamic-range clamp and scale.
double mx = -1e30;
for (float& v : *out) {
v = float(std::log10(std::max(double(v), 1e-10)));
if (v > mx) mx = v;
}
const double floor_db = mx - 8.0;
for (float& v : *out) {
const double c = std::max(double(v), floor_db);
v = float((c + 4.0) / 4.0);
}
return true;
}

} // namespace onebit::audio
86 changes: 86 additions & 0 deletions npu/forward/include/audio_ops.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright 2026 bong-water-water-bong
// SPDX-License-Identifier: Apache-2.0
//
// audio_ops.h -- the four f32 host kernels a Whisper encoder front-end is made of: a temporal conv with stride and zero
// padding, layer norm, erf-GELU and a row softmax. Header-only, no dependencies.
//
// Why these and not a graph executor: the encoder's arithmetic that is easy to get subtly wrong is here -- the mel
// front-end is two strided 3-tap convs ([1280,128,3] stride 1 then [1280,1280,3] stride 2, both pad 1), and the GELU is
// the *exact* erf form, which numpy does not have (the reference builds it with A&S 7.1.26, |err| < 1.5e-7). Every number
// in tests/audio_ops_test.cpp was computed in double from the same formulas the reference uses, so a change here that
// moves a value is visible before it reaches the device.

#pragma once

#include <cmath>
#include <cstddef>

namespace onebit::audio {

// (T + 2*pad - k) / stride + 1, floor -- the ONNX Conv default for a valid (non-SAME) convolution.
inline int conv1d_out_len(int T, int k, int stride, int pad) {
const int span = T + 2 * pad - k;
return span < 0 ? 0 : span / stride + 1;
}

// x: (cin, T) row-major. w: (cout, cin, k) row-major -- the layout the ONNX initializer carries, so a caller reading
// weights through the pack's own loader needs no transpose. b: (cout,) or null. out: (cout, tout) row-major.
inline void conv1d(const float* x, int cin, int T, const float* w, const float* b, int cout, int k, int stride, int pad,
float* out) {
const int tout = conv1d_out_len(T, k, stride, pad);
for (int co = 0; co < cout; ++co) {
for (int t = 0; t < tout; ++t) {
float acc = (b != nullptr) ? b[co] : 0.0f;
for (int ci = 0; ci < cin; ++ci) {
const float* xr = x + static_cast<std::size_t>(ci) * T;
const float* wr = w + (static_cast<std::size_t>(co) * cin + ci) * k;
for (int j = 0; j < k; ++j) {
const int src = t * stride + j - pad;
if (src >= 0 && src < T) acc += wr[j] * xr[src];
}
}
out[static_cast<std::size_t>(co) * tout + t] = acc;
}
}
}

// y = (x - mean) / sqrt(var + eps) * gamma + beta, biased variance (divide by n), one row.
inline void layer_norm(const float* x, const float* gamma, const float* beta, int n, float eps, float* out) {
double sum = 0.0;
for (int i = 0; i < n; ++i) sum += x[i];
const double mean = sum / n;
double var = 0.0;
for (int i = 0; i < n; ++i) {
const double d = x[i] - mean;
var += d * d;
}
var /= n;
const double inv = 1.0 / std::sqrt(var + eps);
for (int i = 0; i < n; ++i) {
const float g = (gamma != nullptr) ? gamma[i] : 1.0f;
const float b = (beta != nullptr) ? beta[i] : 0.0f;
out[i] = static_cast<float>((x[i] - mean) * inv) * g + b;
}
}

// The exact form: 0.5*x*(1+erf(x/sqrt(2))). `approximate=none` in the export, so tanh is not a drop-in.
inline float gelu_erf(float x) {
return 0.5f * x * (1.0f + std::erf(x * 0.70710678118654752f));
}

// In place, one row: numerically stable (subtract the row max) so a logit of 100 does not overflow.
inline void softmax_row(float* x, int n) {
if (n <= 0) return;
float mx = x[0];
for (int i = 1; i < n; ++i)
if (x[i] > mx) mx = x[i];
double sum = 0.0;
for (int i = 0; i < n; ++i) {
x[i] = std::exp(x[i] - mx);
sum += x[i];
}
const double inv = 1.0 / sum;
for (int i = 0; i < n; ++i) x[i] = static_cast<float>(x[i] * inv);
}

} // namespace onebit::audio
33 changes: 32 additions & 1 deletion npu/forward/src/q4nx_forward.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2683,7 +2683,38 @@ bool Q4nxNpuForward::load_ple_block(int row) {
ple_block_.assign((size_t)Q4NX_TILE_ROWS * (size_t)cols, 0.0f);
const size_t plain_bytes =
(size_t)sem_.vocab_size_per_layer_input * (size_t)cols;
if ((size_t)td->data_size == plain_bytes) {
// engine#1725: a RAW BF16 band. The multi-component packs state this table as f32 and the importer converts it to BF16
// when it writes the container (a converting copy of the whole V x NL*P table would be 9.4 GiB), so the band here is
// 32 rows of BF16 -- the shape the plain-int8 branch handles, with a 2-byte element instead of a code.
//
// The branch is chosen by the container's DECLARED dtype, not by arithmetic on data_size (review of #1825): a size test
// needs `% bf16_bytes == 0 && != plain_bytes` to separate two layouts, and every multiple of the band satisfies it, so a
// plain table whose byte count happens to divide would be read as BF16 bands -- plausible and wrong. This tree already
// decides exactly this way elsewhere (`model.c`: `strcmp(dtype,"I8")`), and `q4nx_dequant.h`'s rule is that an
// ambiguous layout is refused rather than resolved by which test happens to pass.
const size_t bf16_bytes = (size_t)Q4NX_TILE_ROWS * (size_t)cols * 2;
const char* pl_dtype = td->dtype;
const bool declared_bf16 = pl_dtype[0] != '\0' && std::strcmp(pl_dtype, "BF16") == 0;
const bool declared_i8 = pl_dtype[0] != '\0' && (std::strcmp(pl_dtype, "I8") == 0 || std::strcmp(pl_dtype, "Q4") == 0);
if (!declared_bf16 && !declared_i8) {
// Some other declared format, or none at all: refuse rather than fall through to a layout guess. A container
// written by this importer always states one (`w.add()` writes dtype for every tensor), so an empty dtype means a
// foreign or truncated container, not a layout this loader can infer.
err_ = pl_dtype[0] != '\0'
? std::string("per_layer_token_embd has dtype ") + pl_dtype +
", which this loader does not read (BF16 or plain I8 only)"
: std::string("per_layer_token_embd declares no dtype, so its layout cannot be decided");
return false;
}
if (declared_bf16) {
if ((size_t)td->data_size < ((size_t)block + 1) * bf16_bytes ||
(size_t)td->data_size % bf16_bytes != 0) {
err_ = "per_layer_token_embd is declared BF16 but its size is not whole 32-row bands";
return false;
}
const uint16_t* band = reinterpret_cast<const uint16_t*>(raw + (size_t)block * bf16_bytes);
for (size_t i = 0; i < (size_t)Q4NX_TILE_ROWS * (size_t)cols; ++i) ple_block_[i] = bf16_to_f32(band[i]);
} else if ((size_t)td->data_size == plain_bytes) {
// PLAIN int8 + per-32-column scale (Gemma4): only the requested band of
// 32 rows is needed, so it is dequantized in place rather than the whole
// [vocab, NL*ple_dim] table.
Expand Down
Loading
Loading