Skip to content

[Fix] Compile and dispatch a script per target - #173

Merged
yaoyaoding merged 2 commits into
NVIDIA:mainfrom
0z5a:fix-script-target-dispatch
Nov 10, 2026
Merged

yaoyaoding merged 2 commits into
NVIDIA:mainfrom
0z5a:fix-script-target-dispatch

Conversation

@0z5a

@0z5a 0z5a commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

What

tilus.target.scope has no effect on a script that has already been compiled or launched. Under another target, compile() and __call__ return the first target's binary. This PR gives each target its own JIT instance and launch function.

Why

Script(...) returns a cached InstantiatedScript, which keeps its compiled state in three places. None of them includes the target:

  • jit_instances, keyed by jit_key
  • dispatch_table, keyed by (jit_key, tuning_key)
  • the last-launch shortcut in __call__, which compares the raw call arguments

So after compiling for sm_90a, compile() under scope(nvgpu_sm103a) returns the sm_90a build. This matters for the documented use of compile() in CI, and for requires.*, which compiles under scope(...) in the same process.

Adding the target only to the jit_instances key is not enough for __call__: the shortcut and the dispatch table would still return the old function.

How

  • jit_instances is keyed by (target, jit_key).
  • dispatch_tables holds one table per target, and dispatch_table stays the table of the current target.
  • __call__ compares the current target with the last one by identity. When it changes, __call__ switches the table and drops the shortcut.

The tiers below that check are unchanged and do not hash the target, because Target is a nested frozen dataclass and hashing it on every launch would be wasted. tests/lang/test_autotune.py builds the script by hand, so it now sets the last target and runs under scope.

Testing

Host tests ran on the Grace CPU of a GH200: GCC 13.3, CUDA 13.2, PyTorch 2.14.1+cu130, tilus main at f23018a.

tests/lang/test_compile_target.py is new:

test needs main this PR
test_jit_instance_per_target no GPU, no nvcc fails passes
test_compile_builds_each_target (sm_80, sm_90) nvcc, no GPU fails: same program dir passes
test_call_dispatches_on_target: sm_90 → sm_103a → sm_90, stubbed JIT instance no GPU, no nvcc fails passes
test_call_runs_binary_of_target: the kernel writes a marker of the target it was transpiled for; default target, then a compatible base target, then default again GPU fails passes

compile() under scope in one process with this PR, reading the architecture from the built library with cuobjdump -lelf:

order requested ELF arch
1 sm_90a sm_90a
2 sm_103a sm_103a
3 sm_90a sm_90a (same program dir as 1)
4 sm_90 sm_90
5 sm_103 sm_103

Cost of __call__ with a no-op launch, median ns per call over 400k calls, two runs, pinned to one Grace core:

main this PR
same arguments (shortcut) 314, 320 348, 351
alternating arguments (dispatch table) 664, 684 698, 766

That is about 30 ns for the target check on the shortcut path. The second run of the alternating case on this PR looks noisy, since its minimum was 725 ns. Reading the module global directly instead of calling get_current_target() was no faster (medians 358 to 424 ns against 349 to 355 ns), so the PR keeps the call. Real launches take far longer than this.

GPU tests ran on GH200 (sm_90, CUDA 13.2). Not run on B300.

The rest of the CPU-runnable suite (tests/lang, tests/ir, tests/drivers, tests/transforms and the top-level tests) gives the same result on main and here: the same 5 failed because they need a GPU. Passed: 192 on main, 196 here with the 4 new tests, of which 3 run without a GPU and the GPU test compiles only.

ruff check and ruff format --check (0.11.0 from pyproject.toml, 0.12.3 from pre-commit), mypy (1.15.0) and the copyright check pass on the changed files.

Not in this PR

The script-level cache directory is keyed by program text and build options, not by target. When two targets produce identical programs, they share dispatch_table.json and the programs/ symlinks. The binaries are still separate. The saved metadata already includes the target, so a table from another target is ignored and the kernel is re-tuned rather than misdispatched. The only cost is repeated tuning when one cache alternates between targets.

InstantiatedScript cached its JIT instances and launch functions by call arguments only. After compiling or running under one target, a call under scope(other_target) reused the first binary, so compiling for sm_103a after sm_90a returned the sm_90a build.

Key the JIT instances by target as well and keep one dispatch table per target. The last-launch shortcut stays valid until the target changes, so a repeated launch only adds an identity check of the current target.

Signed-off-by: 0z5a <dezhen.lu@student.uni-tuebingen.de>
@copy-pr-bot

copy-pr-bot Bot commented Nov 7, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

…dirs by target

- Resolve the target and its dispatch table once at the start of
  InstantiatedScript.__call__, and keep the last launch as a single
  (target, table, args, func) tuple, so a target change during a call
  cannot record a binary under another target or mix fast-path state.
- Include the target in the scripts/ cache-dir hash, so two targets with
  the same program text no longer share programs/ links and
  dispatch_table.json. Bump SPECIALIZATION_CACHE_VERSION so manifests
  that point at the old shared dirs are not reused.
- Test that the script cache dir differs per target, and update the
  hand-built script in test_autotune to the new attributes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yaoyaoding

Copy link
Copy Markdown
Member

/ok to test fab36d2

@yaoyaoding
yaoyaoding merged commit 8781f4b into NVIDIA:main Nov 10, 2026
9 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants