Skip to content
Merged
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
75 changes: 48 additions & 27 deletions npu/mtp/draft.h
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,20 @@
// M=16 twins verify up to 16 rows for the price of one row's weight stream. So the draft head is worth
// its cost exactly when it turns "one pass per emitted token" into "one pass per (accepted + 1) tokens".
//
// Rollback is a truncation, not a snapshot. A snapshot of a 400-token context is ~100 MB and one per
// iteration would cost more than the drafts save, while every cache here is position-indexed: the caller
// supplies `pos`, so "forget everything past N" is a length bump for attention. A GDN layer's recurrence is
// the exception (it is not position-indexed), which is why BaseModel::truncate() may refuse -- the loop then
// falls back to plain decode rather than emitting wrong tokens.
// Rollback is a commit, not a snapshot, and not a rollback at all where it can be avoided.
//
// A snapshot of a 400-token context is ~100 MB and one per iteration would cost more than the drafts save, and
// attention state needs no rollback whatsoever: it is position-indexed, the caller supplies `pos`, so
// "forget everything past N" is a length bump. The one state that cannot be re-indexed is a GDN layer's
// recurrence. So instead of rolling it back, the verify pass DEFERS it: the rows of a verification batch
// store their recurrence inputs rather than applying them, and BaseModel::commit_verify(accepted) then
// applies exactly the accepted rows and drops the rest. Nothing is recomputed and nothing is snapshotted;
// the cost is the storage of k rows of update inputs (per layer: the conv-ring rows plus the rank-1 update
// inputs), which is kilobytes, not the 100 MB a state copy would take.
//
// A base that has not implemented deferral reports it from commit_verify() and the loop falls back to a
// synchronous commit + position truncation -- correct, just not free. Either way the emitted text is the
// same; the difference is only what a rejected draft costs.
//
// The loop is policy only -- it owns no weights and no device state. The base model arrives through
// BaseModel (Q4nxNpuForward satisfies it through the adapter in npu/mtp/forward_base.h) and the draft head
Expand Down Expand Up @@ -77,11 +86,15 @@ class BaseModel {
virtual bool step_batch_all(const std::vector<int>& tokens, int start_pos,
std::vector<float>& logits_all) = 0;

// Forget every position at or past `tokens`, so the next step can run at `tokens`. Cheap by design:
// attention state is position-indexed (the caller passes `pos`), so for a pure-attention model this is
// a length bump; a GDN/hybrid model must also roll its recurrent state back and returns false until it
// can (see the note at the top of this file -- that is the one engine gap speculation has).
virtual bool truncate(int tokens) = 0;
// After a verification batch of `rows` rows, keep the first `accepted` of them and discard the rest.
// * attention: pure bookkeeping -- drop the positions of the rejected rows (they are position-indexed,
// so nothing has to be undone);
// * GDN/hybrid: apply the deferred recurrence updates of the accepted rows, drop the rejected ones.
// That is the design this interface exists for: the updates were stored during the verify pass, not
// applied, so committing a prefix is cheap and no state copy is involved.
// Returns false if this base cannot defer (no GDN deferral implemented); the loop then stops rather than
// pretending the state is consistent.
virtual bool commit_verify(int accepted) = 0;

// The last row's hidden state (H floats) after the last step/step_batch_all: the MTP head's input.
virtual const std::vector<float>& last_hidden() const = 0;
Expand All @@ -92,14 +105,22 @@ class DraftHead {
virtual ~DraftHead() = default;
// The base model has just consumed `last_token` and `hidden` is the state after it. Propose up to k
// tokens; return how many were written to out (0 = no proposal, the iteration then costs nothing).
// `conf`, when given, receives each proposal's own probability (softmax of the head's logits at that
// position) so the loop can apply the tau gate; a head that cannot report it leaves `conf` empty and the
// gate is skipped for that iteration rather than guessed.
virtual int draft(const std::vector<float>& hidden, int last_token, int k,
std::vector<int>& out) = 0;
std::vector<int>& out, std::vector<float>* conf = nullptr) = 0;
virtual void reset() {}
};

struct Options {
int k = 4; // drafts per iteration; clamped to max_batch_rows()
int max_tokens = 64; // tokens to emit, prompt excluded
// The chain is gated on the drafter's own confidence: keep drafting while the token's probability is at
// least `tau` (0.6 is the published setting for this head), stop the chain otherwise -- a low-confidence
// draft is verified anyway (it is already in the batch), it just does not get followed by more of them.
// tau <= 0 disables the gate.
float tau = 0.6f;
};

