Add top-k support to MLX sample - #20564

Merged
metascroy merged 4 commits into
pytorch:mainfrom
goutamadwant:fix-mlx-sample-top-k
Jun 29, 2026
Merged

Add top-k support to MLX sample#20564
metascroy merged 4 commits into
pytorch:mainfrom
goutamadwant:fix-mlx-sample-top-k

Conversation

@goutamadwant

@goutamadwantgoutamadwant commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Fixes#20548

Summary

  • Thread optional top_k through SamplingHead and mlx::sample.
  • Apply top-k filtering as an additional threshold mask that composes with the existing top-p nucleus mask.
  • Add eager, export, end-to-end, and MLX lowering coverage for top-k sampling.

Test plan

  • PYTHONPATH=src:. python3 -m unittest executorch.backends.mlx.test.test_sample.TestSampleOp executorch.backends.mlx.test.test_sample.TestSampleExport
  • python3 -m compileall -q backends/mlx/llm/sampling.py backends/mlx/custom_ops.py backends/mlx/ops.py backends/mlx/test/test_sample.py backends/mlx/test/test_ops.py

cc @metascroy

@pytorch-bot

pytorch-botBot commented Jun 27, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20564

Note: Links to docs will display an error until the docs builds have been completed.

❌ 1 New Failure, 12 Pending

As of commit 7e9ae35 with merge base 55a71e6 (image):

NEW FAILURE - The following job has failed:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 27, 2026
@linux-foundation-easycla

linux-foundation-easyclaBot commented Jun 27, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

  • ✅ login: goutamadwant / name: goutamadwant (c944a5a)

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@pytorchbot label "release notes: llm"

Comment threadbackends/mlx/llm/sampling.py Outdated
return torch.ops.mlx.sample(last, temperature, top_p, seed)
if top_k is not None and not isinstance(top_k, torch.Tensor):
top_k = torch.tensor(int(top_k), dtype=torch.int64)
return torch.ops.mlx.sample(last, temperature, top_p, seed, top_k)

@metascroymetascroyJun 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: Can we reorder the args here to be temp, top_k, top_p, seed?

We also need to modify this custom ops behavior to work with top_k correctly now.

Do huggingface style, where topk happens before topp, which requires renormalization, roughly something like this.

Something similar is required on the emit path.

def sample(logits, temperature, top_k=None, top_p=1.0, seed=None):
if float(temperature) <= 0:
return torch.argmax(logits, dim=-1)
scaled = logits.float() / temperature
# ── Top-k FIRST (on logits; monotonicity ⇒ top-k logits = top-k probs) ──
if top_k is not None:
k = int(top_k.item())
s_scaled, _ = torch.sort(scaled, dim=-1, descending=True)
kth = s_scaled[..., k - 1 : k]
scaled = torch.where(scaled >= kth, scaled, scaled.new_tensor(float("-inf")))
# ── Top-p on the *renormalized* distribution ──
probs = torch.softmax(scaled, dim=-1) # exp(-inf)=0 → renormalized over top-k
s_probs, _ = torch.sort(probs, dim=-1, descending=True)
cum = torch.cumsum(s_probs, dim=-1)
keep = (cum - s_probs) <= top_p
thresh = torch.where(keep, s_probs, s_probs.new_tensor(float("inf"))).amin(
dim=-1, keepdim=True
)
scaled = torch.where(probs >= thresh, scaled, scaled.new_tensor(float("-inf")))
# ── Gumbel-max ──
if seed is None:
u = torch.rand(scaled.shape)
else:
gen = torch.Generator().manual_seed(int(seed.item()))
u = torch.rand(scaled.shape, generator=gen)
gumbel = -torch.log(-torch.log(u))
return torch.argmax(scaled + gumbel, dim=-1)

Comment threadbackends/mlx/test/test_sample.py Outdated

def test_top_k_restricts_to_top_k(self):
# probs [0.5, 0.3, 0.15, 0.05]; top_k=2 keeps {0,1}.
base = torch.log(torch.tensor([0.5, 0.3, 0.15, 0.05]))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we have test case in non-sorted order, e.g., 0.15, 0.5, .05, 0.3?

Comment threadbackends/mlx/ops.py Outdated
out=P.slot_to_tid(drop),
)
)
if top_k is not None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's keep top_k specified at runtime, with default value int max (select everything)

