Skip to content

[Caching] Per-offload cache - #852

Closed
hughperkins wants to merge 7 commits into
cache/s2-frontend-splitfrom
cache/s2d-disk-culink
Closed

[Caching] Per-offload cache#852
hughperkins wants to merge 7 commits into
cache/s2-frontend-splitfrom
cache/s2d-disk-culink

Conversation

@hughperkins

Copy link
Copy Markdown
Collaborator

Stacked on #851 (which is stacked on #837). This is the top of the stack and carries the headline result.

Result

Editing one offloaded task of the giant qipc nested-graph _step_kernel and re-running in a fresh process
(freefall NB=1, CUDA, caches on, same harness for every cell):

scenario baseline this stack
cold 22.24 s 20.75 s
warm, unchanged 0.163 s 0.190 s
warm, one offload edited 23.82 s 5.69 s

compile_kernel for the edit case: 18.92 s -> 1.87 s. No regression on cold or warm-unchanged.
q_sum = 1.7492255125 byte-identical throughout.

What this branch adds

  1. Per-task artifact cache (disk). A cubin alone cannot be launched — the launcher and CUDA graph builder run off
    OffloadedTask metadata — so the record is {cubin, tasks, used_tree_ids, struct_for_tls_sizes}. Probed before
    compile_task, so a hit skips CHI->LLVM, link, optimize, PTX and ptxas entirely.
  2. Construct manifests (disk). CHI IR has no round-trippable codec, so instead of persisting a construct's IR we
    persist what it produced: the ordered per-task artifact keys. A hit skips that construct's whole frontend.
  3. Cubin cache re-keyed by the per-task IR key (was a SHA of the sub-module's LLVM text, which could only be
    computed after codegen — so a hit could only ever skip ptxas). Directory namespaced by SM.
  4. Whole-module link skipped when tasks are code-only, removing the prototype's double-build.
  5. clone_block_subset — the split cloned the whole 2685-stmt block per construct, O(constructs x block).
    compile_kernel 7.73 s -> 2.13 s.
  6. Parallel per-task cubin build — 242 serial ptxas -c shell-outs were 9.8 s of a 13.7 s cubin build (LLVM->PTX
    was only 1.6 s). ptxas is a subprocess on private temp files so it parallelises; LLVM->PTX stays serialised because
    those modules may share an LLVMContext. This removed a cold regression of ~7 s.
  7. Memoised AST source-position formatting (see caveat below).

Reviewer notes

Made with Cursor

The slice-1b cubin cache keyed on a SHA of the sub-module's LLVM-IR *text*,
which meant a warm run had to run the whole frontend and per-task CHI->LLVM
codegen just to produce the thing to hash -- so it could only ever skip
PTX+ptxas. Thread the per-task IR key (get_hashed_per_task_cache_key + "#index",
computed from the task IR *before* codegen) from the codegen driver down through
LLVMCompiledKernel::per_construct_keys -> kernel_launcher ->
create_jit_module_culink -> add_module_culink -> get_or_build_construct_cubin.
That key is edit-stable and is the prerequisite for skipping codegen entirely on
a warm one-task edit (next increment).

Also namespace the cubin dir by SM (culink_cubins_<mcpu>): cubins are built with
`ptxas -arch=`, so a cache populated on one GPU must not be loaded on another.
per_construct_keys is copied in LLVMCompiledKernel::clone() -- the launcher
consumes a clone, and dropping it would silently demote to the text-hash path.

Validated on the real qipc SimEngine._step_kernel (freefall, CUDA): run1 writes
164 cubins, run2 reuses all of them, both byte-identical (q_sum 1.7492255125).
Default (non-culink) path unchanged; test_per_offload_cache 2 passed/2 skipped.
Part B.2/B.3. A cubin alone cannot be launched -- the launcher and CUDA graph
builder are driven by OffloadedTask metadata (entry name, dims, gdw level,
stream group, checkpoint id, adstack sizing, snode/arg RW sets) and the runtime
needs used_tree_ids. So persist a complete PerTaskArtifact record {tasks,
used_tree_ids, struct_for_tls_sizes, cubin} keyed by the per-task IR key, via
the existing QD_IO_DEF binary serializer (new header-only
codegen/llvm/per_task_artifact_cache.h; records are .qdb, as the serializer
rejects other extensions).

The codegen driver now probes that cache BEFORE compile_task: on a hit the task
skips CHI->LLVM, link, optimize, PTX and ptxas entirely, and carries the cached
cubin to the cuLink assembly. Replaced the parallel module/key vectors with
PerConstructArtifact, which is either a module-to-compile or a prebuilt cubin
plus its metadata, so the JIT (the only holder of the cubin) can write the
complete record.

Consequently a cached task has no LLVM module, so the whole-module link is
skipped when any task is code-only -- which also removes the prototype's
double-build (link+optimize once per task AND once whole-kernel). Guarded the
three whole-module derefs that this exposes: check() (was verifyModule on a null
module -- the segfault found in testing), dump_impl (reports a dump failure so
the manager skips the .qdc; the artifact cache is that kernel's persistence
instead) and debug_dump_to_string.

