Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
cafd30e
specdec_bench: emit speculation_profile.json alongside acceptance met…
yeyu-nvidia Aug 19, 2026
878421c
specdec_bench: derive K from the flag each method actually uses
yeyu-nvidia Aug 25, 2026
c170403
specdec_bench: address review — publishable ids, per-run state, __all__
yeyu-nvidia Aug 25, 2026
095124e
specdec_bench: make mean accept length per-step, not per-request
yeyu-nvidia Sep 1, 2026
2f235bf
export: attach speculation_profile.json to exported draft checkpoints
yeyu-nvidia Sep 2, 2026
f0b449f
Merge remote-tracking branch 'origin/main' into yeyu/speculation-profile
yeyu-nvidia Sep 2, 2026
7e0c488
Merge branch 'yeyu/speculation-profile' into yeyu/speculation-profile…
yeyu-nvidia Sep 2, 2026
dad7965
ar_validate: emit a speculation profile from in-training AR validation
yeyu-nvidia Sep 2, 2026
e829928
Merge branch 'yeyu/speculation-profile-ar-validate' into yeyu/specula…
yeyu-nvidia Sep 2, 2026
35ed318
docs: document speculation profiles and how to produce them
yeyu-nvidia Sep 2, 2026
1c6ddd3
specdec_bench: fix dump_env regression and sparse-histogram gaps
yeyu-nvidia Sep 2, 2026
77df762
Merge branch 'yeyu/speculation-profile' into yeyu/speculation-profile…
yeyu-nvidia Sep 2, 2026
edf879d
Merge branch 'yeyu/speculation-profile' into yeyu/speculation-profile…
yeyu-nvidia Sep 2, 2026
cde2c7e
Merge branch 'yeyu/speculation-profile-export' into yeyu/speculation-…
yeyu-nvidia Sep 2, 2026
b2beb8d
Merge branch 'yeyu/speculation-profile-ar-validate' into yeyu/specula…
yeyu-nvidia Sep 2, 2026
43e57bf
specdec_bench: validate rates, empty measurements and verification me…
yeyu-nvidia Sep 2, 2026
d67c3cb
Merge branch 'yeyu/speculation-profile' into yeyu/speculation-profile…
yeyu-nvidia Sep 2, 2026
0f55e88
export: reject malformed JSON on the parser path
yeyu-nvidia Sep 2, 2026
0d9549c
Merge branch 'yeyu/speculation-profile' into yeyu/speculation-profile…
yeyu-nvidia Sep 2, 2026
5b8a346
ar_validate: do not emit a profile from an empty measurement
yeyu-nvidia Sep 2, 2026
b63033c
Merge branch 'yeyu/speculation-profile-export' into yeyu/speculation-…
yeyu-nvidia Sep 2, 2026
a4484d0
Merge branch 'yeyu/speculation-profile-ar-validate' into yeyu/specula…
yeyu-nvidia Sep 2, 2026
8ba9025
specdec_bench: fix two lint errors CI caught that local pre-commit mi…
yeyu-nvidia Sep 3, 2026
c618645
Merge branch 'yeyu/speculation-profile' into yeyu/speculation-profile…
yeyu-nvidia Sep 3, 2026
cf91f56
Merge branch 'yeyu/speculation-profile' into yeyu/speculation-profile…
yeyu-nvidia Sep 3, 2026
3edd60d
Merge branch 'yeyu/speculation-profile-export' into yeyu/speculation-…
yeyu-nvidia Sep 3, 2026
0facffa
Merge branch 'yeyu/speculation-profile-ar-validate' into yeyu/specula…
yeyu-nvidia Sep 3, 2026
493abc5
Merge remote-tracking branch 'origin/main' into tmp/yeyu/speculation-…
yeyu-nvidia Sep 9, 2026
55c8dba
Merge remote-tracking branch 'origin/main' into tmp/fix-2316
yeyu-nvidia Sep 9, 2026
ead974f
Merge remote-tracking branch 'origin/main' into tmp/fix-2316
yeyu-nvidia Sep 11, 2026
e30a8ee
address review on #2247: DSPARK K, artifact safety, baseline runs
yeyu-nvidia Sep 16, 2026
268fde7
docs(specdec_bench): correct build_profile's input and per_category s…
yeyu-nvidia Sep 16, 2026
ae9fd59
address review on #2313: validate before export, unmeasured consistency
yeyu-nvidia Sep 16, 2026
f007969
add attach_speculation_profile.py so measuring need not mean re-expor…
yeyu-nvidia Sep 16, 2026
f49621c
address review on #2313/#2247: package re-export, docstring accuracy
yeyu-nvidia Sep 16, 2026
6a03b03
address review on #2316: API stability, stub schema, producer contract
yeyu-nvidia Sep 16, 2026
a87a4c9
Merge branch 'main' into yeyu/speculation-profile-docs
yeyu-nvidia Sep 17, 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
26 changes: 26 additions & 0 deletions examples/specdec_bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,32 @@ python3 run.py \
--runtime_params runtime_args_long_context.yaml
```

## Speculation Profiles

Any run with `--save_dir` that measures acceptance also writes `speculation_profile.json` next to
`acceptance_rate.json`. It is the deployment-facing view of the same numbers: per-position acceptance
plus enough provenance to know what they describe.

The point is that a draft checkpoint's weights say nothing about how good it is, so deployment
tooling guesses -- dynamo's simulator models every draft model with one hardcoded acceptance vector.
Attaching the measurement to the exported checkpoint (`export_hf_checkpoint.py
--speculation_profile`) removes the guess.

Both acceptance conventions are emitted, because the two known consumers disagree: dynamo's mocker
wants **conditional** rates (P(draft i+1 accepted | first i accepted)) while vLLM's synthetic
rejection sampler wants **marginal** rates (P(first i+1 all accepted)). Publishing one and letting a
consumer assume the other is a silent, plausible-looking failure.

Each profile self-checks that `mean_accept_length == 1 + sum(marginal_accept_rates)`. Because both
sides derive from the same histogram, that identity holds exactly *unless the published vector is
truncated* -- so what it really catches is a `num_speculative_tokens` that understates the K the run
used, which would otherwise ship a profile describing a weaker draft than was measured. A failure is
recorded in the artifact and warned about rather than raised, so the discrepancy stays inspectable.

See [`examples/speculative_decoding`](../speculative_decoding/README.md#speculation-profiles) for the
schema, the second (in-training) producer, and guidance on comparing against published model-card
numbers.

## Uploading results to S3

Each `run.py` invocation writes a result directory containing `configuration.json`,
Expand Down
67 changes: 67 additions & 0 deletions examples/specdec_bench/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import yaml
from specdec_bench import datasets, metrics, models, runners
from specdec_bench.speculation_profile import checkpoint_id
from specdec_bench.utils import (
decode_chat,
dump_env,
Expand Down Expand Up @@ -58,6 +59,67 @@
}


# Methods the engine wrappers configure via speculative_num_draft_tokens
# (``--block_size``) rather than speculative_num_steps (``--draft_length``).
_BLOCK_CONFIGURED_METHODS = frozenset({"dflash", "dspark"})


def _speculation_profile_metadata(args):
"""Describe the measurement for speculation_profile.json.

Only the fields needed to interpret the acceptance vectors standalone live here;
the exhaustive run record (engine version, checkpoint hashes, redacted argv, GPU)
is already written to configuration.json by dump_env().

On K -- which flag actually sets it depends on the method, so this mirrors the
engine wrappers rather than guessing:

* The block-parallel methods -- DFLASH and DSPARK -- are configured by
``--block_size``. The vLLM wrapper forwards it as
``speculative_num_draft_tokens`` for both (``models/vllm.py``), and the SGLang
wrapper *ignores* ``--draft_length`` (it warns about this explicitly). Keying on
one name would truncate the other's vectors to ``--draft_length``.
* Everything else uses ``--draft_length``, forwarded as ``speculative_num_steps``
(TRT-LLM turns it into ``max_draft_len``).

Reading K off the wrong flag would silently mislabel the vectors, so it is
derived here rather than assumed.

``max_supported_k`` is deliberately left to default to the measured K. A
block-parallel draft does have a hard architectural ceiling, but specdec_bench
cannot observe it: ``--block_size`` here is the value handed to the engine as
num_speculative_tokens, which is not the same quantity as the trained
``dflash_block_size`` in the checkpoint config despite the shared name. Publishing
a ceiling we cannot verify would be worse than publishing none.
"""
method = (args.speculative_algorithm or "").lower() or None
block_size = getattr(args, "block_size", None)
if method in _BLOCK_CONFIGURED_METHODS and block_size:
num_speculative_tokens = block_size
else:
num_speculative_tokens = args.draft_length
return {
"num_speculative_tokens": num_speculative_tokens,
"method": method,
"block_size": block_size,
# Identifiers, not paths: this artifact is meant to be published alongside a
# checkpoint, so it must not carry internal cluster layout. configuration.json
# keeps the full paths for local debugging.
"draft_checkpoint": (
{"id": checkpoint_id(args.draft_model_dir)} if args.draft_model_dir else None
),
"target_model": {"id": checkpoint_id(args.model_dir)},
"measurement_conditions": {
"dataset": args.dataset or ("mtbench" if args.mtbench else None),
"concurrency": args.concurrency,
"temperature": args.temperature,
"engine": args.engine,
"tp_size": args.tp_size,
"full_run_record": "configuration.json",
},
}


async def tqdm_gather(*fs, return_exceptions=False, **kwargs):
if not return_exceptions:
return await tqdm.gather(*fs, **kwargs)
Expand Down Expand Up @@ -210,10 +272,15 @@ def run_simple(args):
if args.save_dir is not None:
for metric in metrics_list:
metric.update_directory(args.save_dir)
metrics.AcceptanceRate.set_profile_metadata(_speculation_profile_metadata(args))
# Stamp configuration.json BEFORE the run loop so the file lands even
# when the run crashes mid-way. Engine init is already done, so the
# live serving_config from the model is available.
dump_env(args, args.save_dir, overrides={"serving_config": model.get_serving_config()})
else:
# Class-level state, so clear it: a second in-process run (e.g. an AR-vs-K
# sweep) without --save_dir must not inherit the previous run's metadata.
metrics.AcceptanceRate.set_profile_metadata(None)

runner = runners.SimpleRunner(model, metrics=metrics_list)

Expand Down
5 changes: 5 additions & 0 deletions examples/specdec_bench/specdec_bench/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,8 @@

_warn("modelopt not found, using 0.0.0 as specdec_bench version")
__version__ = "0.0.0"

# speculation_profile is stdlib-only by design (see its header): the vLLM container
# that runs the benchmark has no modelopt, so re-exporting it here is safe where the
# modelopt import above is not.
from .speculation_profile import *
72 changes: 72 additions & 0 deletions examples/specdec_bench/specdec_bench/metrics/acceptance_rate.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,30 @@
import json
import os

from ..speculation_profile import build_profile
from .base import Metric


class AcceptanceRate(Metric):
# Set once per run by run.py via set_profile_metadata(). Class-level so the
# MTBench/SpecBench subclasses pick it up without extra wiring, mirroring how
# Metric.update_directory() distributes the output path.
profile_metadata = None

def __init__(self):
super().__init__()
self.prompt_ar = {}
self.name = "acceptance_rate"

@classmethod
def set_profile_metadata(cls, metadata):
"""Describe what is being measured, so a speculation_profile.json can be written.

Without this the acceptance numbers are still computed and written as before;
only the deployment-facing profile is skipped.
"""
AcceptanceRate.profile_metadata = metadata

def process_step(self, step_outputs, request_id, turn_id):
if request_id not in self.prompt_ar:
self.prompt_ar[request_id] = {}
Expand Down Expand Up @@ -63,6 +78,63 @@ def _process_lengths(self, lengths):
for k, cond_ar in self.out["Conditional_Acceptance_Rate"].items():
running_joint *= cond_ar
self.out["Joint_Acceptance_Rate"][k] = running_joint
# Emitted here rather than in each process_final(): this is the single point
# where the acceptance distribution is final, and all three variants
# (AcceptanceRate / MTBench / SpecBench) route through it, so none can
# silently stop producing a profile.
try:
self._write_speculation_profile()
except Exception as exc:
# Additive artifact: acceptance_rate.json / mtbench.json / responses.jsonl
# are written *after* this call, so letting it raise would end a multi-hour
# run with no acceptance numbers at all. Same rule _consistency_check
# follows -- a problem stays inspectable instead of aborting the run.
print(f"WARNING: could not write speculation_profile.json: {exc}")

def _write_speculation_profile(self):
"""Write speculation_profile.json — the deployment-facing view of these numbers.

Skipped silently when run.py did not supply metadata (e.g. an ad-hoc run with
no --save_dir): the profile is only meaningful if we can say what it describes.
"""
metadata = AcceptanceRate.profile_metadata
if not metadata or not self.directory:
return
# A non-speculative run (--speculative_algorithm NONE) still completes: every
# decode step emits one token, so the histogram is {1: N} and observed_steps > 0.
# build_profile would therefore mark it measured=true with all-zero acceptance --
# indistinguishable from a genuinely terrible draft, and it passes the mean
# consistency check (1 + 0 == 1.0). There is no draft to describe, so emit nothing.
if (metadata.get("method") or "none") == "none":
return
profile = build_profile(
self.out,
per_category=self.out.get("Category_AL"),
**metadata,
)
path = os.path.join(self.directory, "speculation_profile.json")
os.makedirs(self.directory, exist_ok=True)
with open(path, "w") as f:
json.dump(profile, f, indent=2)
validation = profile.get("validation") or {}
# Monotonicity is computed and serialized either way; surfacing only the mean
# check left a malformed histogram -- per _monotonicity_check's own docstring,
# a data problem rather than a draft-quality one -- silent in the log.
monotonicity = validation.get("marginal_monotonicity") or {}
if monotonicity.get("passed") is False:
print(f"WARNING: speculation profile marginal_monotonicity failed: {monotonicity}")
consistency = validation.get("mean_consistency") or {}
if not consistency.get("passed", True):
# Loud, because a failure here means the vectors do not describe the
# measured mean — the profile is wrong in a way downstream cannot detect.
print(
"WARNING: speculation profile failed its mean-consistency check "
f"(implied {consistency.get('implied_mean_accept_length')} vs "
f"reported {consistency.get('reported_mean_accept_length')}). "
f"See {path}"
)
else:
print(f"Wrote speculation profile to {path}")
Comment on lines +118 to +137

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] marginal_monotonicity is computed and serialized but never surfaced, so the malformed-histogram case it detects stays quiet.

build_profile runs both checks into validation, and this writer warns loudly on mean_consistency — correctly — but reads only that one key. A monotonicity violation means, per _monotonicity_check's own docstring, "a malformed histogram rather than an unusual draft model", i.e. a genuine bug in the measurement rather than a property of the draft. That seems at least as worth a console warning as truncation, and someone running a sweep will not open the JSON unless something prompts them to.

        if not consistency.get("passed", True):
            ...
        monotonicity = validation.get("marginal_monotonicity") or {}
        if not monotonicity.get("passed", True):
            print(
                "WARNING: speculation profile marginals are not non-increasing "
                f"({monotonicity.get('violations')}); vLLM's synthetic sampler requires "
                f"a survival function. See {path}"
            )
        if consistency.get("passed", True) and monotonicity.get("passed", True):
            print(f"Wrote speculation profile to {path}")

Related, and the reason I'd bother: verification_method has no runtime path from either producer. run.py::_speculation_profile_metadata doesn't emit the key and ar_validate.py doesn't either, so build_profile always takes the "longest_prefix" default and the vectors_apply is False branch — the one that withholds the vectors and populates vectors_unavailable_reason — is reachable only from test_block_verification_withholds_the_vectors. That's a fair amount of carefully-reasoned code (and a documented schema field) that no production caller can currently reach. Since vLLM does offer block verification and DFlash is block-parallel, it'd be worth either plumbing it from a run flag or noting in the docstring that it's reserved for a future producer, so the next reader doesn't assume DFlash runs are already covered by it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6a03b031. marginal_monotonicity is now surfaced alongside mean_consistency, with its own warning. As you noted, a monotonicity violation indicates a malformed histogram rather than a poor draft, so staying silent about it was the worse of the two failure modes to hide.


def process_final(self, text_outputs):
all_ar = []
Expand Down
Loading
Loading