struct Stats {
Expand Down Expand Up @@ -159,10 +180,18 @@ inline Result run(BaseModel& base, DraftHead* head, const std::vector<int>& prom
const int have = ++pos;

std::vector<int> d;
int nd = head->draft(base.last_hidden(), t1, k, d);
std::vector<float> conf;
int nd = head->draft(base.last_hidden(), t1, k, d, &conf);
if (nd <= 0) continue;
nd = std::min(nd, k);
d.resize((size_t)nd);
// tau gate: the chain only continues while the drafter is confident; the first draft below tau is
// still verified (it is in the batch anyway) but nothing is drafted after it.
if (opt.tau > 0.0f && conf.size() == d.size()) {
for (int i = 0; i < nd; i++)
if (conf[(size_t)i] < opt.tau) { nd = i + 1; break; }
d.resize((size_t)nd);
}

if (!base.step_batch_all(d, have, all)) { r.err = "verify batch failed"; return r; }
r.stats.iterations++;
Expand All @@ -189,21 +218,13 @@ inline Result run(BaseModel& base, DraftHead* head, const std::vector<int>& prom
r.stats.accepted += a; // acceptance is a property of the model, not of the budget
if (a == 0) r.stats.first_reject++;

if (take < nd) {
// Forget the rows we are not using (refused drafts, or the budget cut us short), and take the
// logits that predict the next position from the batch row we stopped at.
if (!base.truncate(have + take)) { r.err = "truncate refused"; return r; }
if (take > 0)
logits.assign(all.begin() + (size_t)(take - 1) * (size_t)V,
all.begin() + (size_t)take * (size_t)V);
pos = have + take;
} else {
// Everything landed: the batch state is already right, and the last row's logits predict the
// next position (one free token per fully accepted iteration).
logits.assign(all.begin() + (size_t)(nd - 1) * (size_t)V,
all.begin() + (size_t)nd * (size_t)V);
pos = have + nd;
}
// Commit the accepted rows of the verify batch and discard the rest. With a deferring base this
// applies only the accepted rows' recurrence updates; without one it truncates by position.
if (!base.commit_verify(take)) { r.err = "commit_verify refused"; return r; }
if (take > 0)
logits.assign(all.begin() + (size_t)(take - 1) * (size_t)V,
all.begin() + (size_t)take * (size_t)V);
pos = have + take;
if ((int)r.tokens.size() >= opt.max_tokens) break;
}

Expand Down
61 changes: 49 additions & 12 deletions tests/mtp_draft_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -67,24 +67,32 @@ class ToyBase : public BaseModel {
for (size_t m = 0; m < tokens.size(); m++)
fill_logits(start_pos + (int)m, logits_all, m * (size_t)V);
consumed_ += (int)tokens.size();
pending_ = (int)tokens.size(); // these rows are a verify batch until commit_verify() decides
batches_++;
return true;
}

// Attention-only toy: truncation is a length bump, exactly like the real position-indexed caches.
bool truncate(int tokens) override {
if (tokens < 0 || tokens > consumed_) return false;
consumed_ = tokens;
restores_++;
// Toy base: no GDN deferral, so committing a verify prefix is the position bookkeeping a pure-attention
// model needs (drop the rejected rows). It records the call so the test can check the loop commits
// exactly the accepted count and never more.
bool commit_verify(int accepted) override {
if (accepted < 0 || accepted > pending_) return false;
consumed_ = consumed_ - pending_ + accepted;
pending_ = 0;
commits_++;
last_commit_ = accepted;
return true;
}

int commits() const { return commits_; }
int last_commit() const { return last_commit_; }
int pending() const { return pending_; }

const std::vector<float>& last_hidden() const override { return hidden_; }

int consumed() const { return consumed_; }
int steps() const { return steps_; }
int batches() const { return batches_; }
int restores() const { return restores_; }
int bad_pos() const { return bad_pos_; }
int over_batch() const { return over_batch_; }

Expand All @@ -97,14 +105,17 @@ class ToyBase : public BaseModel {
}

int batch_rows_;
int consumed_ = 0, steps_ = 0, batches_ = 0, restores_ = 0, bad_pos_ = 0, over_batch_ = 0;
int consumed_ = 0, steps_ = 0, batches_ = 0, commits_ = 0, last_commit_ = -1, pending_ = 0;
int bad_pos_ = 0, over_batch_ = 0;
std::vector<float> hidden_{0.0f};
};

// Knows the truth (reads the position out of the hidden state): every draft should be accepted.
class PerfectHead : public DraftHead {
public:
int draft(const std::vector<float>& hidden, int, int k, std::vector<int>& out) override {
int draft(const std::vector<float>& hidden, int, int k, std::vector<int>& out,
std::vector<float>* conf = nullptr) override {
if (conf) conf->assign((size_t)k, 1.0f);
const int have = (int)hidden[0]; // positions consumed so far
out.clear();
for (int i = 0; i < k; i++) out.push_back(truth(have + i));
Expand All @@ -115,7 +126,9 @@ class PerfectHead : public DraftHead {
// Proposes the token AFTER the true one at every position: the base must refuse all of it.
class GarbageHead : public DraftHead {
public:
int draft(const std::vector<float>& hidden, int, int k, std::vector<int>& out) override {
int draft(const std::vector<float>& hidden, int, int k, std::vector<int>& out,
std::vector<float>* conf = nullptr) override {
if (conf) conf->assign((size_t)k, 0.1f);
const int have = (int)hidden[0];
out.clear();
for (int i = 0; i < k; i++) out.push_back((truth(have + i) + 1) % V);
Expand All @@ -126,7 +139,9 @@ class GarbageHead : public DraftHead {
// Right for the first draft, wrong afterwards: exercises the longest-prefix accept AND the rollback.
class HalfHead : public DraftHead {
public:
int draft(const std::vector<float>& hidden, int, int k, std::vector<int>& out) override {
int draft(const std::vector<float>& hidden, int, int k, std::vector<int>& out,
std::vector<float>* conf = nullptr) override {
if (conf) { conf->assign((size_t)k, 0.1f); if (!conf->empty()) (*conf)[0] = 1.0f; }
const int have = (int)hidden[0];
out.clear();
if (k > 0) out.push_back(truth(have)); // accepted
Expand Down Expand Up @@ -192,8 +207,8 @@ int main() {
// [1] k=0 must be a bit-for-bit plain decode
{ ToyBase m(8); PerfectHead ph; Options o = base_opt; o.k = 0;
const Result r = onebit::npu::mtp::run(m, &ph, P, o);
check(r.ok && same(r.tokens, ref.tokens) && m.batches() == 0 && m.restores() == 0,
"k=0: no batched pass, no rollback, same tokens"); }
check(r.ok && same(r.tokens, ref.tokens) && m.batches() == 0 && m.commits() == 0,
"k=0: no batched pass, no commit, same tokens"); }
}

// [2] the win condition, stated as a number: a perfect head at k=8 amortises the pass
Expand All @@ -217,6 +232,28 @@ int main() {
r.stats.passes_per_token(), r.stats.rows_verified);
}

// [6] tau gate: a head whose first draft is below tau gets verified but not followed, so exactly one
// row goes through the batch every iteration -- and the text still matches plain decode.
{
ToyBase m(8); GarbageHead gh; Options o = base_opt; o.k = 8; o.tau = 0.6f;
const Result r = onebit::npu::mtp::run(m, &gh, P, o);
check(r.ok && same(r.tokens, ref.tokens), "tau gate: text still identical to plain decode");
check(r.stats.rows_verified == r.stats.iterations,
"tau gate: a below-tau first draft is verified alone (1 row per iteration)");
std::printf("INFO tau=0.6 garbage head: rows verified=%d over %d iterations, passes/token=%.3f\n",
r.stats.rows_verified, r.stats.iterations, r.stats.passes_per_token());
}
// [7] the loop commits exactly the accepted prefix, never the whole batch when the tail is refused
{
ToyBase m(8); HalfHead hh; Options o = base_opt; o.k = 4;
const Result r = onebit::npu::mtp::run(m, &hh, P, o);
check(r.ok && m.pending() == 0, "commit: no verify rows left uncommitted at the end");
check(m.last_commit() <= r.stats.drafted && m.last_commit() >= 0,
"commit: the last commit was a prefix of the verified rows");
std::printf("INFO half head k=4: commits=%d last_commit=%d accepted=%d drafted=%d\n", m.commits(),
m.last_commit(), r.stats.accepted, r.stats.drafted);
}

std::printf(fails ? "\n%d CHECK(S) FAILED\n" : "\nall checks passed\n", fails);
return fails ? 1 : 0;
}
Loading