Gated on QD_PERTASK_ARTIFACT_CACHE=1 + QD_CULINK_PERTASK=1 for A/B.

Measured on the real qipc SimEngine._step_kernel (freefall, CUDA), fresh process
run 2 vs run 1: all 148 tasks served from disk, pertask_compile 1292.8 -> 49.5ms,
per_construct_selfcontained 2622.6 -> 0.1ms, whole_module_link skipped, step0
37.7 -> 24.0s, q_sum byte-identical (1.7492255125). The residual 24s is the
whole-kernel frontend (merge_global_ptrs et al), which is Part A.

Default path unchanged (28.9s, byte-identical); test_per_offload_cache +
test_fields_builder: 25 passed / 2 skipped.
A construct's frontend output cannot be persisted as IR (no round-trippable CHI
codec), so persist what it *contributed* instead: the ordered list of per-task
artifact keys. On a warm compile a manifest hit lets the split emit one
placeholder task per key and skip that construct's entire frontend
(merge_global_ptrs / full_simplify / offload); codegen then loads the named
artifacts (Part B) and never lowers anything.

Mechanics: the split and codegen communicate through a program-scoped
ConstructDiskPlan side table (task index -> construct key / artifact key) rather
than new OffloadedStmt fields, which would perturb the printed-IR-derived
per-task key. Manifests are keyed by a new cross-process construct key
(get_hashed_per_construct_disk_key) that restores the full SNode-layout hash and
device caps the in-memory key deliberately drops; the layout hash is memoized
per snode-tree, since computing it per construct was a >20x blowup before.

Because the task index is baked into entry-fn / shared_array / adstack-counter
names, a manifest is only reused if the construct lands at the same task offset;
the loader verifies this and treats a shift as a miss.

Measured on the real qipc SimEngine._step_kernel (freefall, CUDA, fresh process):
constructs_manifest_hit 74/130, recompiled 81 -> 7, frontend_ms 3025 -> 21ms;
242/242 tasks served from the artifact cache; compile_kernel 16.89 -> 7.89s;
step0 19.3 -> 16.2s. q_sum byte-identical (1.7492255125) throughout.
…ache

A kernel whose device code is per-task cubins has no whole-kernel LLVM module, and
dump_impl was reporting a dump failure for it -- so it was never written to .qdc,
the whole-kernel cache stayed permanently empty, and every run re-paid the
per-construct path (measured as a ~50x regression on warm-no-change, 0.17s -> 8.6s).

Give such kernels a real .qdc entry instead: serialize per_task_artifact_keys
alongside tasks (both now in LLVMCompiledKernel's QD_IO_DEF) and write an empty
src_code; on load, rebuild per_construct_artifacts from the PerTaskArtifactCache
and leave module null, failing the load if an artifact is missing so the manager
recompiles rather than emitting a kernel with a missing task.

load_impl sits far from any CompileConfig, so the artifact directory is resolved
once in the LlvmProgramImpl constructor rather than threaded through the load path.
The per-construct split cloned the entire top-level block for each construct and
then deleted everything outside that construct's backward slice -- O(constructs x
block size). On the genesis _step_kernel that is 130 deep clones of a 2685-stmt
block, ~5.9 s, and it was paid even when every construct was a cache hit, because
the isolated construct is what the cache key is computed from.

Compute the slice on the original block instead and clone only the surviving
top-level statements, via a new irpass::analysis::clone_block_subset(). It drives
the existing IRCloner per statement pair (the source and target blocks no longer
line up index-for-index) so operand remapping is unchanged: references between
cloned statements are redirected, references outside the subset keep pointing at
the original -- same semantics as a whole-block clone.

compile_kernel 7.73 s -> 2.13 s on the real _step_kernel, with the split
behaviour identical (130 constructs, 49 in-memory / 74 manifest / 7 recompiled)
and key_ms 416 -> 176. q_sum byte-identical (1.7492255125); quadrants
per_offload/fields_builder/offload tests 31 passed, qipc freefall 4 passed.
get_pos_info builds the source-position banner used in error messages and
DebugInfo, and ASTTransformerBase.__call__ calls it for every stmt/expr node
visited. It was the single largest cost on the Python side of a recompile: on the
genesis _step_kernel, ~9.6s of a 17.5s AST transform, mostly inside textwrap
(614k wrap calls, a fresh TextWrapper per node).

The same ~63 functions are transformed ~44 times each (once per inlined call
site), so the identical source position is formatted over and over. Memoize on
what the output depends on -- file, function, indent, line offset and the node's
span. The cache is process-global on purpose: the win is reuse ACROSS transforms
of a function, which a per-context cache would miss. src is not in the key
because for a given file+function+indent+offset it is that function's source,
which cannot change under a running interpreter.

