fix: v0.4.3 correctness and robustness fixes#109
Conversation
H100 benchmark validation (SkyPilot, 1×H100 80GB, torch 2.8.0+cu128)Reran the benches these fixes could plausibly move, via Outcome: no doc tables needed updating — but the run caught one real regression, fixed in 298ffac.
|
Half the file was explanation around rules agents already follow once stated. Keep one line per rule, add the H100 bench-validation and GPU-CI-label protocol that PR #109 proved necessary, require Conventional Commits, and ban AI attribution in commits and PRs.
|
Solid PR, the fixes and tests all check out. One real concern before merge though: The varlen Since both loops in the kernel are plain Two smaller things:
|
…ommits, no AI attribution (#110) * docs: align AGENTS.md with uv tooling, add GPU and docs-drift caveats The checklist commands didn't match CONTRIBUTING.md's uv-managed invocations, implied a green local pytest validates kernel changes (CUDA tests auto-skip without a GPU), and said nothing about keeping the prose docs in sync with the code — the source of most of the doc drift this PR fixes. * docs(agents): condense AGENTS.md into a checklist Half the file was explanation around rules agents already follow once stated. Keep one line per rule, add the H100 bench-validation and GPU-CI-label protocol that PR #109 proved necessary, require Conventional Commits, and ban AI attribution in commits and PRs. * docs(agents): import AGENTS.md into Claude Code via CLAUDE.md Claude Code reads only CLAUDE.md; the @AGENTS.md import is the documented way to apply the shared agent rules there (symlinks need admin rights on Windows and leave no room for Claude-specific notes). * chore: ignore .claude/worktrees/
- chunked top-k: clamp k to the merge width (retrieve + maxsim_topk) - MaxSimScorer.forward: nullcontext instead of forced enable_grad - maxsim_residual_varlen: early-return on Nd == 0 (grid used max(Nd, 1)) - pack_padded: handle B == 0 up front - pylate_compat: forward legacy mask= on the fallback path - maxsim_varlen: bucket max_lq/max_ld constexpr autotune keys; same for the plaid residual kernels' max_Ld - max_seqlen_*: documented as hard loop bounds + on-device assert - maxsim_residual: squeeze 2-D Q back to [Nd] - fp8: drop stale argmax-ties note; fallback works without Triton - lowmem: Triton/CUDA guard + input-dtype (not bf16) wording - plaid/varlen kernels: drop dead B / Lq / Ld params - prune_forward: smallest-footprint fallback instead of configs[:2] - KD path: same device validation as the 3-D path
H100 measurements on the fastplaid e2e bench showed the bucketed loop bound costing 30-50% steady-state throughput on Ld=300 corpora (300 buckets to 512; the masked decompress+dot iterations are not free), while max_Ld is a property of the compressed index — stable across calls — so the per-value autotune sweep already amortizes to one. maxsim_varlen keeps its bucketing: training batches have genuinely varying maxima and the sweep cost there is the documented pain point.
AGENTS.md mandates entries land under '## [Unreleased]'; the version number gets assigned at release time.
- Make max_lq/max_ld plain runtime args of the varlen forward kernel and key its autotune cache on bucketed copies, so sweeps stay capped with zero masked loop iterations (same scheme as the dense kernel's Ld) - Take max_lq as a runtime arg in the varlen backward kernels and drop the dead Nq constexpr, avoiding recompiles on new query maxima - Replace falsy-or fallbacks in prune_forward with explicit None checks so a legit Lq == 0 is not treated as missing - Pin the CHANGELOG section to v0.4.3 (2026-06-10)
31702d7 to
1fc735d
Compare
Thanks for the review! There are no API breaking changes wrt to signatures and types. As agreed, I'll keep this release as a minor. |
Context
A post-v0.4.2 review of the library surfaced five verified bugs (two reproducible crashes among them) plus ten API-contract, docstring, and hardening issues. This PR fixes all fifteen, with regression tests for everything CPU-testable and GPU-gated tests for the rest.
The fixes were validated end-to-end on an H100: the full GPU test suite passes, and the affected benchmarks were re-run to compare against the published tables. That benchmark pass caught a real 30–50% PLAID regression from the initial
max_Ldbucketing change (a 300→512 loop bound tooklik_fullfrom 3.78 to 5.41 ms at 10k×300), reverted for the plaid kernels in298ffac. Full numbers are in the benchmark validation comment.Note
Behavior changes, all listed in the CHANGELOG under
[0.4.3]:maxsim_varlenkeys its autotune cache on power-of-two buckets ofmax_lq/max_ld; the kernel loop bounds stay exact (perf domain only; results unchanged, sweep count capped, zero masked iterations added).maxsim_residualsqueezes a 2-DQback to[Nd]instead of[1, Nd], matching its siblings.max_seqlen_*values are validated with an on-devicetorch._assert_async(kept async to preserve the sync-free hot path these arguments exist for).None of these affect the native LIK backends in PyLate (pylate#222) or colpali-engine (colpali#412): both only call
maxsim/maxsim_pairs/maxsim_mps, whose signatures and return shapes are unchanged (pinned bytests/test_public_api.py); the behavior changes above are all in functions neither imports.Benchmark tables in the docs are untouched: after the plaid revert, every re-measured number matched the published tables within run noise, so changing them would only churn noise.
Validation
ruff check,ruff format --check,ty checkclean-x(only colpali_engine/MPS skips)forward,backward_method,decompress_maxsim,fastplaid_e2e, lowmem backward); cluster torn down afterwardsChanges
Click here to expand
Verified bugs
top_kexceeds the merge width —retrieve(Q, D, top_k=10, chunk=4)raised "selected index k out of range" on both the CPU path (retrieve.py) and the CUDA path (topk.py): each chunk contributes at mostchunkcolumns but the merge always askedtorch.topkfork. The merge now clampskto the merged width; the final output still honorsmin(top_k, Nd).MaxSimScorer.forward()overrode a caller'storch.no_grad()—_score's non-inference branch forcedtorch.enable_grad(), so scoring insidetorch.no_grad()returnedrequires_grad=Truetensors. Nowcontextlib.nullcontext().maxsim_residual_varlenbroken forNd == 0— the grid wasNq * max(Nd, 1)while the kernel computespid // Ndwith constexprNd = 0. The empty[Nq, 0]result is returned before launching.pack_paddedcrashed on an empty batch (B == 0,qlen.max()on a zero-element tensor) — early-exit returning the trivially-emptyPackedBatch; CUDA and CPUmaxsim_paddednow agree on[0, C].mask=silently dropped on the fallback path — the patched wrappers mappedmask=into the fused path but called the original function with a still-Nonedocuments_maskwhen deferring to PyLate (CPU / sub-Ampere /LIK_DISABLE=1). The resolved mask is now forwarded in all three wrappers.API contracts and docstrings
maxsim_varlenautotune sweeps capped by a bucketed cache key —max_lq/max_ldwere constexpr autotune keys with no bucketing, so every distinct pair re-triggered the full sweep (and a recompile), contrary to the docs. They are now exact runtime loop bounds — zero masked iterations, exact-sized argmax buffer — and the autotune cache is keyed on power-of-two-bucketed copies (floor 16) passed as key-only dummy args, the same trick the dense kernel uses to keepLdout of its key. An earlier draft of this fix bucketed the loop bounds themselves; the H100 benchmark pass measured that idea at 30–50% steady-state loss on the plaid kernels (298ffac), and since nothing inbenchmarks/exercisesmaxsim_varlen, the same regression there would have gone unmeasured — hence exact bounds everywhere, bucketing only in the cache key. Plaid keeps exactmax_Ldin its key, which is index-stable per corpus, so its sweep already amortizes to one.max_seqlen_*documented as hard loop bounds, not hints (maxsim_varlen,score_pairs_packed,maxsim_residual_varlen) — a too-small value silently truncated tokens. Values are now validated against thecu_seqlensmaxima on-device.maxsim_residual2-DQcontract — returns[Nd], matchingmaxsim_residual_varlenandmaxsim.maxsimdocstrings corrected.Hardening and dead code
Bconstexpr from_plaid_approx_score_kernel(it forced a recompile per batch size), the unusedLq/Ldplaceholder params from the varlen forward kernel, and the deadNqconstexpr from_varlen_bwd_dQ_kernel; the varlen backward kernels takemax_lqas a runtime arg (mirroring thescore_pairsbackward kernels), so a new query-length maximum no longer recompiles them.prune_forwardreadsmax_lqso varlen pruning is unchanged, with explicitis Nonefallbacks so a legitLq == 0is not treated as missing.maxsim_backward_lowmemgets the same clean Triton/CUDARuntimeErrorguard asmaxsim_backward_unified.prune_forwardfalls back to the two smallest-SMEM configs when nothing survives the budget (was: the first two arbitrary entries, which had just been pruned for overflowing).maxsimwith 4-DD) validates Q/D/mask device agreement like the 3-D path (shared_validate_deviceshelper).retrieve(Triton bf16 kernel on CUDA, compiled reference on MPS, eager reference elsewhere), with a CPU regression test — chosen over a docstring-only fix.Tests
no_gradscorer, empty-batchpack_padded, legacy-mask fallback parity, FP8 CPU fallback,bucket_seqlen/assert_max_seqlen_covers, lowmem guard, prune fallback, prunemax_lqspelling +Lq == 0handling.Next steps
GPU CI needs the
run-gpu-testslabel (triage+) or aworkflow_dispatchof the GPU CI workflow — the new GPU-gated tests have only run on the SkyPilot H100, not in CI. Note the varlen exact-bounds change (1fc735d, post-review) and the rebase onto main landed after that H100 run, so the GPU suite must be re-run before merge.