// src/kernels/ngram.cpp - P2.S4: the PLE n-gram hash and the IQ4_NL table read. // // See include/strata/kernels/ngram.hpp for the semantics, the rival readings, and the note on MADV_RANDOM. #include "strata/kernels/ngram.hpp" #include "strata/artifact/gguf_reader.hpp" #include "strata/artifact/dequant.hpp" #include "strata/kernels/f16_bits.hpp" #include "strata/ngram/ple_reader.hpp" #include #include #include #include #if !defined(_WIN32) #include #endif #include #include #if defined(_WIN32) // `PrefetchVirtualMemory` (memoryapi.h, Windows 8+) is the whole point of the change in `gather` below. #define WIN32_LEAN_AND_MEAN #define NOMINMAX #include #endif namespace strata::kernels { namespace { /// The A/B arm. Host-token-path only, so it needs no atomics; see the note on `ple_prefetch_enable`. bool g_ple_prefetch = true; } // namespace void ple_prefetch_enable(bool on) { g_ple_prefetch = on; } bool ple_prefetch_enabled() { return g_ple_prefetch; } PleConsts ple_artifact_consts() { // docs/gguf-dump-shard1.txt, verbatim. Written once here rather than derived, because `head_offsets` is // ALSO in the metadata as its own array - and deriving one from the other would hide a mismatch between // them instead of surfacing it. The parity test checks the two agree. PleConsts c{}; c.mult[0] = 23703573157769ull; c.mult[1] = 20109073645365ull; c.mult[2] = 8052911324071ull; const uint64_t vocab[PLE_N_HEADS] = { 20000003, 20000023, 20000033, 20000047, 20000059, 20000063, 20000069, 20000077, 20000081, 20000093, 20000107, 20000147, 20000153, 20000159, 20000161, 20000171}; const uint64_t offset[PLE_N_HEADS] = { 0, 20000003, 40000026, 60000059, 80000106, 100000165, 120000228, 140000297, 160000374, 180000455, 200000548, 220000655, 240000802, 260000955, 280001114, 300001275}; for (int i = 0; i < PLE_N_HEADS; ++i) { c.vocab[i] = vocab[i]; c.offset[i] = offset[i]; } return c; } uint64_t ngram_mixed(const int64_t* ctx, const uint64_t* mult, int n) { // The first term is an ASSIGNMENT and the rest are XORed into it, which is how the source writes it // (`uint64_t mixed = ctx[0]*m[0]; for j=1.. mixed ^= ctx[j]*m[j];`). Every product wraps mod 2^64, // which is what the `(uint64_t)` casts in the source make explicit. uint64_t mixed = (uint64_t) ctx[0] * mult[0]; for (int j = 1; j < n; ++j) mixed ^= (uint64_t) ctx[j] * mult[j]; return mixed; } void ngram_rows(const int32_t* tokens, const int32_t* prev, int n_tokens, const PleConsts& c, uint32_t* out) { const int n_prev = NGRAM_SIZE - 1; for (int i = 0; i < n_tokens; ++i) { int64_t ctx[NGRAM_SIZE]; ctx[0] = tokens[i]; bool cut = false; for (int s = 1; s < NGRAM_SIZE; ++s) { // `prev` is OLDEST FIRST, so predecessor `s` positions back is entry (n_prev - s): s=1 reads the // NEWEST. Reading index (s-1) instead walks the window backwards, which still produces indices // in range and so cannot be caught by a range check - only by an oracle. const int32_t t = cut ? TOKEN_NULL : prev[i * n_prev + (n_prev - s)]; // The cut is evaluated BEFORE the value is stored, so the position whose predecessor was EOS is // itself EOS. Storing first and then cutting would leave position s holding the real token while // position s+1 became EOS - one token of history too much. cut = cut || t < 0 || t == PLE_EOS_TOKEN_ID; ctx[s] = cut ? PLE_EOS_TOKEN_ID : t; } for (int n = 2; n <= NGRAM_SIZE; ++n) { const uint64_t mixed = ngram_mixed(ctx, c.mult, n); const int base = (n - 2) * HEADS_PER_NGRAM; for (int g = 0; g < HEADS_PER_NGRAM; ++g) { const int h = base + g; out[i * PLE_N_HEADS + h] = (uint32_t) (mixed % c.vocab[h] + c.offset[h]); } } } } namespace { const int8_t kIq4Nl[16] = {-127, -104, -83, -65, -49, -35, -22, -10, 1, 13, 25, 38, 53, 69, 89, 113}; } int iq4nl_code(int code) { return kIq4Nl[code & 15]; } void iq4nl_dequant_row(const uint8_t* row, float* out160) { for (int b = 0; b < PLE_HEAD_DIM / 32; ++b) { const uint8_t* blk = row + (size_t) b * 18; uint16_t dbits; std::memcpy(&dbits, blk, 2); const float d = f32_from_f16(dbits); const uint8_t* qs = blk + 2; // SPLIT HALVES: qs[j] holds elements j and j+16, not 2j and 2j+1. for (int j = 0; j < 16; ++j) { out160[b * 32 + j] = d * (float) kIq4Nl[qs[j] & 0x0F]; out160[b * 32 + j + 16] = d * (float) kIq4Nl[qs[j] >> 4]; } } } namespace { struct Fp8Table { float v[256]; Fp8Table() { for (int x = 0; x < 256; ++x) { const int s = x >> 7, e = (x >> 3) & 15, m = x & 7; float f; if (e == 15 && m == 7) f = 0.0f; // NaN in e4m3fn; never in a real table, read as 0 else if (e == 0) f = std::ldexp((float) m / 8.0f, -6); else f = std::ldexp(1.0f + (float) m / 8.0f, e - 7); v[x] = s ? -f : f; } } }; const Fp8Table kFp8; } // namespace void fp8_e4m3_dequant_row(const uint8_t* row, float scale, float* out160) { for (int j = 0; j < PLE_HEAD_DIM; ++j) out160[j] = kFp8.v[row[j]] * scale; } // --------------------------------------------------------------------------------------------------- struct PleTable::Impl { GgufFile* file = nullptr; const uint8_t* data = nullptr; uint64_t n_rows = 0; bool q5_0 = false; // #296: Q5_0 rows (110 B), the mapped reader only mutable uint64_t bytes_read = 0; // Direct mode (plan v0.3 P2): the mapping above is released after the header parse and every row comes // from an unbuffered SSD read into `raw`. PleIo mode = PleIo::Mmap; strata::ngram::PleReader reader; strata::ngram::PleReader::Ticket ticket; bool pending = false; bool locked = false; uint32_t rows[PLE_N_HEADS] = {}; uint8_t raw[PLE_N_HEADS * PLE_ROW_BYTES_MAX] = {}; bool fp8 = false; // F8_E4M3 rows (tools/ple_fp8_pack.py); else IQ4_NL float scale = 1.0f; // the FP8 table's one scale uint32_t rb = PLE_ROW_BYTES; // bytes per row void decode(const uint8_t* row, float* out160) const { if (fp8) fp8_e4m3_dequant_row(row, scale, out160); else if (q5_0) for (int b = 0; b < PLE_HEAD_DIM / 32; ++b) strata::dequantize_q5_0(row + (size_t) b * 22, out160 + b * 32); else iq4nl_dequant_row(row, out160); } }; PleTable::PleTable() : impl_(new Impl) {} PleTable::~PleTable() { close(); delete impl_; } bool PleTable::open(const std::string& gguf_path, std::string& err) { return open(gguf_path, err, PleIoOptions{}); } bool PleTable::open(const std::string& gguf_path, std::string& err, const PleIoOptions& io) { close(); try { impl_->file = new GgufFile(gguf_path); } catch (const std::exception& e) { err = e.what(); return false; } const TensorInfo* t = impl_->file->find("per_layer_token_embd.weight"); if (t == nullptr) { err = "per_layer_token_embd.weight is not in " + gguf_path; close(); return false; } // [160, 320001536]: ne0 = 160 is the FAST axis, so the ROW index is shape[1] and a row is contiguous. if (t->shape.size() != 2 || t->shape[0] != (uint64_t) PLE_HEAD_DIM) { err = "per_layer_token_embd.weight has an unexpected shape"; close(); return false; } // IQ4_NL (ISTA-DASLab's shard 2, the original's own GGUF), Q5_0 (#296: OrcaRouter's GGUF), or the FP8 table as // shipped: I8 bytes marked strata.ple.format = f8_e4m3 with strata.ple.scale (tools/ple_fp8_pack.py) impl_->fp8 = false; impl_->q5_0 = std::strcmp(t->type_name(), "Q5_0") == 0; impl_->rb = impl_->q5_0 ? (PLE_HEAD_DIM / 32) * 22 : PLE_ROW_BYTES; if (std::strcmp(t->type_name(), "I8") == 0) { const MetaValue* f = impl_->file->get("strata.ple.format"); const MetaValue* s = impl_->file->get("strata.ple.scale"); if (f == nullptr || f->s != "f8_e4m3" || s == nullptr || !(s->num() > 0.0)) { err = "per_layer_token_embd.weight is I8 without strata.ple.format = f8_e4m3 and a positive strata.ple.scale"; close(); return false; } impl_->fp8 = true; impl_->scale = (float) s->num(); impl_->rb = PLE_ROW_BYTES_FP8; } else if (!impl_->q5_0 && std::strcmp(t->type_name(), "IQ4_NL") != 0) { err = std::string("per_layer_token_embd.weight is ") + t->type_name() + ", not IQ4_NL, Q5_0 or FP8 (I8)"; close(); return false; } // PleReader's row_bytes has been a runtime parameter since the FP8 table (160 B rows) needed it; Q5_0's // 110 B rows go through the exact same generic path (ple_reader_test --selftest covers both 90 and 110 B // rows: straddling, caching, in-flight tickets, keep-alive). This refusal was stale. impl_->n_rows = t->shape[1]; impl_->data = impl_->file->tensor_data(*t); // THE CHECK THAT MAKES THE OFFSET FALSIFIABLE. The manifest's `shard2_tensor.offset` is 0, but that is // the offset within the GGUF's DATA SECTION: the file's first 192 bytes are a header, and reading at 0 // would decode the header plus 192 bytes of shifted rows - still plausible IQ4_NL, and wrong for every // row. `GgufFile` parses the header, so `tensor_data` is already correct; this asserts the tensor // exactly fills the file from there, which is what makes the whole arrangement checkable rather than // assumed. A wrong data offset would leave a different remainder. // A shard may hold other tensors too (Swift 1.5's shard 1 holds layers 0-12 and the table): the table must // then fit inside the file at its own offset; alone in its shard (the original's shard 2) it fills it exactly. const uint64_t need = impl_->n_rows * (uint64_t) impl_->rb; const uint64_t have = impl_->file->file_size() - impl_->file->data_start(); const bool alone = impl_->file->tensors().size() == 1; if (alone ? need != have : t->offset + need > have) { char buf[256]; std::snprintf(buf, sizeof buf, "PLE table size mismatch: %llu rows x %d B = %llu at offset %llu, but the file holds %llu from " "data_start %llu", (unsigned long long) impl_->n_rows, (int) impl_->rb, (unsigned long long) need, (unsigned long long) t->offset, (unsigned long long) have, (unsigned long long) impl_->file->data_start()); err = buf; close(); return false; } if (io.mode == PleIo::Direct) { // The parse above is the validated source of the offset; the mapping itself is not kept, so no page of // the table can enter this process's working set or the file cache through it. const uint64_t table_offset = impl_->file->data_start() + t->offset; const uint64_t n_rows = impl_->n_rows; delete impl_->file; impl_->file = nullptr; impl_->data = nullptr; if (!impl_->reader.open(gguf_path, table_offset, n_rows, io.max_inflight, io.cache_rows, err, io.io_thread, impl_->rb)) { close(); return false; } impl_->reader.set_keepalive(io.keepalive_ms, io.keepalive_window_s); impl_->n_rows = n_rows; } if (io.mode == PleIo::Mmap && io.lock && impl_->data != nullptr) { #if !defined(_WIN32) const uint64_t page = 4096; const uintptr_t a0 = (uintptr_t) impl_->data & ~(uintptr_t) (page - 1); const uintptr_t a1 = (uintptr_t) impl_->data + (uintptr_t) need; madvise((void*) a0, a1 - a0, MADV_WILLNEED); if (mlock((const void*) a0, a1 - a0) == 0) { impl_->locked = true; } else { std::fprintf(stderr, "strata: PLE table mlock failed (%s; raise `ulimit -l`): touching its pages instead\n", std::strerror(errno)); volatile uint8_t sink = 0; for (uintptr_t p = a0; p < a1; p += page) sink = sink + *(const volatile uint8_t*) p; (void) sink; } #endif } impl_->mode = io.mode; return true; } void PleTable::close() { impl_->reader.close(); impl_->pending = false; impl_->locked = false; // the unmap below releases the lock impl_->mode = PleIo::Mmap; delete impl_->file; impl_->file = nullptr; impl_->data = nullptr; impl_->n_rows = 0; impl_->rb = PLE_ROW_BYTES; impl_->q5_0 = false; impl_->fp8 = false; } bool PleTable::is_open() const { return impl_->data != nullptr || impl_->reader.is_open(); } bool PleTable::locked() const { return impl_->locked; } const char* PleTable::format() const { return impl_->fp8 ? "F8_E4M3" : impl_->q5_0 ? "Q5_0" : "IQ4_NL"; } PleIo PleTable::mode() const { return impl_->mode; } uint64_t PleTable::rows() const { return impl_->n_rows; } uint64_t PleTable::bytes_read() const { return impl_->bytes_read; } void PleTable::read_row(uint32_t row, float* out160) const { if (impl_->mode == PleIo::Direct && impl_->reader.is_open()) { uint8_t raw[PLE_ROW_BYTES_MAX]; std::string err; const auto t = impl_->reader.issue(&row, 1, raw); if (!impl_->reader.collect(t, err)) { std::memset(out160, 0, (size_t) PLE_HEAD_DIM * sizeof(float)); return; } impl_->decode(raw, out160); impl_->bytes_read += impl_->rb; return; } if (impl_->data == nullptr || row >= impl_->n_rows) { std::memset(out160, 0, (size_t) PLE_HEAD_DIM * sizeof(float)); return; } impl_->decode(impl_->data + (size_t) row * impl_->rb, out160); impl_->bytes_read += impl_->rb; } bool PleTable::issue(const uint32_t* rows16) { std::memcpy(impl_->rows, rows16, sizeof impl_->rows); if (impl_->mode == PleIo::Direct) { if (impl_->pending) return false; // one token in flight per table impl_->ticket = impl_->reader.issue(impl_->rows, PLE_N_HEADS, impl_->raw); impl_->pending = true; return true; } #if defined(_WIN32) if (impl_->data != nullptr && g_ple_prefetch) { WIN32_MEMORY_RANGE_ENTRY ranges[PLE_N_HEADS]; ULONG_PTR n = 0; for (int h = 0; h < PLE_N_HEADS; ++h) { if (rows16[h] >= impl_->n_rows) continue; ranges[n].VirtualAddress = (PVOID) (impl_->data + (size_t) rows16[h] * impl_->rb); ranges[n].NumberOfBytes = impl_->rb; ++n; } if (n > 0) (void) PrefetchVirtualMemory(GetCurrentProcess(), n, ranges, 0); } #endif impl_->pending = true; return true; } bool PleTable::collect(float* out2560, std::string& err) { if (!impl_->pending) { err = "PleTable::collect without issue"; return false; } impl_->pending = false; if (impl_->mode == PleIo::Direct) { if (!impl_->reader.collect(impl_->ticket, err)) return false; for (int h = 0; h < PLE_N_HEADS; ++h) impl_->decode(impl_->raw + (size_t) h * impl_->rb, out2560 + (size_t) h * PLE_HEAD_DIM); impl_->bytes_read += (uint64_t) PLE_N_HEADS * impl_->rb; return true; } for (int h = 0; h < PLE_N_HEADS; ++h) read_row(impl_->rows[h], out2560 + (size_t) h * PLE_HEAD_DIM); return true; } bool PleTable::gather_batch(const uint32_t* rows, size_t n_tokens, float* out, std::string& err) { if (impl_->pending) { err = "PleTable::gather_batch while a token is in flight"; return false; } const size_t n = n_tokens * (size_t) PLE_N_HEADS; if (impl_->mode == PleIo::Direct) { std::vector raw(n * impl_->rb); const auto ticket = impl_->reader.issue(rows, n, raw.data()); if (!impl_->reader.collect(ticket, err)) return false; for (size_t i = 0; i < n; ++i) impl_->decode(raw.data() + i * impl_->rb, out + i * PLE_HEAD_DIM); impl_->bytes_read += (uint64_t) n * impl_->rb; return true; } for (size_t i = 0; i < n; ++i) read_row(rows[i], out + i * PLE_HEAD_DIM); return true; } void PleTable::set_injected_delay_us(double us) { impl_->reader.set_injected_delay_us(us); } std::string PleTable::io_report() const { if (impl_->mode != PleIo::Direct || !impl_->reader.is_open()) return {}; const strata::ngram::ReaderStats s = impl_->reader.snapshot(); char buf[400]; int n = std::snprintf(buf, sizeof buf, "ple io: %llu rows, %.1f%% row-cache hits, %llu SSD reads (%.1f MB), read p50 %.0f us p99 %.0f us, " "blocked %.3f ms total (submit %.3f ms), cache %llu/%llu rows", (unsigned long long) s.requests, s.requests ? 100.0 * (double) s.cache_hits / (double) s.requests : 0.0, (unsigned long long) s.reads, (double) s.bytes / 1e6, s.percentile(0.5), s.percentile(0.99), s.wait_us / 1000.0, s.submit_us / 1000.0, (unsigned long long) impl_->reader.cache_size(), (unsigned long long) impl_->reader.cache_capacity()); if (s.keepalive_reads > 0 && n > 0 && n < (int) sizeof buf) std::snprintf(buf + n, sizeof buf - (size_t) n, ", SSD kept awake by %llu reads (slowest %.1f ms)", (unsigned long long) s.keepalive_reads, s.keepalive_us_max / 1000.0); return buf; } void PleTable::gather(const uint32_t* rows16, float* out2560) const { if (impl_->mode == PleIo::Direct) { // `gather` stays const for its existing callers; the reader's state is the table's I/O state. PleTable* self = const_cast(this); std::string err; if (!self->issue(rows16) || !self->collect(out2560, err)) { std::fprintf(stderr, "PleTable::gather: %s\n", err.empty() ? "a token is already in flight" : err.c_str()); std::memset(out2560, 0, (size_t) NG_N_EMBD * sizeof(float)); } return; } // ================================ SIXTEEN SERIAL PAGE FAULTS, MEASURED ================================ // // **THIS COST 2.10-2.61 ms PER TOKEN AND HAD NEVER BEEN IN THE PLAN'S BUDGET AT ALL.** The round-309 // `token host phases` line put it second behind the layer loop among avoidable terms, and the arithmetic // says why: the table is 320,001,536 rows of `PLE_ROW_BYTES` = 90 B in a 26.8 GB mapping, so the sixteen // rows a token needs are 1,440 B - **0.5 MB/s**. That is not bandwidth, it is latency: sixteen reads into // sixteen different 4 KB pages scattered across 26.8 GB, taken ONE AT A TIME, and on this machine the PLE // shard is 26.8 GB against 63 GB of RAM that the 31.6 GB expert arena is also competing for, so they are // not in the OS cache. Sixteen serial NVMe reads at ~150 us is 2.4 ms, which is the measurement. // // `PrefetchVirtualMemory` issues all sixteen in ONE call and lets them complete in parallel. It is a hint // and cannot change the answer - a range it does not fetch is simply faulted in by the read that follows - // so the only risk is that it does nothing. #if defined(_WIN32) if (impl_->data != nullptr && g_ple_prefetch) { WIN32_MEMORY_RANGE_ENTRY ranges[PLE_N_HEADS]; ULONG_PTR n = 0; for (int h = 0; h < PLE_N_HEADS; ++h) { // Out-of-range rows are handled by `read_row` as zeros and have no address to prefetch. if (rows16[h] >= impl_->n_rows) continue; ranges[n].VirtualAddress = (PVOID) (impl_->data + (size_t) rows16[h] * impl_->rb); ranges[n].NumberOfBytes = impl_->rb; ++n; } if (n > 0) (void) PrefetchVirtualMemory(GetCurrentProcess(), n, ranges, 0); } #endif // HEAD-SLOWEST, which is what `ggml_get_rows` does and what the source's own comment says: head h's 160 // values occupy [h*160, (h+1)*160). A head-fastest layout would put element (d, h) at d*16 + h and needs // a real transpose - a reshape of the same flat buffer compares equal and would make the check vacuous. for (int h = 0; h < PLE_N_HEADS; ++h) read_row(rows16[h], out2560 + (size_t) h * PLE_HEAD_DIM); } } // namespace strata::kernels