Measured on the real _step_kernel (step0, time-to-first-step):
  warm + one offload edit: 10.27s -> 5.80s   (machinery on)
  warm + one offload edit: 27.16s -> 22.06s  (baseline, no machinery)
  cold:                    34.28s -> 28.74s  (machinery on)
It is a pure Python-side fix, so it helps the baseline equally.
Warm-unchanged unaffected (0.187s). q_sum byte-identical; 109 tests pass.
The cold regression from the per-task cuLink path was 242 serial 'ptxas -c'
shell-outs. Splitting the per-task cubin build showed 9.8s of the 13.7s was
ptxas and only 1.6s was LLVM->PTX, with cuLink itself at 6ms and artifact
writes at 0ms -- so the whole regression was subprocess launches.

Run the per-task builds across hardware_concurrency threads. ptxas is a
subprocess on private temp files and holds no shared state, so it parallelises
safely; LLVM->PTX stays serialised under a mutex because the per-task modules
come off the codegen worker threads and may share an LLVMContext. Each entry
writes only its own slot. Also split assemble_and_store_cubin out so the
parallelisable half is explicit, and made the diagnostic timers atomic.

Cold step0 on the real _step_kernel: 28.74s -> 20.47s (machinery on), i.e. the
regression against baseline is gone. Edit case unchanged at 5.66s, warm
unchanged at 0.190s, q_sum byte-identical.
@hughperkins hughperkins changed the title [Caching] Cross-process per-construct/per-task caches: 4x faster single-offload recompile [Caching] Cross-process per-construct/per-task caches: faster single-offload recompile Aug 7, 2026
@hughperkins hughperkins changed the title [Caching] Cross-process per-construct/per-task caches: faster single-offload recompile [Caching] Per-offload cache Aug 7, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

https://github.com/Genesis-Embodied-AI/quadrants/blob/4860dbd0936e1329f75d767013b86b4e8da45cfd/codegen/codegen.cpp#L195-L198
P1 Badge Skip artifact hits for adstack tasks

For reverse-mode kernels whose offload contains AdStackAllocaStmt, this disk-artifact probe returns before the existing has_adstack guard runs. The in-memory cache below deliberately excludes those tasks because lowering them registers per-task adstack sizing in the Program; a fresh-process artifact hit skips that side effect while still launching the cached cubin. Either run the adstack check before probing/storing artifacts and treat these tasks as misses, or explicitly re-seed the registry from the serialized task metadata.


https://github.com/Genesis-Embodied-AI/quadrants/blob/4860dbd0936e1329f75d767013b86b4e8da45cfd/codegen/codegen.cpp#L127-L128
P2 Badge Require cubin mode for artifact-cache hits

When QD_PERTASK_ARTIFACT_CACHE=1 and QD_CULINK_PERTASK=1 are set but QD_CULINK_CUBIN is not, this enables loading cached cubins even though add_module_culink() will take its PTX path and call compile_module_to_ptx(art.module) on artifacts whose module is null. The same combination also writes construct manifests while the JIT never stores PerTaskArtifact records. Gate artifact-cache use on cubin mode too, or make the JIT consume prebuilt cubins regardless of QD_CULINK_CUBIN.


https://github.com/Genesis-Embodied-AI/quadrants/blob/4860dbd0936e1329f75d767013b86b4e8da45cfd/transforms/compile_to_offloads.cpp#L767
P2 Badge Treat stale construct manifests as misses

A manifest hit is accepted solely because the manifest file exists and has task keys, before verifying that the referenced per-task artifacts are still present/readable. If those artifacts were evicted, corrupted, or the manifest was written before the JIT stored them, the split replaces the real construct with placeholders and codegen later hard-errors because it has no IR to fall back to. While cb is still available here, validate all referenced artifacts and treat any missing artifact as a manifest miss.


https://github.com/Genesis-Embodied-AI/quadrants/blob/4860dbd0936e1329f75d767013b86b4e8da45cfd/transforms/compile_to_offloads.cpp#L858-L862
P2 Badge Preserve manifest plans when construct cache is off

With QD_CONSTRUCT_MANIFEST=1 and QD_CONSTRUCT_CACHE=0, manifest hits above still push placeholder offloads, but this new disk plan is only stored inside the cc != nullptr block. Codegen then sees no artifact_key_by_task, compiles the empty placeholders as ordinary serial tasks, and silently skips the cached construct's real work. Store the ConstructDiskPlan whenever manifests are active, using the program's per_construct_cache() as the side channel even when the in-memory construct cache is disabled.

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@hughperkins

Copy link
Copy Markdown
Collaborator Author

Superseded. Re-splitting this work by what is actually being changed rather than by the branches that happened to exist, and dropping the env gates and diagnostic scaffolding entirely (everything defaults on). Plan: perso_hugh/doc/per_offload_cache/per_offload_prs.md. Branches stay pushed; nothing is lost.

@hughperkins hughperkins closed this Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

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.

1 participant