Comment threadbackends/mlx/ops.py Outdated
)
if top_k is not None:
_, top_k_val = P.make_tmp_value_slot()
P.emit(ItemIntNode(x=P.slot_to_tid(top_k), out=P.slot_to_vid(top_k_val)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's do Int clipping to vocab size if topk > vocab size.

Comment threadbackends/mlx/ops.py Outdated
if top_k is not None:
_, top_k_val = P.make_tmp_value_slot()
P.emit(ItemIntNode(x=P.slot_to_tid(top_k), out=P.slot_to_vid(top_k_val)))
top_k_iov = P.to_int_or_vid(top_k_val)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

top_k_val is already intorvid?

@metascroy

Copy link
Copy Markdown
Contributor

@claude review this code

@claude

claudeBot commented Jun 27, 2026

Copy link
Copy Markdown

Claude finished @metascroy's task in 3m 58s —— View job


Code review: Add top-k support to MLX sample

I read through all five changed files plus the emit path in ops.py and the helpers it relies on. Scott's inline comments already cover the main direction; below I confirm them with a concrete numeric case and add a few additional findings.

1. Top-k / top-p composition diverges from HuggingFace semantics (correctness) — confirms Scott's sampling.py:41 comment

Both the reference op (custom_ops.py) and the emit path (ops.py) compute the top-k mask and the top-p mask independently on the same un-renormalized probs and then OR the drop masks together. HF does top-k first, renormalizes, then applies top-p to the renormalized distribution. These are not equivalent. Worked example, probs = [0.5, 0.3, 0.15, 0.05], top_k=3, top_p=0.83:

  • This PR: top-k keeps {0,1,2}, top-p (prefix mass [0, 0.5, 0.8, 0.95] ≤ 0.83) keeps {0,1,2} → final keep {0,1,2}.
  • HF: top-k → renormalize over 0.95 → [0.526, 0.316, 0.158]; top-p on that (prefix [0, 0.526, 0.842] ≤ 0.83) keeps {0,1} → final keep {0,1}.

So the result genuinely differs once both filters are active. Scott's suggested sample() rewrite (top-k on logits → softmax/renormalize → top-p on the renormalized probs) is the fix, and the same restructuring is needed on the emit path — the current LogicalOrNode combine mirrors the reference's OR-compose, so both must move to sequential top-k-then-renormalize-then-top-p. The new test_top_k_and_top_p_compose test passes only because the chosen values happen to agree under both semantics; a case like the one above would catch the divergence.

2. top_k > vocab_size is an out-of-bounds bug (correctness) — relates to Scott's ops.py:3681 clipping comment

custom_ops.py:431:

k=int(top_k.item())
kth=s_probs[..., k-1 : k]

If k > vocab, the slice [k-1:k] is empty, so probs >= kth broadcasts against a size-0 tensor and the mask is wrong/raises. On the emit path the corresponding TakeNode(index = top_k - 1) indexes out of bounds. Clipping k = min(k, vocab) (Scott's point) fixes both. Worth a test with top_k larger than the vocab to lock this in.

3. top_k as runtime default instead of Optional — Scott's ops.py:3679 comment

Making top_k always-present with a default of INT_MAX (clipped to vocab per #2) removes the if top_k is not None branch in both custom_ops.py and the _sample_handler emit block, and the is not None checks in sampling.py. That collapses two code paths into one and naturally yields "keep everything" when unset.

4. Argument ordering — Scott's sampling.py:41 comment

Reorder to temperature, top_k, top_p, seed consistently across mlx::sample, sample_fake, _sample_handler's args[...] unpacking, and the _sample test helper. Note this is a signature change to the registered custom op, so every call site and the require_args(args, 3, 5, ...) indices need to move together.

5. to_int_or_vid already handles the literal/vid split — Scott's ops.py:3682 comment

ops.py:3688-3692:

index=(
IntOrVidOrTid.from_vid(top_k_index.vid)
iftop_k_index.is_videlseIntOrVidOrTid.from_literal(top_k_index.literal)
)

top_k_val comes from ItemIntNode so it's always a Vid here — top_k_iov/top_k_index will never be a literal in this path, making the else branch dead. P.to_int_or_vid_or_tid(top_k_val) (program_builder.py:318) already does this conversion in one call; prefer it over the manual ternary.

6. Test coverage — Scott's test_sample.py:165 comment

  • Add a non-sorted-logits case (e.g. probs 0.15, 0.5, 0.05, 0.3) so the test exercises the sort path rather than already-descending input.
  • Add a top_k > vocab case (Re-sync with internal repository #2) and a combined case where HF vs OR-compose semantics actually differ (Add support for quantized LeakyReLU #1), so the renormalization fix is verified rather than coincidentally passing.

Minor

  • custom_ops.py:431int(top_k.item()) is fine for the eager/meta reference, but confirm the .item() data-dependent read doesn't trip export's symbolic tracing on this op (the seed path already uses ItemIntNode on device, so the device side is fine).
  • Docstring in sampling.py still reads cleanly, but once Rename _pt2e to pt2e #3 lands ("None disables top-k") the wording should reflect the always-present default instead.

Overall the lowering scaffolding (Take → ExpandDims → Less → LogicalOr) is well-built and the node-count test is a nice touch; the substantive work is switching both the reference and emit paths to HF-style sequential filtering and adding the vocab-size clip.
· branch fix-mlx-sample-top-k

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

thanks for your quick review @metascroy will take a look and address them.

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@metascroy Addressed below changes in the latest commit.

  • Reordered the custom op args to temperature, top_k, top_p, seed.
  • Switched sampling to top-k first.. and then top-p on the renormalized probabilities.
  • Made the default top_k path use a max-int sentinel clipped to vocab size.
  • Removed the independent OR-composed top-k/top-p masks in the emit path.
  • Added coverage for non-sorted logits, top_k > vocab, and the combined top-k/top-p case where renormalization changes the result.
    let me know if you have any more suggestions. thanks!

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@claude review this code

1 similar comment
@metascroy

Copy link
Copy Markdown
Contributor

@claude review this code

@claude

claudeBot commented Jun 29, 2026

Copy link
Copy Markdown

Claude finished @metascroy's task in 2m 11s —— View job


Code review: top-k support for MLX sample (updated)

I re-read all five changed files plus the _sample_handler emit path. The latest commit cleanly addresses every point from the prior round. Summary below.

Prior review items — all resolved ✅

#ItemStatus
1HF-style sequential filtering (top-k → renormalize → top-p)custom_ops.py:430-444 does top-k on logits, then softmax (renormalizes over the kept set), then top-p. Emit path mirrors this: top-k WhereNode feeds the SoftmaxNode at ops.py:3695, and the old LogicalOrNode OR-compose is gone (test asserts "LogicalOrNode": 0).
2top_k > vocab clippingmin(int(top_k.item()), scaled.shape[-1]) (custom_ops.py:430) and MinimumNode against vocab (ops.py:3622-3628).
3top_k runtime arg with int-max defaultsampling.py:40-43 defaults to torch.iinfo(torch.int64).max; the op signature now always takes top_k.
4Arg ordering temperature, top_k, top_p, seed✅ Consistent across sample, sample_fake, _sample_handler unpacking, and the _sample test helper. require_args(args, 4, 5, ...) updated.
5Use to_int_or_vid_or_tidops.py:3660 now calls P.to_int_or_vid_or_tid(top_k_index) directly — dead literal branch removed.
6Test coverage✅ Added non-sorted input (test_top_k_restricts_to_top_k), top_k > vocab (test_top_k_clips_to_vocab_size), and a true HF-vs-OR divergence case (test_top_k_and_top_p_compose, top_k=3, top_p=0.83 → {0,1}). Plus an end-to-end on-device top-k test and a SampleTopKTest node-count case.

I worked the test_top_k_and_top_p_compose numbers by hand: top_k=3 keeps {0.5,0.3,0.15}, renormalizes to [0.526,0.316,0.158], and top-p=0.83 (prefix [0,0.526,0.842] ≤ 0.83) keeps {0,1}. This is the case that would have failed under the old OR-compose, so the renormalization fix is genuinely exercised now, not coincidentally passing.

Minor observations (non-blocking)

  1. custom_ops.py:432 empty-slice edge when k=0.k is clipped above by vocab but not below. A top_k=0 would make s_scaled[..., -1:0] empty and broadcast oddly. This isn't reachable through SamplingHead (default is int-max, callers pass ≥1), so it's only a concern if the op is called directly with 0. A max(k, 1) clamp or a one-line docstring note that top_k ≥ 1 is expected would close it. Not worth blocking on.

  2. Emit-path top-k threshold vs. reference — confirmed equivalent. The reference keeps scaled >= kth (>=), while the emit path drops via LessNode(scaled, top_k_thresh) i.e. keeps scaled >= thresh too — consistent. Good. The negate→sort→negate dance to get a descending sort (since SortNode is ascending-only) and the TakeNode(index = k-1) correctly pick the k-th largest. Matches the top-p chain's existing pattern.

  3. int(top_k.item()) data-dependent read (custom_ops.py:430): fine for the eager/meta reference; the device path uses ItemIntNode so symbolic tracing is unaffected, same as the existing seed handling.

  4. Buffer-reuse comments in ops.py are accurate (verified scaled/probs/sorted_p live-range notes against their actual reads).

Verdict

Looks good to me — the substantive correctness fix (sequential HF-style filtering + vocab clip) is in on both the reference and the lowered graph, and the tests lock in the behaviors that distinguish it from the old implementation. I couldn't execute the test suite in this sandbox (command approval), so please confirm the documented unittest + compileall runs are green in CI before merging. Only the k=0 clamp (#1) is worth a quick thought; everything else is optional polish.
· branch fix-mlx-sample-top-k

@metascroy

Copy link
Copy Markdown
Contributor

Looks great! Thanks for the contribution @goutamadwant!

@metascroy
metascroy merged commit 035b45a into pytorch:mainJun 29, 2026
190 of 191 checks passed
@nil-is-allnil-is-all added the module: mlx Issues related to MLX Backend: Metal-accelerated inference on Apple Silicon label Jul 6, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.module: mlxIssues related to MLX Backend: Metal-accelerated inference on Apple Siliconrelease notes: llmChanges to llm utilities

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Good First Issue: top-k filtering for mlx::sample (MLX backend)

3 participants

@goutamadwant@metascroy@nil-is-all
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Add top-k support to MLX sample - #20564

Merged
metascroy merged 4 commits into
pytorch:mainfrom
goutamadwant:fix-mlx-sample-top-k
Jun 29, 2026
Merged

Add top-k support to MLX sample#20564
metascroy merged 4 commits into
pytorch:mainfrom
goutamadwant:fix-mlx-sample-top-k

Conversation

@goutamadwant

@goutamadwantgoutamadwant commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Fixes#20548

Summary

  • Thread optional top_k through SamplingHead and mlx::sample.
  • Apply top-k filtering as an additional threshold mask that composes with the existing top-p nucleus mask.
  • Add eager, export, end-to-end, and MLX lowering coverage for top-k sampling.

Test plan

  • PYTHONPATH=src:. python3 -m unittest executorch.backends.mlx.test.test_sample.TestSampleOp executorch.backends.mlx.test.test_sample.TestSampleExport
  • python3 -m compileall -q backends/mlx/llm/sampling.py backends/mlx/custom_ops.py backends/mlx/ops.py backends/mlx/test/test_sample.py backends/mlx/test/test_ops.py

cc @metascroy

@pytorch-bot

pytorch-botBot commented Jun 27, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20564

Note: Links to docs will display an error until the docs builds have been completed.

❌ 1 New Failure, 12 Pending

As of commit 7e9ae35 with merge base 55a71e6 (image):

NEW FAILURE - The following job has failed:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 27, 2026
@linux-foundation-easycla

linux-foundation-easyclaBot commented Jun 27, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

  • ✅ login: goutamadwant / name: goutamadwant (c944a5a)

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@pytorchbot label "release notes: llm"

Comment threadbackends/mlx/llm/sampling.py Outdated
return torch.ops.mlx.sample(last, temperature, top_p, seed)
if top_k is not None and not isinstance(top_k, torch.Tensor):
top_k = torch.tensor(int(top_k), dtype=torch.int64)
return torch.ops.mlx.sample(last, temperature, top_p, seed, top_k)

@metascroymetascroyJun 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: Can we reorder the args here to be temp, top_k, top_p, seed?

We also need to modify this custom ops behavior to work with top_k correctly now.

Do huggingface style, where topk happens before topp, which requires renormalization, roughly something like this.

Something similar is required on the emit path.

def sample(logits, temperature, top_k=None, top_p=1.0, seed=None):
if float(temperature) <= 0:
return torch.argmax(logits, dim=-1)
scaled = logits.float() / temperature
# ── Top-k FIRST (on logits; monotonicity ⇒ top-k logits = top-k probs) ──
if top_k is not None:
k = int(top_k.item())
s_scaled, _ = torch.sort(scaled, dim=-1, descending=True)
kth = s_scaled[..., k - 1 : k]
scaled = torch.where(scaled >= kth, scaled, scaled.new_tensor(float("-inf")))
# ── Top-p on the *renormalized* distribution ──
probs = torch.softmax(scaled, dim=-1) # exp(-inf)=0 → renormalized over top-k
s_probs, _ = torch.sort(probs, dim=-1, descending=True)
cum = torch.cumsum(s_probs, dim=-1)
keep = (cum - s_probs) <= top_p
thresh = torch.where(keep, s_probs, s_probs.new_tensor(float("inf"))).amin(
dim=-1, keepdim=True
)
scaled = torch.where(probs >= thresh, scaled, scaled.new_tensor(float("-inf")))
# ── Gumbel-max ──
if seed is None:
u = torch.rand(scaled.shape)
else:
gen = torch.Generator().manual_seed(int(seed.item()))
u = torch.rand(scaled.shape, generator=gen)
gumbel = -torch.log(-torch.log(u))
return torch.argmax(scaled + gumbel, dim=-1)

Comment threadbackends/mlx/test/test_sample.py Outdated

def test_top_k_restricts_to_top_k(self):
# probs [0.5, 0.3, 0.15, 0.05]; top_k=2 keeps {0,1}.
base = torch.log(torch.tensor([0.5, 0.3, 0.15, 0.05]))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we have test case in non-sorted order, e.g., 0.15, 0.5, .05, 0.3?

Comment threadbackends/mlx/ops.py Outdated
out=P.slot_to_tid(drop),
)
)
if top_k is not None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's keep top_k specified at runtime, with default value int max (select everything)

Comment threadbackends/mlx/ops.py Outdated
)
if top_k is not None:
_, top_k_val = P.make_tmp_value_slot()
P.emit(ItemIntNode(x=P.slot_to_tid(top_k), out=P.slot_to_vid(top_k_val)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's do Int clipping to vocab size if topk > vocab size.

Comment threadbackends/mlx/ops.py Outdated
if top_k is not None:
_, top_k_val = P.make_tmp_value_slot()
P.emit(ItemIntNode(x=P.slot_to_tid(top_k), out=P.slot_to_vid(top_k_val)))
top_k_iov = P.to_int_or_vid(top_k_val)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

top_k_val is already intorvid?

@metascroy

Copy link
Copy Markdown
Contributor

@claude review this code

@claude

claudeBot commented Jun 27, 2026

Copy link
Copy Markdown

Claude finished @metascroy's task in 3m 58s —— View job


Code review: Add top-k support to MLX sample

I read through all five changed files plus the emit path in ops.py and the helpers it relies on. Scott's inline comments already cover the main direction; below I confirm them with a concrete numeric case and add a few additional findings.

1. Top-k / top-p composition diverges from HuggingFace semantics (correctness) — confirms Scott's sampling.py:41 comment

Both the reference op (custom_ops.py) and the emit path (ops.py) compute the top-k mask and the top-p mask independently on the same un-renormalized probs and then OR the drop masks together. HF does top-k first, renormalizes, then applies top-p to the renormalized distribution. These are not equivalent. Worked example, probs = [0.5, 0.3, 0.15, 0.05], top_k=3, top_p=0.83:

  • This PR: top-k keeps {0,1,2}, top-p (prefix mass [0, 0.5, 0.8, 0.95] ≤ 0.83) keeps {0,1,2} → final keep {0,1,2}.
  • HF: top-k → renormalize over 0.95 → [0.526, 0.316, 0.158]; top-p on that (prefix [0, 0.526, 0.842] ≤ 0.83) keeps {0,1} → final keep {0,1}.

So the result genuinely differs once both filters are active. Scott's suggested sample() rewrite (top-k on logits → softmax/renormalize → top-p on the renormalized probs) is the fix, and the same restructuring is needed on the emit path — the current LogicalOrNode combine mirrors the reference's OR-compose, so both must move to sequential top-k-then-renormalize-then-top-p. The new test_top_k_and_top_p_compose test passes only because the chosen values happen to agree under both semantics; a case like the one above would catch the divergence.

2. top_k > vocab_size is an out-of-bounds bug (correctness) — relates to Scott's ops.py:3681 clipping comment

custom_ops.py:431:

k=int(top_k.item())
kth=s_probs[..., k-1 : k]

If k > vocab, the slice [k-1:k] is empty, so probs >= kth broadcasts against a size-0 tensor and the mask is wrong/raises. On the emit path the corresponding TakeNode(index = top_k - 1) indexes out of bounds. Clipping k = min(k, vocab) (Scott's point) fixes both. Worth a test with top_k larger than the vocab to lock this in.

3. top_k as runtime default instead of Optional — Scott's ops.py:3679 comment

Making top_k always-present with a default of INT_MAX (clipped to vocab per #2) removes the if top_k is not None branch in both custom_ops.py and the _sample_handler emit block, and the is not None checks in sampling.py. That collapses two code paths into one and naturally yields "keep everything" when unset.

4. Argument ordering — Scott's sampling.py:41 comment

Reorder to temperature, top_k, top_p, seed consistently across mlx::sample, sample_fake, _sample_handler's args[...] unpacking, and the _sample test helper. Note this is a signature change to the registered custom op, so every call site and the require_args(args, 3, 5, ...) indices need to move together.

5. to_int_or_vid already handles the literal/vid split — Scott's ops.py:3682 comment

ops.py:3688-3692:

index=(
IntOrVidOrTid.from_vid(top_k_index.vid)
iftop_k_index.is_videlseIntOrVidOrTid.from_literal(top_k_index.literal)
)

top_k_val comes from ItemIntNode so it's always a Vid here — top_k_iov/top_k_index will never be a literal in this path, making the else branch dead. P.to_int_or_vid_or_tid(top_k_val) (program_builder.py:318) already does this conversion in one call; prefer it over the manual ternary.

6. Test coverage — Scott's test_sample.py:165 comment

  • Add a non-sorted-logits case (e.g. probs 0.15, 0.5, 0.05, 0.3) so the test exercises the sort path rather than already-descending input.
  • Add a top_k > vocab case (Re-sync with internal repository #2) and a combined case where HF vs OR-compose semantics actually differ (Add support for quantized LeakyReLU #1), so the renormalization fix is verified rather than coincidentally passing.

Minor

  • custom_ops.py:431int(top_k.item()) is fine for the eager/meta reference, but confirm the .item() data-dependent read doesn't trip export's symbolic tracing on this op (the seed path already uses ItemIntNode on device, so the device side is fine).
  • Docstring in sampling.py still reads cleanly, but once Rename _pt2e to pt2e #3 lands ("None disables top-k") the wording should reflect the always-present default instead.

Overall the lowering scaffolding (Take → ExpandDims → Less → LogicalOr) is well-built and the node-count test is a nice touch; the substantive work is switching both the reference and emit paths to HF-style sequential filtering and adding the vocab-size clip.
· branch fix-mlx-sample-top-k

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

thanks for your quick review @metascroy will take a look and address them.

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@metascroy Addressed below changes in the latest commit.

  • Reordered the custom op args to temperature, top_k, top_p, seed.
  • Switched sampling to top-k first.. and then top-p on the renormalized probabilities.
  • Made the default top_k path use a max-int sentinel clipped to vocab size.
  • Removed the independent OR-composed top-k/top-p masks in the emit path.
  • Added coverage for non-sorted logits, top_k > vocab, and the combined top-k/top-p case where renormalization changes the result.
    let me know if you have any more suggestions. thanks!

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@claude review this code

1 similar comment
@metascroy

Copy link
Copy Markdown
Contributor

@claude review this code

@claude

claudeBot commented Jun 29, 2026

Copy link
Copy Markdown

Claude finished @metascroy's task in 2m 11s —— View job


Code review: top-k support for MLX sample (updated)

I re-read all five changed files plus the _sample_handler emit path. The latest commit cleanly addresses every point from the prior round. Summary below.

Prior review items — all resolved ✅

#ItemStatus
1HF-style sequential filtering (top-k → renormalize → top-p)custom_ops.py:430-444 does top-k on logits, then softmax (renormalizes over the kept set), then top-p. Emit path mirrors this: top-k WhereNode feeds the SoftmaxNode at ops.py:3695, and the old LogicalOrNode OR-compose is gone (test asserts "LogicalOrNode": 0).
2top_k > vocab clippingmin(int(top_k.item()), scaled.shape[-1]) (custom_ops.py:430) and MinimumNode against vocab (ops.py:3622-3628).
3top_k runtime arg with int-max defaultsampling.py:40-43 defaults to torch.iinfo(torch.int64).max; the op signature now always takes top_k.
4Arg ordering temperature, top_k, top_p, seed✅ Consistent across sample, sample_fake, _sample_handler unpacking, and the _sample test helper. require_args(args, 4, 5, ...) updated.
5Use to_int_or_vid_or_tidops.py:3660 now calls P.to_int_or_vid_or_tid(top_k_index) directly — dead literal branch removed.
6Test coverage✅ Added non-sorted input (test_top_k_restricts_to_top_k), top_k > vocab (test_top_k_clips_to_vocab_size), and a true HF-vs-OR divergence case (test_top_k_and_top_p_compose, top_k=3, top_p=0.83 → {0,1}). Plus an end-to-end on-device top-k test and a SampleTopKTest node-count case.

I worked the test_top_k_and_top_p_compose numbers by hand: top_k=3 keeps {0.5,0.3,0.15}, renormalizes to [0.526,0.316,0.158], and top-p=0.83 (prefix [0,0.526,0.842] ≤ 0.83) keeps {0,1}. This is the case that would have failed under the old OR-compose, so the renormalization fix is genuinely exercised now, not coincidentally passing.

Minor observations (non-blocking)

  1. custom_ops.py:432 empty-slice edge when k=0.k is clipped above by vocab but not below. A top_k=0 would make s_scaled[..., -1:0] empty and broadcast oddly. This isn't reachable through SamplingHead (default is int-max, callers pass ≥1), so it's only a concern if the op is called directly with 0. A max(k, 1) clamp or a one-line docstring note that top_k ≥ 1 is expected would close it. Not worth blocking on.

  2. Emit-path top-k threshold vs. reference — confirmed equivalent. The reference keeps scaled >= kth (>=), while the emit path drops via LessNode(scaled, top_k_thresh) i.e. keeps scaled >= thresh too — consistent. Good. The negate→sort→negate dance to get a descending sort (since SortNode is ascending-only) and the TakeNode(index = k-1) correctly pick the k-th largest. Matches the top-p chain's existing pattern.

  3. int(top_k.item()) data-dependent read (custom_ops.py:430): fine for the eager/meta reference; the device path uses ItemIntNode so symbolic tracing is unaffected, same as the existing seed handling.

  4. Buffer-reuse comments in ops.py are accurate (verified scaled/probs/sorted_p live-range notes against their actual reads).

Verdict

Looks good to me — the substantive correctness fix (sequential HF-style filtering + vocab clip) is in on both the reference and the lowered graph, and the tests lock in the behaviors that distinguish it from the old implementation. I couldn't execute the test suite in this sandbox (command approval), so please confirm the documented unittest + compileall runs are green in CI before merging. Only the k=0 clamp (#1) is worth a quick thought; everything else is optional polish.
· branch fix-mlx-sample-top-k

@metascroy

Copy link
Copy Markdown
Contributor

Looks great! Thanks for the contribution @goutamadwant!

@metascroy
metascroy merged commit 035b45a into pytorch:mainJun 29, 2026
190 of 191 checks passed
@nil-is-allnil-is-all added the module: mlx Issues related to MLX Backend: Metal-accelerated inference on Apple Silicon label Jul 6, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.module: mlxIssues related to MLX Backend: Metal-accelerated inference on Apple Siliconrelease notes: llmChanges to llm utilities

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Good First Issue: top-k filtering for mlx::sample (MLX backend)

3 participants

@goutamadwant@metascroy@nil-is-all
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add top-k support to MLX sample - #20564

Merged
metascroy merged 4 commits into
pytorch:mainfrom
goutamadwant:fix-mlx-sample-top-k
Jun 29, 2026
Merged

Add top-k support to MLX sample#20564
metascroy merged 4 commits into
pytorch:mainfrom
goutamadwant:fix-mlx-sample-top-k

Conversation

@goutamadwant

@goutamadwantgoutamadwant commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Fixes#20548

Summary

  • Thread optional top_k through SamplingHead and mlx::sample.
  • Apply top-k filtering as an additional threshold mask that composes with the existing top-p nucleus mask.
  • Add eager, export, end-to-end, and MLX lowering coverage for top-k sampling.

Test plan

  • PYTHONPATH=src:. python3 -m unittest executorch.backends.mlx.test.test_sample.TestSampleOp executorch.backends.mlx.test.test_sample.TestSampleExport
  • python3 -m compileall -q backends/mlx/llm/sampling.py backends/mlx/custom_ops.py backends/mlx/ops.py backends/mlx/test/test_sample.py backends/mlx/test/test_ops.py

cc @metascroy

@pytorch-bot

pytorch-botBot commented Jun 27, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20564

Note: Links to docs will display an error until the docs builds have been completed.

❌ 1 New Failure, 12 Pending

As of commit 7e9ae35 with merge base 55a71e6 (image):

NEW FAILURE - The following job has failed:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 27, 2026
@linux-foundation-easycla

linux-foundation-easyclaBot commented Jun 27, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

  • ✅ login: goutamadwant / name: goutamadwant (c944a5a)

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@pytorchbot label "release notes: llm"

Comment threadbackends/mlx/llm/sampling.py Outdated
return torch.ops.mlx.sample(last, temperature, top_p, seed)
if top_k is not None and not isinstance(top_k, torch.Tensor):
top_k = torch.tensor(int(top_k), dtype=torch.int64)
return torch.ops.mlx.sample(last, temperature, top_p, seed, top_k)

@metascroymetascroyJun 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: Can we reorder the args here to be temp, top_k, top_p, seed?

We also need to modify this custom ops behavior to work with top_k correctly now.

Do huggingface style, where topk happens before topp, which requires renormalization, roughly something like this.

Something similar is required on the emit path.

def sample(logits, temperature, top_k=None, top_p=1.0, seed=None):
if float(temperature) <= 0:
return torch.argmax(logits, dim=-1)
scaled = logits.float() / temperature
# ── Top-k FIRST (on logits; monotonicity ⇒ top-k logits = top-k probs) ──
if top_k is not None:
k = int(top_k.item())
s_scaled, _ = torch.sort(scaled, dim=-1, descending=True)
kth = s_scaled[..., k - 1 : k]
scaled = torch.where(scaled >= kth, scaled, scaled.new_tensor(float("-inf")))
# ── Top-p on the *renormalized* distribution ──
probs = torch.softmax(scaled, dim=-1) # exp(-inf)=0 → renormalized over top-k
s_probs, _ = torch.sort(probs, dim=-1, descending=True)
cum = torch.cumsum(s_probs, dim=-1)
keep = (cum - s_probs) <= top_p
thresh = torch.where(keep, s_probs, s_probs.new_tensor(float("inf"))).amin(
dim=-1, keepdim=True
)
scaled = torch.where(probs >= thresh, scaled, scaled.new_tensor(float("-inf")))
# ── Gumbel-max ──
if seed is None:
u = torch.rand(scaled.shape)
else:
gen = torch.Generator().manual_seed(int(seed.item()))
u = torch.rand(scaled.shape, generator=gen)
gumbel = -torch.log(-torch.log(u))
return torch.argmax(scaled + gumbel, dim=-1)

Comment threadbackends/mlx/test/test_sample.py Outdated

def test_top_k_restricts_to_top_k(self):
# probs [0.5, 0.3, 0.15, 0.05]; top_k=2 keeps {0,1}.
base = torch.log(torch.tensor([0.5, 0.3, 0.15, 0.05]))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we have test case in non-sorted order, e.g., 0.15, 0.5, .05, 0.3?

Comment threadbackends/mlx/ops.py Outdated
out=P.slot_to_tid(drop),
)
)
if top_k is not None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's keep top_k specified at runtime, with default value int max (select everything)

Comment threadbackends/mlx/ops.py Outdated
)
if top_k is not None:
_, top_k_val = P.make_tmp_value_slot()
P.emit(ItemIntNode(x=P.slot_to_tid(top_k), out=P.slot_to_vid(top_k_val)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's do Int clipping to vocab size if topk > vocab size.

Comment threadbackends/mlx/ops.py Outdated
if top_k is not None:
_, top_k_val = P.make_tmp_value_slot()
P.emit(ItemIntNode(x=P.slot_to_tid(top_k), out=P.slot_to_vid(top_k_val)))
top_k_iov = P.to_int_or_vid(top_k_val)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

top_k_val is already intorvid?

@metascroy

Copy link
Copy Markdown
Contributor

@claude review this code

@claude

claudeBot commented Jun 27, 2026

Copy link
Copy Markdown

Claude finished @metascroy's task in 3m 58s —— View job


Code review: Add top-k support to MLX sample

I read through all five changed files plus the emit path in ops.py and the helpers it relies on. Scott's inline comments already cover the main direction; below I confirm them with a concrete numeric case and add a few additional findings.

1. Top-k / top-p composition diverges from HuggingFace semantics (correctness) — confirms Scott's sampling.py:41 comment

Both the reference op (custom_ops.py) and the emit path (ops.py) compute the top-k mask and the top-p mask independently on the same un-renormalized probs and then OR the drop masks together. HF does top-k first, renormalizes, then applies top-p to the renormalized distribution. These are not equivalent. Worked example, probs = [0.5, 0.3, 0.15, 0.05], top_k=3, top_p=0.83:

  • This PR: top-k keeps {0,1,2}, top-p (prefix mass [0, 0.5, 0.8, 0.95] ≤ 0.83) keeps {0,1,2} → final keep {0,1,2}.
  • HF: top-k → renormalize over 0.95 → [0.526, 0.316, 0.158]; top-p on that (prefix [0, 0.526, 0.842] ≤ 0.83) keeps {0,1} → final keep {0,1}.

So the result genuinely differs once both filters are active. Scott's suggested sample() rewrite (top-k on logits → softmax/renormalize → top-p on the renormalized probs) is the fix, and the same restructuring is needed on the emit path — the current LogicalOrNode combine mirrors the reference's OR-compose, so both must move to sequential top-k-then-renormalize-then-top-p. The new test_top_k_and_top_p_compose test passes only because the chosen values happen to agree under both semantics; a case like the one above would catch the divergence.

2. top_k > vocab_size is an out-of-bounds bug (correctness) — relates to Scott's ops.py:3681 clipping comment

custom_ops.py:431:

k=int(top_k.item())
kth=s_probs[..., k-1 : k]

If k > vocab, the slice [k-1:k] is empty, so probs >= kth broadcasts against a size-0 tensor and the mask is wrong/raises. On the emit path the corresponding TakeNode(index = top_k - 1) indexes out of bounds. Clipping k = min(k, vocab) (Scott's point) fixes both. Worth a test with top_k larger than the vocab to lock this in.

3. top_k as runtime default instead of Optional — Scott's ops.py:3679 comment

Making top_k always-present with a default of INT_MAX (clipped to vocab per #2) removes the if top_k is not None branch in both custom_ops.py and the _sample_handler emit block, and the is not None checks in sampling.py. That collapses two code paths into one and naturally yields "keep everything" when unset.

4. Argument ordering — Scott's sampling.py:41 comment

Reorder to temperature, top_k, top_p, seed consistently across mlx::sample, sample_fake, _sample_handler's args[...] unpacking, and the _sample test helper. Note this is a signature change to the registered custom op, so every call site and the require_args(args, 3, 5, ...) indices need to move together.

5. to_int_or_vid already handles the literal/vid split — Scott's ops.py:3682 comment

ops.py:3688-3692:

index=(
IntOrVidOrTid.from_vid(top_k_index.vid)
iftop_k_index.is_videlseIntOrVidOrTid.from_literal(top_k_index.literal)
)

top_k_val comes from ItemIntNode so it's always a Vid here — top_k_iov/top_k_index will never be a literal in this path, making the else branch dead. P.to_int_or_vid_or_tid(top_k_val) (program_builder.py:318) already does this conversion in one call; prefer it over the manual ternary.

6. Test coverage — Scott's test_sample.py:165 comment

  • Add a non-sorted-logits case (e.g. probs 0.15, 0.5, 0.05, 0.3) so the test exercises the sort path rather than already-descending input.
  • Add a top_k > vocab case (Re-sync with internal repository #2) and a combined case where HF vs OR-compose semantics actually differ (Add support for quantized LeakyReLU #1), so the renormalization fix is verified rather than coincidentally passing.

Minor

  • custom_ops.py:431int(top_k.item()) is fine for the eager/meta reference, but confirm the .item() data-dependent read doesn't trip export's symbolic tracing on this op (the seed path already uses ItemIntNode on device, so the device side is fine).
  • Docstring in sampling.py still reads cleanly, but once Rename _pt2e to pt2e #3 lands ("None disables top-k") the wording should reflect the always-present default instead.

Overall the lowering scaffolding (Take → ExpandDims → Less → LogicalOr) is well-built and the node-count test is a nice touch; the substantive work is switching both the reference and emit paths to HF-style sequential filtering and adding the vocab-size clip.
· branch fix-mlx-sample-top-k

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

thanks for your quick review @metascroy will take a look and address them.

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@metascroy Addressed below changes in the latest commit.

  • Reordered the custom op args to temperature, top_k, top_p, seed.
  • Switched sampling to top-k first.. and then top-p on the renormalized probabilities.
  • Made the default top_k path use a max-int sentinel clipped to vocab size.
  • Removed the independent OR-composed top-k/top-p masks in the emit path.
  • Added coverage for non-sorted logits, top_k > vocab, and the combined top-k/top-p case where renormalization changes the result.
    let me know if you have any more suggestions. thanks!

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@claude review this code

1 similar comment
@metascroy

Copy link
Copy Markdown
Contributor

@claude review this code

@claude

claudeBot commented Jun 29, 2026

Copy link
Copy Markdown

Claude finished @metascroy's task in 2m 11s —— View job


Code review: top-k support for MLX sample (updated)

I re-read all five changed files plus the _sample_handler emit path. The latest commit cleanly addresses every point from the prior round. Summary below.

Prior review items — all resolved ✅

#ItemStatus
1HF-style sequential filtering (top-k → renormalize → top-p)custom_ops.py:430-444 does top-k on logits, then softmax (renormalizes over the kept set), then top-p. Emit path mirrors this: top-k WhereNode feeds the SoftmaxNode at ops.py:3695, and the old LogicalOrNode OR-compose is gone (test asserts "LogicalOrNode": 0).
2top_k > vocab clippingmin(int(top_k.item()), scaled.shape[-1]) (custom_ops.py:430) and MinimumNode against vocab (ops.py:3622-3628).
3top_k runtime arg with int-max defaultsampling.py:40-43 defaults to torch.iinfo(torch.int64).max; the op signature now always takes top_k.
4Arg ordering temperature, top_k, top_p, seed✅ Consistent across sample, sample_fake, _sample_handler unpacking, and the _sample test helper. require_args(args, 4, 5, ...) updated.
5Use to_int_or_vid_or_tidops.py:3660 now calls P.to_int_or_vid_or_tid(top_k_index) directly — dead literal branch removed.
6Test coverage✅ Added non-sorted input (test_top_k_restricts_to_top_k), top_k > vocab (test_top_k_clips_to_vocab_size), and a true HF-vs-OR divergence case (test_top_k_and_top_p_compose, top_k=3, top_p=0.83 → {0,1}). Plus an end-to-end on-device top-k test and a SampleTopKTest node-count case.

I worked the test_top_k_and_top_p_compose numbers by hand: top_k=3 keeps {0.5,0.3,0.15}, renormalizes to [0.526,0.316,0.158], and top-p=0.83 (prefix [0,0.526,0.842] ≤ 0.83) keeps {0,1}. This is the case that would have failed under the old OR-compose, so the renormalization fix is genuinely exercised now, not coincidentally passing.

Minor observations (non-blocking)

  1. custom_ops.py:432 empty-slice edge when k=0.k is clipped above by vocab but not below. A top_k=0 would make s_scaled[..., -1:0] empty and broadcast oddly. This isn't reachable through SamplingHead (default is int-max, callers pass ≥1), so it's only a concern if the op is called directly with 0. A max(k, 1) clamp or a one-line docstring note that top_k ≥ 1 is expected would close it. Not worth blocking on.

  2. Emit-path top-k threshold vs. reference — confirmed equivalent. The reference keeps scaled >= kth (>=), while the emit path drops via LessNode(scaled, top_k_thresh) i.e. keeps scaled >= thresh too — consistent. Good. The negate→sort→negate dance to get a descending sort (since SortNode is ascending-only) and the TakeNode(index = k-1) correctly pick the k-th largest. Matches the top-p chain's existing pattern.

  3. int(top_k.item()) data-dependent read (custom_ops.py:430): fine for the eager/meta reference; the device path uses ItemIntNode so symbolic tracing is unaffected, same as the existing seed handling.

  4. Buffer-reuse comments in ops.py are accurate (verified scaled/probs/sorted_p live-range notes against their actual reads).

Verdict

Looks good to me — the substantive correctness fix (sequential HF-style filtering + vocab clip) is in on both the reference and the lowered graph, and the tests lock in the behaviors that distinguish it from the old implementation. I couldn't execute the test suite in this sandbox (command approval), so please confirm the documented unittest + compileall runs are green in CI before merging. Only the k=0 clamp (#1) is worth a quick thought; everything else is optional polish.
· branch fix-mlx-sample-top-k

@metascroy

Copy link
Copy Markdown
Contributor

Looks great! Thanks for the contribution @goutamadwant!

@metascroy
metascroy merged commit 035b45a into pytorch:mainJun 29, 2026
190 of 191 checks passed
@nil-is-allnil-is-all added the module: mlx Issues related to MLX Backend: Metal-accelerated inference on Apple Silicon label Jul 6, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.module: mlxIssues related to MLX Backend: Metal-accelerated inference on Apple Siliconrelease notes: llmChanges to llm utilities

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Good First Issue: top-k filtering for mlx::sample (MLX backend)

3 participants

@goutamadwant@metascroy@nil-is-all
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add top-k support to MLX sample - #20564

Merged
metascroy merged 4 commits into
pytorch:mainfrom
goutamadwant:fix-mlx-sample-top-k
Jun 29, 2026
Merged

Add top-k support to MLX sample#20564
metascroy merged 4 commits into
pytorch:mainfrom
goutamadwant:fix-mlx-sample-top-k

Conversation

@goutamadwant

@goutamadwantgoutamadwant commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Fixes#20548

Summary

  • Thread optional top_k through SamplingHead and mlx::sample.
  • Apply top-k filtering as an additional threshold mask that composes with the existing top-p nucleus mask.
  • Add eager, export, end-to-end, and MLX lowering coverage for top-k sampling.

Test plan

  • PYTHONPATH=src:. python3 -m unittest executorch.backends.mlx.test.test_sample.TestSampleOp executorch.backends.mlx.test.test_sample.TestSampleExport
  • python3 -m compileall -q backends/mlx/llm/sampling.py backends/mlx/custom_ops.py backends/mlx/ops.py backends/mlx/test/test_sample.py backends/mlx/test/test_ops.py

cc @metascroy

@pytorch-bot

pytorch-botBot commented Jun 27, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20564

Note: Links to docs will display an error until the docs builds have been completed.

❌ 1 New Failure, 12 Pending

As of commit 7e9ae35 with merge base 55a71e6 (image):

NEW FAILURE - The following job has failed:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 27, 2026
@linux-foundation-easycla

linux-foundation-easyclaBot commented Jun 27, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

  • ✅ login: goutamadwant / name: goutamadwant (c944a5a)

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@pytorchbot label "release notes: llm"

Comment threadbackends/mlx/llm/sampling.py Outdated
return torch.ops.mlx.sample(last, temperature, top_p, seed)
if top_k is not None and not isinstance(top_k, torch.Tensor):
top_k = torch.tensor(int(top_k), dtype=torch.int64)
return torch.ops.mlx.sample(last, temperature, top_p, seed, top_k)

@metascroymetascroyJun 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: Can we reorder the args here to be temp, top_k, top_p, seed?

We also need to modify this custom ops behavior to work with top_k correctly now.

Do huggingface style, where topk happens before topp, which requires renormalization, roughly something like this.

Something similar is required on the emit path.

def sample(logits, temperature, top_k=None, top_p=1.0, seed=None):
if float(temperature) <= 0:
return torch.argmax(logits, dim=-1)
scaled = logits.float() / temperature
# ── Top-k FIRST (on logits; monotonicity ⇒ top-k logits = top-k probs) ──
if top_k is not None:
k = int(top_k.item())
s_scaled, _ = torch.sort(scaled, dim=-1, descending=True)
kth = s_scaled[..., k - 1 : k]
scaled = torch.where(scaled >= kth, scaled, scaled.new_tensor(float("-inf")))
# ── Top-p on the *renormalized* distribution ──
probs = torch.softmax(scaled, dim=-1) # exp(-inf)=0 → renormalized over top-k
s_probs, _ = torch.sort(probs, dim=-1, descending=True)
cum = torch.cumsum(s_probs, dim=-1)
keep = (cum - s_probs) <= top_p
thresh = torch.where(keep, s_probs, s_probs.new_tensor(float("inf"))).amin(
dim=-1, keepdim=True
)
scaled = torch.where(probs >= thresh, scaled, scaled.new_tensor(float("-inf")))
# ── Gumbel-max ──
if seed is None:
u = torch.rand(scaled.shape)
else:
gen = torch.Generator().manual_seed(int(seed.item()))
u = torch.rand(scaled.shape, generator=gen)
gumbel = -torch.log(-torch.log(u))
return torch.argmax(scaled + gumbel, dim=-1)

Comment threadbackends/mlx/test/test_sample.py Outdated

def test_top_k_restricts_to_top_k(self):
# probs [0.5, 0.3, 0.15, 0.05]; top_k=2 keeps {0,1}.
base = torch.log(torch.tensor([0.5, 0.3, 0.15, 0.05]))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we have test case in non-sorted order, e.g., 0.15, 0.5, .05, 0.3?

Comment threadbackends/mlx/ops.py Outdated
out=P.slot_to_tid(drop),
)
)
if top_k is not None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's keep top_k specified at runtime, with default value int max (select everything)

Comment threadbackends/mlx/ops.py Outdated
)
if top_k is not None:
_, top_k_val = P.make_tmp_value_slot()
P.emit(ItemIntNode(x=P.slot_to_tid(top_k), out=P.slot_to_vid(top_k_val)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's do Int clipping to vocab size if topk > vocab size.

Comment threadbackends/mlx/ops.py Outdated
if top_k is not None:
_, top_k_val = P.make_tmp_value_slot()
P.emit(ItemIntNode(x=P.slot_to_tid(top_k), out=P.slot_to_vid(top_k_val)))
top_k_iov = P.to_int_or_vid(top_k_val)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

top_k_val is already intorvid?

@metascroy

Copy link
Copy Markdown
Contributor

@claude review this code

@claude

claudeBot commented Jun 27, 2026

Copy link
Copy Markdown

Claude finished @metascroy's task in 3m 58s —— View job


Code review: Add top-k support to MLX sample

I read through all five changed files plus the emit path in ops.py and the helpers it relies on. Scott's inline comments already cover the main direction; below I confirm them with a concrete numeric case and add a few additional findings.

1. Top-k / top-p composition diverges from HuggingFace semantics (correctness) — confirms Scott's sampling.py:41 comment

Both the reference op (custom_ops.py) and the emit path (ops.py) compute the top-k mask and the top-p mask independently on the same un-renormalized probs and then OR the drop masks together. HF does top-k first, renormalizes, then applies top-p to the renormalized distribution. These are not equivalent. Worked example, probs = [0.5, 0.3, 0.15, 0.05], top_k=3, top_p=0.83:

  • This PR: top-k keeps {0,1,2}, top-p (prefix mass [0, 0.5, 0.8, 0.95] ≤ 0.83) keeps {0,1,2} → final keep {0,1,2}.
  • HF: top-k → renormalize over 0.95 → [0.526, 0.316, 0.158]; top-p on that (prefix [0, 0.526, 0.842] ≤ 0.83) keeps {0,1} → final keep {0,1}.

So the result genuinely differs once both filters are active. Scott's suggested sample() rewrite (top-k on logits → softmax/renormalize → top-p on the renormalized probs) is the fix, and the same restructuring is needed on the emit path — the current LogicalOrNode combine mirrors the reference's OR-compose, so both must move to sequential top-k-then-renormalize-then-top-p. The new test_top_k_and_top_p_compose test passes only because the chosen values happen to agree under both semantics; a case like the one above would catch the divergence.

2. top_k > vocab_size is an out-of-bounds bug (correctness) — relates to Scott's ops.py:3681 clipping comment

custom_ops.py:431:

k=int(top_k.item())
kth=s_probs[..., k-1 : k]

If k > vocab, the slice [k-1:k] is empty, so probs >= kth broadcasts against a size-0 tensor and the mask is wrong/raises. On the emit path the corresponding TakeNode(index = top_k - 1) indexes out of bounds. Clipping k = min(k, vocab) (Scott's point) fixes both. Worth a test with top_k larger than the vocab to lock this in.

3. top_k as runtime default instead of Optional — Scott's ops.py:3679 comment

Making top_k always-present with a default of INT_MAX (clipped to vocab per #2) removes the if top_k is not None branch in both custom_ops.py and the _sample_handler emit block, and the is not None checks in sampling.py. That collapses two code paths into one and naturally yields "keep everything" when unset.

4. Argument ordering — Scott's sampling.py:41 comment

Reorder to temperature, top_k, top_p, seed consistently across mlx::sample, sample_fake, _sample_handler's args[...] unpacking, and the _sample test helper. Note this is a signature change to the registered custom op, so every call site and the require_args(args, 3, 5, ...) indices need to move together.

5. to_int_or_vid already handles the literal/vid split — Scott's ops.py:3682 comment

ops.py:3688-3692:

index=(
IntOrVidOrTid.from_vid(top_k_index.vid)
iftop_k_index.is_videlseIntOrVidOrTid.from_literal(top_k_index.literal)
)

top_k_val comes from ItemIntNode so it's always a Vid here — top_k_iov/top_k_index will never be a literal in this path, making the else branch dead. P.to_int_or_vid_or_tid(top_k_val) (program_builder.py:318) already does this conversion in one call; prefer it over the manual ternary.

6. Test coverage — Scott's test_sample.py:165 comment

  • Add a non-sorted-logits case (e.g. probs 0.15, 0.5, 0.05, 0.3) so the test exercises the sort path rather than already-descending input.
  • Add a top_k > vocab case (Re-sync with internal repository #2) and a combined case where HF vs OR-compose semantics actually differ (Add support for quantized LeakyReLU #1), so the renormalization fix is verified rather than coincidentally passing.

Minor

  • custom_ops.py:431int(top_k.item()) is fine for the eager/meta reference, but confirm the .item() data-dependent read doesn't trip export's symbolic tracing on this op (the seed path already uses ItemIntNode on device, so the device side is fine).
  • Docstring in sampling.py still reads cleanly, but once Rename _pt2e to pt2e #3 lands ("None disables top-k") the wording should reflect the always-present default instead.

Overall the lowering scaffolding (Take → ExpandDims → Less → LogicalOr) is well-built and the node-count test is a nice touch; the substantive work is switching both the reference and emit paths to HF-style sequential filtering and adding the vocab-size clip.
· branch fix-mlx-sample-top-k

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

thanks for your quick review @metascroy will take a look and address them.

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@metascroy Addressed below changes in the latest commit.

  • Reordered the custom op args to temperature, top_k, top_p, seed.
  • Switched sampling to top-k first.. and then top-p on the renormalized probabilities.
  • Made the default top_k path use a max-int sentinel clipped to vocab size.
  • Removed the independent OR-composed top-k/top-p masks in the emit path.
  • Added coverage for non-sorted logits, top_k > vocab, and the combined top-k/top-p case where renormalization changes the result.
    let me know if you have any more suggestions. thanks!

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@claude review this code

1 similar comment
@metascroy

Copy link
Copy Markdown
Contributor

@claude review this code

@claude

claudeBot commented Jun 29, 2026

Copy link
Copy Markdown

Claude finished @metascroy's task in 2m 11s —— View job


Code review: top-k support for MLX sample (updated)

I re-read all five changed files plus the _sample_handler emit path. The latest commit cleanly addresses every point from the prior round. Summary below.

Prior review items — all resolved ✅

#ItemStatus
1HF-style sequential filtering (top-k → renormalize → top-p)custom_ops.py:430-444 does top-k on logits, then softmax (renormalizes over the kept set), then top-p. Emit path mirrors this: top-k WhereNode feeds the SoftmaxNode at ops.py:3695, and the old LogicalOrNode OR-compose is gone (test asserts "LogicalOrNode": 0).
2top_k > vocab clippingmin(int(top_k.item()), scaled.shape[-1]) (custom_ops.py:430) and MinimumNode against vocab (ops.py:3622-3628).
3top_k runtime arg with int-max defaultsampling.py:40-43 defaults to torch.iinfo(torch.int64).max; the op signature now always takes top_k.
4Arg ordering temperature, top_k, top_p, seed✅ Consistent across sample, sample_fake, _sample_handler unpacking, and the _sample test helper. require_args(args, 4, 5, ...) updated.
5Use to_int_or_vid_or_tidops.py:3660 now calls P.to_int_or_vid_or_tid(top_k_index) directly — dead literal branch removed.
6Test coverage✅ Added non-sorted input (test_top_k_restricts_to_top_k), top_k > vocab (test_top_k_clips_to_vocab_size), and a true HF-vs-OR divergence case (test_top_k_and_top_p_compose, top_k=3, top_p=0.83 → {0,1}). Plus an end-to-end on-device top-k test and a SampleTopKTest node-count case.

I worked the test_top_k_and_top_p_compose numbers by hand: top_k=3 keeps {0.5,0.3,0.15}, renormalizes to [0.526,0.316,0.158], and top-p=0.83 (prefix [0,0.526,0.842] ≤ 0.83) keeps {0,1}. This is the case that would have failed under the old OR-compose, so the renormalization fix is genuinely exercised now, not coincidentally passing.

Minor observations (non-blocking)

  1. custom_ops.py:432 empty-slice edge when k=0.k is clipped above by vocab but not below. A top_k=0 would make s_scaled[..., -1:0] empty and broadcast oddly. This isn't reachable through SamplingHead (default is int-max, callers pass ≥1), so it's only a concern if the op is called directly with 0. A max(k, 1) clamp or a one-line docstring note that top_k ≥ 1 is expected would close it. Not worth blocking on.

  2. Emit-path top-k threshold vs. reference — confirmed equivalent. The reference keeps scaled >= kth (>=), while the emit path drops via LessNode(scaled, top_k_thresh) i.e. keeps scaled >= thresh too — consistent. Good. The negate→sort→negate dance to get a descending sort (since SortNode is ascending-only) and the TakeNode(index = k-1) correctly pick the k-th largest. Matches the top-p chain's existing pattern.

  3. int(top_k.item()) data-dependent read (custom_ops.py:430): fine for the eager/meta reference; the device path uses ItemIntNode so symbolic tracing is unaffected, same as the existing seed handling.

  4. Buffer-reuse comments in ops.py are accurate (verified scaled/probs/sorted_p live-range notes against their actual reads).

Verdict

Looks good to me — the substantive correctness fix (sequential HF-style filtering + vocab clip) is in on both the reference and the lowered graph, and the tests lock in the behaviors that distinguish it from the old implementation. I couldn't execute the test suite in this sandbox (command approval), so please confirm the documented unittest + compileall runs are green in CI before merging. Only the k=0 clamp (#1) is worth a quick thought; everything else is optional polish.
· branch fix-mlx-sample-top-k

@metascroy

Copy link
Copy Markdown
Contributor

Looks great! Thanks for the contribution @goutamadwant!

@metascroy
metascroy merged commit 035b45a into pytorch:mainJun 29, 2026
190 of 191 checks passed
@nil-is-allnil-is-all added the module: mlx Issues related to MLX Backend: Metal-accelerated inference on Apple Silicon label Jul 6, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.module: mlxIssues related to MLX Backend: Metal-accelerated inference on Apple Siliconrelease notes: llmChanges to llm utilities

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Good First Issue: top-k filtering for mlx::sample (MLX backend)

3 participants

@goutamadwant@metascroy@nil-is-all
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Add top-k support to MLX sample - #20564

Merged
metascroy merged 4 commits into
pytorch:mainfrom
goutamadwant:fix-mlx-sample-top-k
Jun 29, 2026
Merged

Add top-k support to MLX sample#20564
metascroy merged 4 commits into
pytorch:mainfrom
goutamadwant:fix-mlx-sample-top-k

Conversation

@goutamadwant

@goutamadwantgoutamadwant commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Fixes#20548

Summary

  • Thread optional top_k through SamplingHead and mlx::sample.
  • Apply top-k filtering as an additional threshold mask that composes with the existing top-p nucleus mask.
  • Add eager, export, end-to-end, and MLX lowering coverage for top-k sampling.

Test plan

  • PYTHONPATH=src:. python3 -m unittest executorch.backends.mlx.test.test_sample.TestSampleOp executorch.backends.mlx.test.test_sample.TestSampleExport
  • python3 -m compileall -q backends/mlx/llm/sampling.py backends/mlx/custom_ops.py backends/mlx/ops.py backends/mlx/test/test_sample.py backends/mlx/test/test_ops.py

cc @metascroy

@pytorch-bot

pytorch-botBot commented Jun 27, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20564

Note: Links to docs will display an error until the docs builds have been completed.

❌ 1 New Failure, 12 Pending

As of commit 7e9ae35 with merge base 55a71e6 (image):

NEW FAILURE - The following job has failed:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 27, 2026
@linux-foundation-easycla

linux-foundation-easyclaBot commented Jun 27, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

  • ✅ login: goutamadwant / name: goutamadwant (c944a5a)

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@pytorchbot label "release notes: llm"

Comment threadbackends/mlx/llm/sampling.py Outdated
return torch.ops.mlx.sample(last, temperature, top_p, seed)
if top_k is not None and not isinstance(top_k, torch.Tensor):
top_k = torch.tensor(int(top_k), dtype=torch.int64)
return torch.ops.mlx.sample(last, temperature, top_p, seed, top_k)

@metascroymetascroyJun 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: Can we reorder the args here to be temp, top_k, top_p, seed?

We also need to modify this custom ops behavior to work with top_k correctly now.

Do huggingface style, where topk happens before topp, which requires renormalization, roughly something like this.

Something similar is required on the emit path.

def sample(logits, temperature, top_k=None, top_p=1.0, seed=None):
if float(temperature) <= 0:
return torch.argmax(logits, dim=-1)
scaled = logits.float() / temperature
# ── Top-k FIRST (on logits; monotonicity ⇒ top-k logits = top-k probs) ──
if top_k is not None:
k = int(top_k.item())
s_scaled, _ = torch.sort(scaled, dim=-1, descending=True)
kth = s_scaled[..., k - 1 : k]
scaled = torch.where(scaled >= kth, scaled, scaled.new_tensor(float("-inf")))
# ── Top-p on the *renormalized* distribution ──
probs = torch.softmax(scaled, dim=-1) # exp(-inf)=0 → renormalized over top-k
s_probs, _ = torch.sort(probs, dim=-1, descending=True)
cum = torch.cumsum(s_probs, dim=-1)
keep = (cum - s_probs) <= top_p
thresh = torch.where(keep, s_probs, s_probs.new_tensor(float("inf"))).amin(
dim=-1, keepdim=True
)
scaled = torch.where(probs >= thresh, scaled, scaled.new_tensor(float("-inf")))
# ── Gumbel-max ──
if seed is None:
u = torch.rand(scaled.shape)
else:
gen = torch.Generator().manual_seed(int(seed.item()))
u = torch.rand(scaled.shape, generator=gen)
gumbel = -torch.log(-torch.log(u))
return torch.argmax(scaled + gumbel, dim=-1)

Comment threadbackends/mlx/test/test_sample.py Outdated

def test_top_k_restricts_to_top_k(self):
# probs [0.5, 0.3, 0.15, 0.05]; top_k=2 keeps {0,1}.
base = torch.log(torch.tensor([0.5, 0.3, 0.15, 0.05]))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we have test case in non-sorted order, e.g., 0.15, 0.5, .05, 0.3?

Comment threadbackends/mlx/ops.py Outdated
out=P.slot_to_tid(drop),
)
)
if top_k is not None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's keep top_k specified at runtime, with default value int max (select everything)

Comment threadbackends/mlx/ops.py Outdated
)
if top_k is not None:
_, top_k_val = P.make_tmp_value_slot()
P.emit(ItemIntNode(x=P.slot_to_tid(top_k), out=P.slot_to_vid(top_k_val)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's do Int clipping to vocab size if topk > vocab size.

Comment threadbackends/mlx/ops.py Outdated
if top_k is not None:
_, top_k_val = P.make_tmp_value_slot()
P.emit(ItemIntNode(x=P.slot_to_tid(top_k), out=P.slot_to_vid(top_k_val)))
top_k_iov = P.to_int_or_vid(top_k_val)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

top_k_val is already intorvid?

@metascroy

Copy link
Copy Markdown
Contributor

@claude review this code

@claude

claudeBot commented Jun 27, 2026

Copy link
Copy Markdown

Claude finished @metascroy's task in 3m 58s —— View job


Code review: Add top-k support to MLX sample

I read through all five changed files plus the emit path in ops.py and the helpers it relies on. Scott's inline comments already cover the main direction; below I confirm them with a concrete numeric case and add a few additional findings.

1. Top-k / top-p composition diverges from HuggingFace semantics (correctness) — confirms Scott's sampling.py:41 comment

Both the reference op (custom_ops.py) and the emit path (ops.py) compute the top-k mask and the top-p mask independently on the same un-renormalized probs and then OR the drop masks together. HF does top-k first, renormalizes, then applies top-p to the renormalized distribution. These are not equivalent. Worked example, probs = [0.5, 0.3, 0.15, 0.05], top_k=3, top_p=0.83:

  • This PR: top-k keeps {0,1,2}, top-p (prefix mass [0, 0.5, 0.8, 0.95] ≤ 0.83) keeps {0,1,2} → final keep {0,1,2}.
  • HF: top-k → renormalize over 0.95 → [0.526, 0.316, 0.158]; top-p on that (prefix [0, 0.526, 0.842] ≤ 0.83) keeps {0,1} → final keep {0,1}.

So the result genuinely differs once both filters are active. Scott's suggested sample() rewrite (top-k on logits → softmax/renormalize → top-p on the renormalized probs) is the fix, and the same restructuring is needed on the emit path — the current LogicalOrNode combine mirrors the reference's OR-compose, so both must move to sequential top-k-then-renormalize-then-top-p. The new test_top_k_and_top_p_compose test passes only because the chosen values happen to agree under both semantics; a case like the one above would catch the divergence.

2. top_k > vocab_size is an out-of-bounds bug (correctness) — relates to Scott's ops.py:3681 clipping comment

custom_ops.py:431:

k=int(top_k.item())
kth=s_probs[..., k-1 : k]

If k > vocab, the slice [k-1:k] is empty, so probs >= kth broadcasts against a size-0 tensor and the mask is wrong/raises. On the emit path the corresponding TakeNode(index = top_k - 1) indexes out of bounds. Clipping k = min(k, vocab) (Scott's point) fixes both. Worth a test with top_k larger than the vocab to lock this in.

3. top_k as runtime default instead of Optional — Scott's ops.py:3679 comment

Making top_k always-present with a default of INT_MAX (clipped to vocab per #2) removes the if top_k is not None branch in both custom_ops.py and the _sample_handler emit block, and the is not None checks in sampling.py. That collapses two code paths into one and naturally yields "keep everything" when unset.

4. Argument ordering — Scott's sampling.py:41 comment

Reorder to temperature, top_k, top_p, seed consistently across mlx::sample, sample_fake, _sample_handler's args[...] unpacking, and the _sample test helper. Note this is a signature change to the registered custom op, so every call site and the require_args(args, 3, 5, ...) indices need to move together.

5. to_int_or_vid already handles the literal/vid split — Scott's ops.py:3682 comment

ops.py:3688-3692:

index=(
IntOrVidOrTid.from_vid(top_k_index.vid)
iftop_k_index.is_videlseIntOrVidOrTid.from_literal(top_k_index.literal)
)

top_k_val comes from ItemIntNode so it's always a Vid here — top_k_iov/top_k_index will never be a literal in this path, making the else branch dead. P.to_int_or_vid_or_tid(top_k_val) (program_builder.py:318) already does this conversion in one call; prefer it over the manual ternary.

6. Test coverage — Scott's test_sample.py:165 comment

  • Add a non-sorted-logits case (e.g. probs 0.15, 0.5, 0.05, 0.3) so the test exercises the sort path rather than already-descending input.
  • Add a top_k > vocab case (Re-sync with internal repository #2) and a combined case where HF vs OR-compose semantics actually differ (Add support for quantized LeakyReLU #1), so the renormalization fix is verified rather than coincidentally passing.

Minor

  • custom_ops.py:431int(top_k.item()) is fine for the eager/meta reference, but confirm the .item() data-dependent read doesn't trip export's symbolic tracing on this op (the seed path already uses ItemIntNode on device, so the device side is fine).
  • Docstring in sampling.py still reads cleanly, but once Rename _pt2e to pt2e #3 lands ("None disables top-k") the wording should reflect the always-present default instead.

Overall the lowering scaffolding (Take → ExpandDims → Less → LogicalOr) is well-built and the node-count test is a nice touch; the substantive work is switching both the reference and emit paths to HF-style sequential filtering and adding the vocab-size clip.
· branch fix-mlx-sample-top-k

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

thanks for your quick review @metascroy will take a look and address them.

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@metascroy Addressed below changes in the latest commit.

  • Reordered the custom op args to temperature, top_k, top_p, seed.
  • Switched sampling to top-k first.. and then top-p on the renormalized probabilities.
  • Made the default top_k path use a max-int sentinel clipped to vocab size.
  • Removed the independent OR-composed top-k/top-p masks in the emit path.
  • Added coverage for non-sorted logits, top_k > vocab, and the combined top-k/top-p case where renormalization changes the result.
    let me know if you have any more suggestions. thanks!

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@claude review this code

1 similar comment
@metascroy

Copy link
Copy Markdown
Contributor

@claude review this code

@claude

claudeBot commented Jun 29, 2026

Copy link
Copy Markdown

Claude finished @metascroy's task in 2m 11s —— View job


Code review: top-k support for MLX sample (updated)

I re-read all five changed files plus the _sample_handler emit path. The latest commit cleanly addresses every point from the prior round. Summary below.

Prior review items — all resolved ✅

#ItemStatus
1HF-style sequential filtering (top-k → renormalize → top-p)custom_ops.py:430-444 does top-k on logits, then softmax (renormalizes over the kept set), then top-p. Emit path mirrors this: top-k WhereNode feeds the SoftmaxNode at ops.py:3695, and the old LogicalOrNode OR-compose is gone (test asserts "LogicalOrNode": 0).
2top_k > vocab clippingmin(int(top_k.item()), scaled.shape[-1]) (custom_ops.py:430) and MinimumNode against vocab (ops.py:3622-3628).
3top_k runtime arg with int-max defaultsampling.py:40-43 defaults to torch.iinfo(torch.int64).max; the op signature now always takes top_k.
4Arg ordering temperature, top_k, top_p, seed✅ Consistent across sample, sample_fake, _sample_handler unpacking, and the _sample test helper. require_args(args, 4, 5, ...) updated.
5Use to_int_or_vid_or_tidops.py:3660 now calls P.to_int_or_vid_or_tid(top_k_index) directly — dead literal branch removed.
6Test coverage✅ Added non-sorted input (test_top_k_restricts_to_top_k), top_k > vocab (test_top_k_clips_to_vocab_size), and a true HF-vs-OR divergence case (test_top_k_and_top_p_compose, top_k=3, top_p=0.83 → {0,1}). Plus an end-to-end on-device top-k test and a SampleTopKTest node-count case.

I worked the test_top_k_and_top_p_compose numbers by hand: top_k=3 keeps {0.5,0.3,0.15}, renormalizes to [0.526,0.316,0.158], and top-p=0.83 (prefix [0,0.526,0.842] ≤ 0.83) keeps {0,1}. This is the case that would have failed under the old OR-compose, so the renormalization fix is genuinely exercised now, not coincidentally passing.

Minor observations (non-blocking)

  1. custom_ops.py:432 empty-slice edge when k=0.k is clipped above by vocab but not below. A top_k=0 would make s_scaled[..., -1:0] empty and broadcast oddly. This isn't reachable through SamplingHead (default is int-max, callers pass ≥1), so it's only a concern if the op is called directly with 0. A max(k, 1) clamp or a one-line docstring note that top_k ≥ 1 is expected would close it. Not worth blocking on.

  2. Emit-path top-k threshold vs. reference — confirmed equivalent. The reference keeps scaled >= kth (>=), while the emit path drops via LessNode(scaled, top_k_thresh) i.e. keeps scaled >= thresh too — consistent. Good. The negate→sort→negate dance to get a descending sort (since SortNode is ascending-only) and the TakeNode(index = k-1) correctly pick the k-th largest. Matches the top-p chain's existing pattern.

  3. int(top_k.item()) data-dependent read (custom_ops.py:430): fine for the eager/meta reference; the device path uses ItemIntNode so symbolic tracing is unaffected, same as the existing seed handling.

  4. Buffer-reuse comments in ops.py are accurate (verified scaled/probs/sorted_p live-range notes against their actual reads).

Verdict

Looks good to me — the substantive correctness fix (sequential HF-style filtering + vocab clip) is in on both the reference and the lowered graph, and the tests lock in the behaviors that distinguish it from the old implementation. I couldn't execute the test suite in this sandbox (command approval), so please confirm the documented unittest + compileall runs are green in CI before merging. Only the k=0 clamp (#1) is worth a quick thought; everything else is optional polish.
· branch fix-mlx-sample-top-k

@metascroy

Copy link
Copy Markdown
Contributor

Looks great! Thanks for the contribution @goutamadwant!

@metascroy
metascroy merged commit 035b45a into pytorch:mainJun 29, 2026
190 of 191 checks passed
@nil-is-allnil-is-all added the module: mlx Issues related to MLX Backend: Metal-accelerated inference on Apple Silicon label Jul 6, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.module: mlxIssues related to MLX Backend: Metal-accelerated inference on Apple Siliconrelease notes: llmChanges to llm utilities

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Good First Issue: top-k filtering for mlx::sample (MLX backend)

3 participants

@goutamadwant@metascroy@nil-is-all
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add top-k support to MLX sample - #20564

Merged
metascroy merged 4 commits into
pytorch:mainfrom
goutamadwant:fix-mlx-sample-top-k
Jun 29, 2026
Merged

Add top-k support to MLX sample#20564
metascroy merged 4 commits into
pytorch:mainfrom
goutamadwant:fix-mlx-sample-top-k

Conversation

@goutamadwant

@goutamadwantgoutamadwant commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Fixes#20548

Summary

  • Thread optional top_k through SamplingHead and mlx::sample.
  • Apply top-k filtering as an additional threshold mask that composes with the existing top-p nucleus mask.
  • Add eager, export, end-to-end, and MLX lowering coverage for top-k sampling.

Test plan

  • PYTHONPATH=src:. python3 -m unittest executorch.backends.mlx.test.test_sample.TestSampleOp executorch.backends.mlx.test.test_sample.TestSampleExport
  • python3 -m compileall -q backends/mlx/llm/sampling.py backends/mlx/custom_ops.py backends/mlx/ops.py backends/mlx/test/test_sample.py backends/mlx/test/test_ops.py

cc @metascroy

@pytorch-bot

pytorch-botBot commented Jun 27, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20564

Note: Links to docs will display an error until the docs builds have been completed.

❌ 1 New Failure, 12 Pending

As of commit 7e9ae35 with merge base 55a71e6 (image):

NEW FAILURE - The following job has failed:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 27, 2026
@linux-foundation-easycla

linux-foundation-easyclaBot commented Jun 27, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

  • ✅ login: goutamadwant / name: goutamadwant (c944a5a)

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@pytorchbot label "release notes: llm"

Comment threadbackends/mlx/llm/sampling.py Outdated
return torch.ops.mlx.sample(last, temperature, top_p, seed)
if top_k is not None and not isinstance(top_k, torch.Tensor):
top_k = torch.tensor(int(top_k), dtype=torch.int64)
return torch.ops.mlx.sample(last, temperature, top_p, seed, top_k)

@metascroymetascroyJun 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: Can we reorder the args here to be temp, top_k, top_p, seed?

We also need to modify this custom ops behavior to work with top_k correctly now.

Do huggingface style, where topk happens before topp, which requires renormalization, roughly something like this.

Something similar is required on the emit path.

def sample(logits, temperature, top_k=None, top_p=1.0, seed=None):
if float(temperature) <= 0:
return torch.argmax(logits, dim=-1)
scaled = logits.float() / temperature
# ── Top-k FIRST (on logits; monotonicity ⇒ top-k logits = top-k probs) ──
if top_k is not None:
k = int(top_k.item())
s_scaled, _ = torch.sort(scaled, dim=-1, descending=True)
kth = s_scaled[..., k - 1 : k]
scaled = torch.where(scaled >= kth, scaled, scaled.new_tensor(float("-inf")))
# ── Top-p on the *renormalized* distribution ──
probs = torch.softmax(scaled, dim=-1) # exp(-inf)=0 → renormalized over top-k
s_probs, _ = torch.sort(probs, dim=-1, descending=True)
cum = torch.cumsum(s_probs, dim=-1)
keep = (cum - s_probs) <= top_p
thresh = torch.where(keep, s_probs, s_probs.new_tensor(float("inf"))).amin(
dim=-1, keepdim=True
)
scaled = torch.where(probs >= thresh, scaled, scaled.new_tensor(float("-inf")))
# ── Gumbel-max ──
if seed is None:
u = torch.rand(scaled.shape)
else:
gen = torch.Generator().manual_seed(int(seed.item()))
u = torch.rand(scaled.shape, generator=gen)
gumbel = -torch.log(-torch.log(u))
return torch.argmax(scaled + gumbel, dim=-1)

Comment threadbackends/mlx/test/test_sample.py Outdated

def test_top_k_restricts_to_top_k(self):
# probs [0.5, 0.3, 0.15, 0.05]; top_k=2 keeps {0,1}.
base = torch.log(torch.tensor([0.5, 0.3, 0.15, 0.05]))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we have test case in non-sorted order, e.g., 0.15, 0.5, .05, 0.3?

Comment threadbackends/mlx/ops.py Outdated
out=P.slot_to_tid(drop),
)
)
if top_k is not None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's keep top_k specified at runtime, with default value int max (select everything)

Comment threadbackends/mlx/ops.py Outdated
)
if top_k is not None:
_, top_k_val = P.make_tmp_value_slot()
P.emit(ItemIntNode(x=P.slot_to_tid(top_k), out=P.slot_to_vid(top_k_val)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's do Int clipping to vocab size if topk > vocab size.

Comment threadbackends/mlx/ops.py Outdated
if top_k is not None:
_, top_k_val = P.make_tmp_value_slot()
P.emit(ItemIntNode(x=P.slot_to_tid(top_k), out=P.slot_to_vid(top_k_val)))
top_k_iov = P.to_int_or_vid(top_k_val)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

top_k_val is already intorvid?

@metascroy

Copy link
Copy Markdown
Contributor

@claude review this code

@claude

claudeBot commented Jun 27, 2026

Copy link
Copy Markdown

Claude finished @metascroy's task in 3m 58s —— View job


Code review: Add top-k support to MLX sample

I read through all five changed files plus the emit path in ops.py and the helpers it relies on. Scott's inline comments already cover the main direction; below I confirm them with a concrete numeric case and add a few additional findings.

1. Top-k / top-p composition diverges from HuggingFace semantics (correctness) — confirms Scott's sampling.py:41 comment

Both the reference op (custom_ops.py) and the emit path (ops.py) compute the top-k mask and the top-p mask independently on the same un-renormalized probs and then OR the drop masks together. HF does top-k first, renormalizes, then applies top-p to the renormalized distribution. These are not equivalent. Worked example, probs = [0.5, 0.3, 0.15, 0.05], top_k=3, top_p=0.83:

  • This PR: top-k keeps {0,1,2}, top-p (prefix mass [0, 0.5, 0.8, 0.95] ≤ 0.83) keeps {0,1,2} → final keep {0,1,2}.
  • HF: top-k → renormalize over 0.95 → [0.526, 0.316, 0.158]; top-p on that (prefix [0, 0.526, 0.842] ≤ 0.83) keeps {0,1} → final keep {0,1}.

So the result genuinely differs once both filters are active. Scott's suggested sample() rewrite (top-k on logits → softmax/renormalize → top-p on the renormalized probs) is the fix, and the same restructuring is needed on the emit path — the current LogicalOrNode combine mirrors the reference's OR-compose, so both must move to sequential top-k-then-renormalize-then-top-p. The new test_top_k_and_top_p_compose test passes only because the chosen values happen to agree under both semantics; a case like the one above would catch the divergence.

2. top_k > vocab_size is an out-of-bounds bug (correctness) — relates to Scott's ops.py:3681 clipping comment

custom_ops.py:431:

k=int(top_k.item())
kth=s_probs[..., k-1 : k]

If k > vocab, the slice [k-1:k] is empty, so probs >= kth broadcasts against a size-0 tensor and the mask is wrong/raises. On the emit path the corresponding TakeNode(index = top_k - 1) indexes out of bounds. Clipping k = min(k, vocab) (Scott's point) fixes both. Worth a test with top_k larger than the vocab to lock this in.

3. top_k as runtime default instead of Optional — Scott's ops.py:3679 comment

Making top_k always-present with a default of INT_MAX (clipped to vocab per #2) removes the if top_k is not None branch in both custom_ops.py and the _sample_handler emit block, and the is not None checks in sampling.py. That collapses two code paths into one and naturally yields "keep everything" when unset.

4. Argument ordering — Scott's sampling.py:41 comment

Reorder to temperature, top_k, top_p, seed consistently across mlx::sample, sample_fake, _sample_handler's args[...] unpacking, and the _sample test helper. Note this is a signature change to the registered custom op, so every call site and the require_args(args, 3, 5, ...) indices need to move together.

5. to_int_or_vid already handles the literal/vid split — Scott's ops.py:3682 comment

ops.py:3688-3692:

index=(
IntOrVidOrTid.from_vid(top_k_index.vid)
iftop_k_index.is_videlseIntOrVidOrTid.from_literal(top_k_index.literal)
)

top_k_val comes from ItemIntNode so it's always a Vid here — top_k_iov/top_k_index will never be a literal in this path, making the else branch dead. P.to_int_or_vid_or_tid(top_k_val) (program_builder.py:318) already does this conversion in one call; prefer it over the manual ternary.

6. Test coverage — Scott's test_sample.py:165 comment

  • Add a non-sorted-logits case (e.g. probs 0.15, 0.5, 0.05, 0.3) so the test exercises the sort path rather than already-descending input.
  • Add a top_k > vocab case (Re-sync with internal repository #2) and a combined case where HF vs OR-compose semantics actually differ (Add support for quantized LeakyReLU #1), so the renormalization fix is verified rather than coincidentally passing.

Minor

  • custom_ops.py:431int(top_k.item()) is fine for the eager/meta reference, but confirm the .item() data-dependent read doesn't trip export's symbolic tracing on this op (the seed path already uses ItemIntNode on device, so the device side is fine).
  • Docstring in sampling.py still reads cleanly, but once Rename _pt2e to pt2e #3 lands ("None disables top-k") the wording should reflect the always-present default instead.

Overall the lowering scaffolding (Take → ExpandDims → Less → LogicalOr) is well-built and the node-count test is a nice touch; the substantive work is switching both the reference and emit paths to HF-style sequential filtering and adding the vocab-size clip.
· branch fix-mlx-sample-top-k

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

thanks for your quick review @metascroy will take a look and address them.

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@metascroy Addressed below changes in the latest commit.

  • Reordered the custom op args to temperature, top_k, top_p, seed.
  • Switched sampling to top-k first.. and then top-p on the renormalized probabilities.
  • Made the default top_k path use a max-int sentinel clipped to vocab size.
  • Removed the independent OR-composed top-k/top-p masks in the emit path.
  • Added coverage for non-sorted logits, top_k > vocab, and the combined top-k/top-p case where renormalization changes the result.
    let me know if you have any more suggestions. thanks!

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@claude review this code

1 similar comment
@metascroy

Copy link
Copy Markdown
Contributor

@claude review this code

@claude

claudeBot commented Jun 29, 2026

Copy link
Copy Markdown

Claude finished @metascroy's task in 2m 11s —— View job


Code review: top-k support for MLX sample (updated)

I re-read all five changed files plus the _sample_handler emit path. The latest commit cleanly addresses every point from the prior round. Summary below.

Prior review items — all resolved ✅

#ItemStatus
1HF-style sequential filtering (top-k → renormalize → top-p)custom_ops.py:430-444 does top-k on logits, then softmax (renormalizes over the kept set), then top-p. Emit path mirrors this: top-k WhereNode feeds the SoftmaxNode at ops.py:3695, and the old LogicalOrNode OR-compose is gone (test asserts "LogicalOrNode": 0).
2top_k > vocab clippingmin(int(top_k.item()), scaled.shape[-1]) (custom_ops.py:430) and MinimumNode against vocab (ops.py:3622-3628).
3top_k runtime arg with int-max defaultsampling.py:40-43 defaults to torch.iinfo(torch.int64).max; the op signature now always takes top_k.
4Arg ordering temperature, top_k, top_p, seed✅ Consistent across sample, sample_fake, _sample_handler unpacking, and the _sample test helper. require_args(args, 4, 5, ...) updated.
5Use to_int_or_vid_or_tidops.py:3660 now calls P.to_int_or_vid_or_tid(top_k_index) directly — dead literal branch removed.
6Test coverage✅ Added non-sorted input (test_top_k_restricts_to_top_k), top_k > vocab (test_top_k_clips_to_vocab_size), and a true HF-vs-OR divergence case (test_top_k_and_top_p_compose, top_k=3, top_p=0.83 → {0,1}). Plus an end-to-end on-device top-k test and a SampleTopKTest node-count case.

I worked the test_top_k_and_top_p_compose numbers by hand: top_k=3 keeps {0.5,0.3,0.15}, renormalizes to [0.526,0.316,0.158], and top-p=0.83 (prefix [0,0.526,0.842] ≤ 0.83) keeps {0,1}. This is the case that would have failed under the old OR-compose, so the renormalization fix is genuinely exercised now, not coincidentally passing.

Minor observations (non-blocking)

  1. custom_ops.py:432 empty-slice edge when k=0.k is clipped above by vocab but not below. A top_k=0 would make s_scaled[..., -1:0] empty and broadcast oddly. This isn't reachable through SamplingHead (default is int-max, callers pass ≥1), so it's only a concern if the op is called directly with 0. A max(k, 1) clamp or a one-line docstring note that top_k ≥ 1 is expected would close it. Not worth blocking on.

  2. Emit-path top-k threshold vs. reference — confirmed equivalent. The reference keeps scaled >= kth (>=), while the emit path drops via LessNode(scaled, top_k_thresh) i.e. keeps scaled >= thresh too — consistent. Good. The negate→sort→negate dance to get a descending sort (since SortNode is ascending-only) and the TakeNode(index = k-1) correctly pick the k-th largest. Matches the top-p chain's existing pattern.

  3. int(top_k.item()) data-dependent read (custom_ops.py:430): fine for the eager/meta reference; the device path uses ItemIntNode so symbolic tracing is unaffected, same as the existing seed handling.

  4. Buffer-reuse comments in ops.py are accurate (verified scaled/probs/sorted_p live-range notes against their actual reads).

Verdict

Looks good to me — the substantive correctness fix (sequential HF-style filtering + vocab clip) is in on both the reference and the lowered graph, and the tests lock in the behaviors that distinguish it from the old implementation. I couldn't execute the test suite in this sandbox (command approval), so please confirm the documented unittest + compileall runs are green in CI before merging. Only the k=0 clamp (#1) is worth a quick thought; everything else is optional polish.
· branch fix-mlx-sample-top-k

@metascroy

Copy link
Copy Markdown
Contributor

Looks great! Thanks for the contribution @goutamadwant!

@metascroy
metascroy merged commit 035b45a into pytorch:mainJun 29, 2026
190 of 191 checks passed
@nil-is-allnil-is-all added the module: mlx Issues related to MLX Backend: Metal-accelerated inference on Apple Silicon label Jul 6, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.module: mlxIssues related to MLX Backend: Metal-accelerated inference on Apple Siliconrelease notes: llmChanges to llm utilities

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Good First Issue: top-k filtering for mlx::sample (MLX backend)

3 participants

@goutamadwant@metascroy@nil-is-all
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add top-k support to MLX sample - #20564

Merged
metascroy merged 4 commits into
pytorch:mainfrom
goutamadwant:fix-mlx-sample-top-k
Jun 29, 2026
Merged

Add top-k support to MLX sample#20564
metascroy merged 4 commits into
pytorch:mainfrom
goutamadwant:fix-mlx-sample-top-k

Conversation

@goutamadwant

@goutamadwantgoutamadwant commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Fixes#20548

Summary

  • Thread optional top_k through SamplingHead and mlx::sample.
  • Apply top-k filtering as an additional threshold mask that composes with the existing top-p nucleus mask.
  • Add eager, export, end-to-end, and MLX lowering coverage for top-k sampling.

Test plan

  • PYTHONPATH=src:. python3 -m unittest executorch.backends.mlx.test.test_sample.TestSampleOp executorch.backends.mlx.test.test_sample.TestSampleExport
  • python3 -m compileall -q backends/mlx/llm/sampling.py backends/mlx/custom_ops.py backends/mlx/ops.py backends/mlx/test/test_sample.py backends/mlx/test/test_ops.py

cc @metascroy

@pytorch-bot

pytorch-botBot commented Jun 27, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20564

Note: Links to docs will display an error until the docs builds have been completed.

❌ 1 New Failure, 12 Pending

As of commit 7e9ae35 with merge base 55a71e6 (image):

NEW FAILURE - The following job has failed:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 27, 2026
@linux-foundation-easycla

linux-foundation-easyclaBot commented Jun 27, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

  • ✅ login: goutamadwant / name: goutamadwant (c944a5a)

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@pytorchbot label "release notes: llm"

Comment threadbackends/mlx/llm/sampling.py Outdated
return torch.ops.mlx.sample(last, temperature, top_p, seed)
if top_k is not None and not isinstance(top_k, torch.Tensor):
top_k = torch.tensor(int(top_k), dtype=torch.int64)
return torch.ops.mlx.sample(last, temperature, top_p, seed, top_k)

@metascroymetascroyJun 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: Can we reorder the args here to be temp, top_k, top_p, seed?

We also need to modify this custom ops behavior to work with top_k correctly now.

Do huggingface style, where topk happens before topp, which requires renormalization, roughly something like this.

Something similar is required on the emit path.

def sample(logits, temperature, top_k=None, top_p=1.0, seed=None):
if float(temperature) <= 0:
return torch.argmax(logits, dim=-1)
scaled = logits.float() / temperature
# ── Top-k FIRST (on logits; monotonicity ⇒ top-k logits = top-k probs) ──
if top_k is not None:
k = int(top_k.item())
s_scaled, _ = torch.sort(scaled, dim=-1, descending=True)
kth = s_scaled[..., k - 1 : k]
scaled = torch.where(scaled >= kth, scaled, scaled.new_tensor(float("-inf")))
# ── Top-p on the *renormalized* distribution ──
probs = torch.softmax(scaled, dim=-1) # exp(-inf)=0 → renormalized over top-k
s_probs, _ = torch.sort(probs, dim=-1, descending=True)
cum = torch.cumsum(s_probs, dim=-1)
keep = (cum - s_probs) <= top_p
thresh = torch.where(keep, s_probs, s_probs.new_tensor(float("inf"))).amin(
dim=-1, keepdim=True
)
scaled = torch.where(probs >= thresh, scaled, scaled.new_tensor(float("-inf")))
# ── Gumbel-max ──
if seed is None:
u = torch.rand(scaled.shape)
else:
gen = torch.Generator().manual_seed(int(seed.item()))
u = torch.rand(scaled.shape, generator=gen)
gumbel = -torch.log(-torch.log(u))
return torch.argmax(scaled + gumbel, dim=-1)

Comment threadbackends/mlx/test/test_sample.py Outdated

def test_top_k_restricts_to_top_k(self):
# probs [0.5, 0.3, 0.15, 0.05]; top_k=2 keeps {0,1}.
base = torch.log(torch.tensor([0.5, 0.3, 0.15, 0.05]))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we have test case in non-sorted order, e.g., 0.15, 0.5, .05, 0.3?

Comment threadbackends/mlx/ops.py Outdated
out=P.slot_to_tid(drop),
)
)
if top_k is not None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's keep top_k specified at runtime, with default value int max (select everything)

Comment threadbackends/mlx/ops.py Outdated
)
if top_k is not None:
_, top_k_val = P.make_tmp_value_slot()
P.emit(ItemIntNode(x=P.slot_to_tid(top_k), out=P.slot_to_vid(top_k_val)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's do Int clipping to vocab size if topk > vocab size.

Comment threadbackends/mlx/ops.py Outdated
if top_k is not None:
_, top_k_val = P.make_tmp_value_slot()
P.emit(ItemIntNode(x=P.slot_to_tid(top_k), out=P.slot_to_vid(top_k_val)))
top_k_iov = P.to_int_or_vid(top_k_val)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

top_k_val is already intorvid?

@metascroy

Copy link
Copy Markdown
Contributor

@claude review this code

@claude

claudeBot commented Jun 27, 2026

Copy link
Copy Markdown

Claude finished @metascroy's task in 3m 58s —— View job


Code review: Add top-k support to MLX sample

I read through all five changed files plus the emit path in ops.py and the helpers it relies on. Scott's inline comments already cover the main direction; below I confirm them with a concrete numeric case and add a few additional findings.

1. Top-k / top-p composition diverges from HuggingFace semantics (correctness) — confirms Scott's sampling.py:41 comment

Both the reference op (custom_ops.py) and the emit path (ops.py) compute the top-k mask and the top-p mask independently on the same un-renormalized probs and then OR the drop masks together. HF does top-k first, renormalizes, then applies top-p to the renormalized distribution. These are not equivalent. Worked example, probs = [0.5, 0.3, 0.15, 0.05], top_k=3, top_p=0.83:

  • This PR: top-k keeps {0,1,2}, top-p (prefix mass [0, 0.5, 0.8, 0.95] ≤ 0.83) keeps {0,1,2} → final keep {0,1,2}.
  • HF: top-k → renormalize over 0.95 → [0.526, 0.316, 0.158]; top-p on that (prefix [0, 0.526, 0.842] ≤ 0.83) keeps {0,1} → final keep {0,1}.

So the result genuinely differs once both filters are active. Scott's suggested sample() rewrite (top-k on logits → softmax/renormalize → top-p on the renormalized probs) is the fix, and the same restructuring is needed on the emit path — the current LogicalOrNode combine mirrors the reference's OR-compose, so both must move to sequential top-k-then-renormalize-then-top-p. The new test_top_k_and_top_p_compose test passes only because the chosen values happen to agree under both semantics; a case like the one above would catch the divergence.

2. top_k > vocab_size is an out-of-bounds bug (correctness) — relates to Scott's ops.py:3681 clipping comment

custom_ops.py:431:

k=int(top_k.item())
kth=s_probs[..., k-1 : k]

If k > vocab, the slice [k-1:k] is empty, so probs >= kth broadcasts against a size-0 tensor and the mask is wrong/raises. On the emit path the corresponding TakeNode(index = top_k - 1) indexes out of bounds. Clipping k = min(k, vocab) (Scott's point) fixes both. Worth a test with top_k larger than the vocab to lock this in.

3. top_k as runtime default instead of Optional — Scott's ops.py:3679 comment

Making top_k always-present with a default of INT_MAX (clipped to vocab per #2) removes the if top_k is not None branch in both custom_ops.py and the _sample_handler emit block, and the is not None checks in sampling.py. That collapses two code paths into one and naturally yields "keep everything" when unset.

4. Argument ordering — Scott's sampling.py:41 comment

Reorder to temperature, top_k, top_p, seed consistently across mlx::sample, sample_fake, _sample_handler's args[...] unpacking, and the _sample test helper. Note this is a signature change to the registered custom op, so every call site and the require_args(args, 3, 5, ...) indices need to move together.

5. to_int_or_vid already handles the literal/vid split — Scott's ops.py:3682 comment

ops.py:3688-3692:

index=(
IntOrVidOrTid.from_vid(top_k_index.vid)
iftop_k_index.is_videlseIntOrVidOrTid.from_literal(top_k_index.literal)
)

top_k_val comes from ItemIntNode so it's always a Vid here — top_k_iov/top_k_index will never be a literal in this path, making the else branch dead. P.to_int_or_vid_or_tid(top_k_val) (program_builder.py:318) already does this conversion in one call; prefer it over the manual ternary.

6. Test coverage — Scott's test_sample.py:165 comment

  • Add a non-sorted-logits case (e.g. probs 0.15, 0.5, 0.05, 0.3) so the test exercises the sort path rather than already-descending input.
  • Add a top_k > vocab case (Re-sync with internal repository #2) and a combined case where HF vs OR-compose semantics actually differ (Add support for quantized LeakyReLU #1), so the renormalization fix is verified rather than coincidentally passing.

Minor

  • custom_ops.py:431int(top_k.item()) is fine for the eager/meta reference, but confirm the .item() data-dependent read doesn't trip export's symbolic tracing on this op (the seed path already uses ItemIntNode on device, so the device side is fine).
  • Docstring in sampling.py still reads cleanly, but once Rename _pt2e to pt2e #3 lands ("None disables top-k") the wording should reflect the always-present default instead.

Overall the lowering scaffolding (Take → ExpandDims → Less → LogicalOr) is well-built and the node-count test is a nice touch; the substantive work is switching both the reference and emit paths to HF-style sequential filtering and adding the vocab-size clip.
· branch fix-mlx-sample-top-k

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

thanks for your quick review @metascroy will take a look and address them.

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@metascroy Addressed below changes in the latest commit.

  • Reordered the custom op args to temperature, top_k, top_p, seed.
  • Switched sampling to top-k first.. and then top-p on the renormalized probabilities.
  • Made the default top_k path use a max-int sentinel clipped to vocab size.
  • Removed the independent OR-composed top-k/top-p masks in the emit path.
  • Added coverage for non-sorted logits, top_k > vocab, and the combined top-k/top-p case where renormalization changes the result.
    let me know if you have any more suggestions. thanks!

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@claude review this code

1 similar comment
@metascroy

Copy link
Copy Markdown
Contributor

@claude review this code

@claude

claudeBot commented Jun 29, 2026

Copy link
Copy Markdown

Claude finished @metascroy's task in 2m 11s —— View job


Code review: top-k support for MLX sample (updated)

I re-read all five changed files plus the _sample_handler emit path. The latest commit cleanly addresses every point from the prior round. Summary below.

Prior review items — all resolved ✅

#ItemStatus
1HF-style sequential filtering (top-k → renormalize → top-p)custom_ops.py:430-444 does top-k on logits, then softmax (renormalizes over the kept set), then top-p. Emit path mirrors this: top-k WhereNode feeds the SoftmaxNode at ops.py:3695, and the old LogicalOrNode OR-compose is gone (test asserts "LogicalOrNode": 0).
2top_k > vocab clippingmin(int(top_k.item()), scaled.shape[-1]) (custom_ops.py:430) and MinimumNode against vocab (ops.py:3622-3628).
3top_k runtime arg with int-max defaultsampling.py:40-43 defaults to torch.iinfo(torch.int64).max; the op signature now always takes top_k.
4Arg ordering temperature, top_k, top_p, seed✅ Consistent across sample, sample_fake, _sample_handler unpacking, and the _sample test helper. require_args(args, 4, 5, ...) updated.
5Use to_int_or_vid_or_tidops.py:3660 now calls P.to_int_or_vid_or_tid(top_k_index) directly — dead literal branch removed.
6Test coverage✅ Added non-sorted input (test_top_k_restricts_to_top_k), top_k > vocab (test_top_k_clips_to_vocab_size), and a true HF-vs-OR divergence case (test_top_k_and_top_p_compose, top_k=3, top_p=0.83 → {0,1}). Plus an end-to-end on-device top-k test and a SampleTopKTest node-count case.

I worked the test_top_k_and_top_p_compose numbers by hand: top_k=3 keeps {0.5,0.3,0.15}, renormalizes to [0.526,0.316,0.158], and top-p=0.83 (prefix [0,0.526,0.842] ≤ 0.83) keeps {0,1}. This is the case that would have failed under the old OR-compose, so the renormalization fix is genuinely exercised now, not coincidentally passing.

Minor observations (non-blocking)

  1. custom_ops.py:432 empty-slice edge when k=0.k is clipped above by vocab but not below. A top_k=0 would make s_scaled[..., -1:0] empty and broadcast oddly. This isn't reachable through SamplingHead (default is int-max, callers pass ≥1), so it's only a concern if the op is called directly with 0. A max(k, 1) clamp or a one-line docstring note that top_k ≥ 1 is expected would close it. Not worth blocking on.

  2. Emit-path top-k threshold vs. reference — confirmed equivalent. The reference keeps scaled >= kth (>=), while the emit path drops via LessNode(scaled, top_k_thresh) i.e. keeps scaled >= thresh too — consistent. Good. The negate→sort→negate dance to get a descending sort (since SortNode is ascending-only) and the TakeNode(index = k-1) correctly pick the k-th largest. Matches the top-p chain's existing pattern.

  3. int(top_k.item()) data-dependent read (custom_ops.py:430): fine for the eager/meta reference; the device path uses ItemIntNode so symbolic tracing is unaffected, same as the existing seed handling.

  4. Buffer-reuse comments in ops.py are accurate (verified scaled/probs/sorted_p live-range notes against their actual reads).

Verdict

Looks good to me — the substantive correctness fix (sequential HF-style filtering + vocab clip) is in on both the reference and the lowered graph, and the tests lock in the behaviors that distinguish it from the old implementation. I couldn't execute the test suite in this sandbox (command approval), so please confirm the documented unittest + compileall runs are green in CI before merging. Only the k=0 clamp (#1) is worth a quick thought; everything else is optional polish.
· branch fix-mlx-sample-top-k

@metascroy

Copy link
Copy Markdown
Contributor

Looks great! Thanks for the contribution @goutamadwant!

@metascroy
metascroy merged commit 035b45a into pytorch:mainJun 29, 2026
190 of 191 checks passed
@nil-is-allnil-is-all added the module: mlx Issues related to MLX Backend: Metal-accelerated inference on Apple Silicon label Jul 6, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.module: mlxIssues related to MLX Backend: Metal-accelerated inference on Apple Siliconrelease notes: llmChanges to llm utilities

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Good First Issue: top-k filtering for mlx::sample (MLX backend)

3 participants

@goutamadwant@metascroy@nil-is-all
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Add top-k support to MLX sample - #20564

Merged
metascroy merged 4 commits into
pytorch:mainfrom
goutamadwant:fix-mlx-sample-top-k
Jun 29, 2026
Merged

Add top-k support to MLX sample#20564
metascroy merged 4 commits into
pytorch:mainfrom
goutamadwant:fix-mlx-sample-top-k

Conversation

@goutamadwant

@goutamadwantgoutamadwant commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Fixes#20548

Summary

  • Thread optional top_k through SamplingHead and mlx::sample.
  • Apply top-k filtering as an additional threshold mask that composes with the existing top-p nucleus mask.
  • Add eager, export, end-to-end, and MLX lowering coverage for top-k sampling.

Test plan

  • PYTHONPATH=src:. python3 -m unittest executorch.backends.mlx.test.test_sample.TestSampleOp executorch.backends.mlx.test.test_sample.TestSampleExport
  • python3 -m compileall -q backends/mlx/llm/sampling.py backends/mlx/custom_ops.py backends/mlx/ops.py backends/mlx/test/test_sample.py backends/mlx/test/test_ops.py

cc @metascroy

@pytorch-bot

pytorch-botBot commented Jun 27, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20564

Note: Links to docs will display an error until the docs builds have been completed.

❌ 1 New Failure, 12 Pending

As of commit 7e9ae35 with merge base 55a71e6 (image):

NEW FAILURE - The following job has failed:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 27, 2026
@linux-foundation-easycla

linux-foundation-easyclaBot commented Jun 27, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

  • ✅ login: goutamadwant / name: goutamadwant (c944a5a)

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@pytorchbot label "release notes: llm"

Comment threadbackends/mlx/llm/sampling.py Outdated
return torch.ops.mlx.sample(last, temperature, top_p, seed)
if top_k is not None and not isinstance(top_k, torch.Tensor):
top_k = torch.tensor(int(top_k), dtype=torch.int64)
return torch.ops.mlx.sample(last, temperature, top_p, seed, top_k)

@metascroymetascroyJun 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: Can we reorder the args here to be temp, top_k, top_p, seed?

We also need to modify this custom ops behavior to work with top_k correctly now.

Do huggingface style, where topk happens before topp, which requires renormalization, roughly something like this.

Something similar is required on the emit path.

def sample(logits, temperature, top_k=None, top_p=1.0, seed=None):
if float(temperature) <= 0:
return torch.argmax(logits, dim=-1)
scaled = logits.float() / temperature
# ── Top-k FIRST (on logits; monotonicity ⇒ top-k logits = top-k probs) ──
if top_k is not None:
k = int(top_k.item())
s_scaled, _ = torch.sort(scaled, dim=-1, descending=True)
kth = s_scaled[..., k - 1 : k]
scaled = torch.where(scaled >= kth, scaled, scaled.new_tensor(float("-inf")))
# ── Top-p on the *renormalized* distribution ──
probs = torch.softmax(scaled, dim=-1) # exp(-inf)=0 → renormalized over top-k
s_probs, _ = torch.sort(probs, dim=-1, descending=True)
cum = torch.cumsum(s_probs, dim=-1)
keep = (cum - s_probs) <= top_p
thresh = torch.where(keep, s_probs, s_probs.new_tensor(float("inf"))).amin(
dim=-1, keepdim=True
)
scaled = torch.where(probs >= thresh, scaled, scaled.new_tensor(float("-inf")))
# ── Gumbel-max ──
if seed is None:
u = torch.rand(scaled.shape)
else:
gen = torch.Generator().manual_seed(int(seed.item()))
u = torch.rand(scaled.shape, generator=gen)
gumbel = -torch.log(-torch.log(u))
return torch.argmax(scaled + gumbel, dim=-1)

Comment threadbackends/mlx/test/test_sample.py Outdated

def test_top_k_restricts_to_top_k(self):
# probs [0.5, 0.3, 0.15, 0.05]; top_k=2 keeps {0,1}.
base = torch.log(torch.tensor([0.5, 0.3, 0.15, 0.05]))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we have test case in non-sorted order, e.g., 0.15, 0.5, .05, 0.3?

Comment threadbackends/mlx/ops.py Outdated
out=P.slot_to_tid(drop),
)
)
if top_k is not None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's keep top_k specified at runtime, with default value int max (select everything)

Comment threadbackends/mlx/ops.py Outdated
)
if top_k is not None:
_, top_k_val = P.make_tmp_value_slot()
P.emit(ItemIntNode(x=P.slot_to_tid(top_k), out=P.slot_to_vid(top_k_val)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's do Int clipping to vocab size if topk > vocab size.

Comment threadbackends/mlx/ops.py Outdated
if top_k is not None:
_, top_k_val = P.make_tmp_value_slot()
P.emit(ItemIntNode(x=P.slot_to_tid(top_k), out=P.slot_to_vid(top_k_val)))
top_k_iov = P.to_int_or_vid(top_k_val)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

top_k_val is already intorvid?

@metascroy

Copy link
Copy Markdown
Contributor

@claude review this code

@claude

claudeBot commented Jun 27, 2026

Copy link
Copy Markdown

Claude finished @metascroy's task in 3m 58s —— View job


Code review: Add top-k support to MLX sample

I read through all five changed files plus the emit path in ops.py and the helpers it relies on. Scott's inline comments already cover the main direction; below I confirm them with a concrete numeric case and add a few additional findings.

1. Top-k / top-p composition diverges from HuggingFace semantics (correctness) — confirms Scott's sampling.py:41 comment

Both the reference op (custom_ops.py) and the emit path (ops.py) compute the top-k mask and the top-p mask independently on the same un-renormalized probs and then OR the drop masks together. HF does top-k first, renormalizes, then applies top-p to the renormalized distribution. These are not equivalent. Worked example, probs = [0.5, 0.3, 0.15, 0.05], top_k=3, top_p=0.83:

  • This PR: top-k keeps {0,1,2}, top-p (prefix mass [0, 0.5, 0.8, 0.95] ≤ 0.83) keeps {0,1,2} → final keep {0,1,2}.
  • HF: top-k → renormalize over 0.95 → [0.526, 0.316, 0.158]; top-p on that (prefix [0, 0.526, 0.842] ≤ 0.83) keeps {0,1} → final keep {0,1}.

So the result genuinely differs once both filters are active. Scott's suggested sample() rewrite (top-k on logits → softmax/renormalize → top-p on the renormalized probs) is the fix, and the same restructuring is needed on the emit path — the current LogicalOrNode combine mirrors the reference's OR-compose, so both must move to sequential top-k-then-renormalize-then-top-p. The new test_top_k_and_top_p_compose test passes only because the chosen values happen to agree under both semantics; a case like the one above would catch the divergence.

2. top_k > vocab_size is an out-of-bounds bug (correctness) — relates to Scott's ops.py:3681 clipping comment

custom_ops.py:431:

k=int(top_k.item())
kth=s_probs[..., k-1 : k]

If k > vocab, the slice [k-1:k] is empty, so probs >= kth broadcasts against a size-0 tensor and the mask is wrong/raises. On the emit path the corresponding TakeNode(index = top_k - 1) indexes out of bounds. Clipping k = min(k, vocab) (Scott's point) fixes both. Worth a test with top_k larger than the vocab to lock this in.

3. top_k as runtime default instead of Optional — Scott's ops.py:3679 comment

Making top_k always-present with a default of INT_MAX (clipped to vocab per #2) removes the if top_k is not None branch in both custom_ops.py and the _sample_handler emit block, and the is not None checks in sampling.py. That collapses two code paths into one and naturally yields "keep everything" when unset.

4. Argument ordering — Scott's sampling.py:41 comment

Reorder to temperature, top_k, top_p, seed consistently across mlx::sample, sample_fake, _sample_handler's args[...] unpacking, and the _sample test helper. Note this is a signature change to the registered custom op, so every call site and the require_args(args, 3, 5, ...) indices need to move together.

5. to_int_or_vid already handles the literal/vid split — Scott's ops.py:3682 comment

ops.py:3688-3692:

index=(
IntOrVidOrTid.from_vid(top_k_index.vid)
iftop_k_index.is_videlseIntOrVidOrTid.from_literal(top_k_index.literal)
)

top_k_val comes from ItemIntNode so it's always a Vid here — top_k_iov/top_k_index will never be a literal in this path, making the else branch dead. P.to_int_or_vid_or_tid(top_k_val) (program_builder.py:318) already does this conversion in one call; prefer it over the manual ternary.

6. Test coverage — Scott's test_sample.py:165 comment

  • Add a non-sorted-logits case (e.g. probs 0.15, 0.5, 0.05, 0.3) so the test exercises the sort path rather than already-descending input.
  • Add a top_k > vocab case (Re-sync with internal repository #2) and a combined case where HF vs OR-compose semantics actually differ (Add support for quantized LeakyReLU #1), so the renormalization fix is verified rather than coincidentally passing.

Minor

  • custom_ops.py:431int(top_k.item()) is fine for the eager/meta reference, but confirm the .item() data-dependent read doesn't trip export's symbolic tracing on this op (the seed path already uses ItemIntNode on device, so the device side is fine).
  • Docstring in sampling.py still reads cleanly, but once Rename _pt2e to pt2e #3 lands ("None disables top-k") the wording should reflect the always-present default instead.

Overall the lowering scaffolding (Take → ExpandDims → Less → LogicalOr) is well-built and the node-count test is a nice touch; the substantive work is switching both the reference and emit paths to HF-style sequential filtering and adding the vocab-size clip.
· branch fix-mlx-sample-top-k

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

thanks for your quick review @metascroy will take a look and address them.

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@metascroy Addressed below changes in the latest commit.

  • Reordered the custom op args to temperature, top_k, top_p, seed.
  • Switched sampling to top-k first.. and then top-p on the renormalized probabilities.
  • Made the default top_k path use a max-int sentinel clipped to vocab size.
  • Removed the independent OR-composed top-k/top-p masks in the emit path.
  • Added coverage for non-sorted logits, top_k > vocab, and the combined top-k/top-p case where renormalization changes the result.
    let me know if you have any more suggestions. thanks!

@goutamadwant

Copy link
Copy Markdown
ContributorAuthor

@claude review this code

1 similar comment
@metascroy

Copy link
Copy Markdown
Contributor

@claude review this code

@claude

claudeBot commented Jun 29, 2026

Copy link
Copy Markdown

Claude finished @metascroy's task in 2m 11s —— View job


Code review: top-k support for MLX sample (updated)

I re-read all five changed files plus the _sample_handler emit path. The latest commit cleanly addresses every point from the prior round. Summary below.

Prior review items — all resolved ✅

#ItemStatus
1HF-style sequential filtering (top-k → renormalize → top-p)custom_ops.py:430-444 does top-k on logits, then softmax (renormalizes over the kept set), then top-p. Emit path mirrors this: top-k WhereNode feeds the SoftmaxNode at ops.py:3695, and the old LogicalOrNode OR-compose is gone (test asserts "LogicalOrNode": 0).
2top_k > vocab clippingmin(int(top_k.item()), scaled.shape[-1]) (custom_ops.py:430) and MinimumNode against vocab (ops.py:3622-3628).
3top_k runtime arg with int-max defaultsampling.py:40-43 defaults to torch.iinfo(torch.int64).max; the op signature now always takes top_k.
4Arg ordering temperature, top_k, top_p, seed✅ Consistent across sample, sample_fake, _sample_handler unpacking, and the _sample test helper. require_args(args, 4, 5, ...) updated.
5Use to_int_or_vid_or_tidops.py:3660 now calls P.to_int_or_vid_or_tid(top_k_index) directly — dead literal branch removed.
6Test coverage✅ Added non-sorted input (test_top_k_restricts_to_top_k), top_k > vocab (test_top_k_clips_to_vocab_size), and a true HF-vs-OR divergence case (test_top_k_and_top_p_compose, top_k=3, top_p=0.83 → {0,1}). Plus an end-to-end on-device top-k test and a SampleTopKTest node-count case.

I worked the test_top_k_and_top_p_compose numbers by hand: top_k=3 keeps {0.5,0.3,0.15}, renormalizes to [0.526,0.316,0.158], and top-p=0.83 (prefix [0,0.526,0.842] ≤ 0.83) keeps {0,1}. This is the case that would have failed under the old OR-compose, so the renormalization fix is genuinely exercised now, not coincidentally passing.

Minor observations (non-blocking)

  1. custom_ops.py:432 empty-slice edge when k=0.k is clipped above by vocab but not below. A top_k=0 would make s_scaled[..., -1:0] empty and broadcast oddly. This isn't reachable through SamplingHead (default is int-max, callers pass ≥1), so it's only a concern if the op is called directly with 0. A max(k, 1) clamp or a one-line docstring note that top_k ≥ 1 is expected would close it. Not worth blocking on.

  2. Emit-path top-k threshold vs. reference — confirmed equivalent. The reference keeps scaled >= kth (>=), while the emit path drops via LessNode(scaled, top_k_thresh) i.e. keeps scaled >= thresh too — consistent. Good. The negate→sort→negate dance to get a descending sort (since SortNode is ascending-only) and the TakeNode(index = k-1) correctly pick the k-th largest. Matches the top-p chain's existing pattern.

  3. int(top_k.item()) data-dependent read (custom_ops.py:430): fine for the eager/meta reference; the device path uses ItemIntNode so symbolic tracing is unaffected, same as the existing seed handling.

  4. Buffer-reuse comments in ops.py are accurate (verified scaled/probs/sorted_p live-range notes against their actual reads).

Verdict

Looks good to me — the substantive correctness fix (sequential HF-style filtering + vocab clip) is in on both the reference and the lowered graph, and the tests lock in the behaviors that distinguish it from the old implementation. I couldn't execute the test suite in this sandbox (command approval), so please confirm the documented unittest + compileall runs are green in CI before merging. Only the k=0 clamp (#1) is worth a quick thought; everything else is optional polish.
· branch fix-mlx-sample-top-k

@metascroy

Copy link
Copy Markdown
Contributor

Looks great! Thanks for the contribution @goutamadwant!

@metascroy
metascroy merged commit 035b45a into pytorch:mainJun 29, 2026
190 of 191 checks passed
@nil-is-allnil-is-all added the module: mlx Issues related to MLX Backend: Metal-accelerated inference on Apple Silicon label Jul 6, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.module: mlxIssues related to MLX Backend: Metal-accelerated inference on Apple Siliconrelease notes: llmChanges to llm utilities

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Good First Issue: top-k filtering for mlx::sample (MLX backend)

3 participants

@goutamadwant@metascroy@nil-is-all