Faster inference by caching the KV projections - #15
Conversation
The conditioning tensor is constant across ODE solver steps, so its key/value projections in every cross-attention block can be computed once and reused. Adds run_fast_flow / OptimizedFlow and a fast=True flag on FlowPipeline; attention blocks accept an optional precomputed kv tuple. Output is identical to the default path.
There was a problem hiding this comment.
Pull request overview
Adds an opt-in “fast path” for flow-model inference that caches cross-attention K/V projections derived from the (loop-invariant) conditioning features, reducing repeated work across ODE solver evaluations while aiming to keep outputs identical to the existing sampler.
Changes:
- Introduces
helpers/fast_sampler.pywithOptimizedFlowandrun_fast_flow()to precompute conditioning and per-block cached(K, V)projections once per sample batch. - Extends cross-attention blocks in
flow_simpleandflow_llama3to accept optional precomputedkvprojections (default path unchanged). - Adds
fast=Falseoption toFlowPipelineand CPU tests assertingfast=Truematches the baseline sampler.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_inference.py | Adds tests for fast sampler equivalence, error paths, and CPU end-to-end execution via FlowPipeline(fast=True). |
| src/phoenix/models/flow_simple.py | Extends attention/block forward APIs to accept precomputed kv for cross-attention reuse. |
| src/phoenix/models/flow_llama3.py | Same as above for the optimized/flash-attn model variant. |
| src/phoenix/helpers/inference.py | Adds fast flag to FlowPipeline and routes sampling through run_fast_flow when enabled. |
| src/phoenix/helpers/fast_sampler.py | New implementation that precomputes conditioning + per-block cached K/V projections and reuses them during ODE integration. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if kv is not None: | ||
| xk, xv = kv # precomputed by the caller | ||
| if xk.shape[0] != x_bsz: | ||
| raise ValueError(f"cached k/v batch {xk.shape[0]} != query batch {x_bsz}") | ||
| else: |
| if kv is not None: | ||
| xk, xv = kv # precomputed by the caller | ||
| if xk.shape[0] != x_bsz: | ||
| raise ValueError(f"cached k/v batch {xk.shape[0]} != query batch {x_bsz}") | ||
| else: |
| if self._kv is None: | ||
| raise RuntimeError("call prep(c) before velocity()") | ||
| for block, kv in zip(m.blocks, self._kv, strict=True): | ||
| x = block(x, t, None, kv) |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #15 +/- ##
==========================================
+ Coverage 37.96% 41.28% +3.32%
==========================================
Files 9 10 +1
Lines 951 1015 +64
==========================================
+ Hits 361 419 +58
- Misses 590 596 +6
🚀 New features to boost your workflow:
|
|
Thanks team :) Best of luck with the exciting research direction! |
Hi @rushin682
As earlier #8, while running large scale inference runs with phoenix I noticed the sampler was doing a fair amount of repeated work, so I've removed that in this PR... It adds an opt-in 'fast path' that gives roughly a 1.4–1.5× speedup at 0.1/0.01 tolerances, with identical outputs to the current sampler.
Concretely, the sampling process integrates the learned velocity field with an adaptive ODE solver:
The solver calls$v_\theta$ many times (more in lower tol). $x_t$ and $t$ change on every call, but the image features $c$ never do, since it's a constant.
Inside each cross-attention block, the parts that depend on the iamge features are only the key and value projections.$$K = \mathrm{norm}(c W_K), V = c W_V$$ while the queries come from $x_t$ .
So, in every solver step we can avoid re-running the conditioning encoder blocks and re-projecting the same$K, V$ in every layer, since they will be identical each time.
Only$Q$ actually varies across steps, everything downstream of $c$ can be moved out of the integrand.
Changes:
helpers/fast_sampler.pywith OptimizedFlow, which runs the conditioning path once (prep(c)), caches the per-blockrun_fast_flowis a drop-in replacement forrun_flow.flow_llama3andflow_simpleaccept an optional precomputed kv tuple (if absent, nothing changes)FlowPipelinegains afast=Falseflag, so the default behaviour is untouched (this is purely opt-in)For the dtypes, the cache mirrors the existing dtype handling (fp32 conditioning path, bf16 inside the attention blocks), so the fast path produces the same tensors as the stock path. The inference savings intuitively increase with lower tolerances since you are making many more sampler evaluations.
Testing
Added CPU tests in
tests/test_inference.pycovering the fast sampler, including a check thatfast=Trueand the default path agree.I also benchmarked both paths across a range of tolerances in a notebook (happy to share it, but didn't include it here for brevity), per-step cost drops consistently, and outputs are identical.
Thanks again for the great contribution! Very happy to adjust anything to suit the codebase :)
Best,
Harry