Skip to content

perf(torch): eager CallSpec fast path skipping signature.bind in Layer.__call__#23198

Draft
pctablet505 wants to merge 11 commits into
keras-team:masterfrom
pctablet505:perf-P13-eager-callspec
Draft

perf(torch): eager CallSpec fast path skipping signature.bind in Layer.__call__#23198
pctablet505 wants to merge 11 commits into
keras-team:masterfrom
pctablet505:perf-P13-eager-callspec

Conversation

@pctablet505

@pctablet505 pctablet505 commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

Layer.__call__ builds a CallSpec — including a full inspect.Signature.bind, apply_defaults, and a per-argument classification loop — on every layer call. For the overwhelmingly common case of layer(x) (one positional tensor, no kwargs) this is pure Python dispatch overhead on the hot path: ~1.9 µs/call measured, one signature.bind per layer call (≈ 70k per 32-token generation of a small transformer — see RC1 in #22561).

This adds an eager fast path that reproduces the CallSpec fields directly for that case and skips signature.bind entirely. Anything outside the common case falls through to the unchanged slow path.

Mechanism

  • In Layer.__init__, precompute once from self._call_signature: the argument names, the first-parameter name, the non-first defaults, and an eligibility flag self._call_spec_fast_path.
  • The flag is True only when the signature is simple: ≥ 1 parameter, the first is positional, no *args/**kwargs, every non-first parameter has a default, and no default is a tensor or nested structure. Otherwise it is False and the layer always takes the slow path.
  • CallSpec._fast(...) constructs the instance via __new__ and sets exactly the fields the slow __init__ produces for a single positional tensor with no kwargs.
  • Layer.__call__ uses it only when self._call_spec_fast_path and not kwargs and len(args) == 1 and (is_tensor(args[0]) or isinstance(args[0], KerasTensor)).

Why it is safe

  • This is pure-Python bookkeeping before call(); it touches no tensors, ops, or the autograd/trace graph, so it is numerically identical on every backend (verified on numpy/torch/jax/tensorflow, eager and symbolic).
  • Conservative gating: multi-input layers, RNN cells, *args/**kwargs, keyword-only-required signatures, and any call with kwargs or a non-tensor first arg all fall through to the existing CallSpec(...).
  • Independent of Don't leak a layer's signature-default call-context arg to sibling layers #23166 (which edits _resolve_and_populate_arg): the call-context-arg resolution reads the same reproduced fields, so training propagation and the sibling-leak fix are both preserved.

Tests

layer_test.py:

  • a fast-vs-slow CallSpec field-equality matrix across representative signatures (call(inputs), call(inputs, training=None), call(inputs, training=None, mask=None), scalar-default), each for eager tensors and symbolic KerasTensors;
  • an exclusion test confirming multi-arg / varargs / keyword-only-required / tensor-default signatures are not eligible;
  • a numeric test that a Dense forward is bit-identical with the fast path enabled vs forced-slow.

Full layer_test.py passes (62 passed, 2 skipped on torch).

Series context

Part of a 13-PR series for #22561 (root cause RC1). Wave 1 merge order: #23182 -> #23183 -> #23184 -> #23185 -> #23186 -> #23187 -> #23188 -> #23189 -> #23190; independent follow-ups #23198, #23199, #23200, #23201 — this is #23198. compute_output_spec's separate CallSpec (symbolic shape inference) is intentionally left unchanged. Touches layers/layer.py, also touched by #23200 (RC18) — the hunks are disjoint (__init__/CallSpec vs the built/_called lines in __call__).

Contributor Agreement

  • I am a human, and not a bot.
  • I will be responsible for responding to review comments in a timely manner.
  • I will work with the maintainers to push this PR forward until submission.

📊 Performance & stack position

PR 10 of 13 in the #22561 torch eager-overhead series (merge order) · base master · prev: #23190 · next: #23199

Stacked on top of #23190this PR's own change is the top commit; the earlier commits are its parent PRs (CI here exercises the cumulative state). Numerically equivalent to master (generate-32 token-ids identical, Δ = 0).

Cumulative through this PR vs master — Keras[torch], torch 2.12.1+cu130 / RTX 4050, GPU eager, best-of-3 of median-20:

Workload master through this PR speedup
CNN forward 1.08 ms 1.04 ms 1.04×
LLM forward 2.87 ms 2.40 ms 1.20×
LLM generate-32 94.18 ms 78.10 ms 1.21×

Framework context (generate-32 eager, same GPU): raw PyTorch 11.4 ms · raw JAX 985 ms · Keras[jax] 584 ms. Keras[torch] carries the per-call Python dispatch tax this series reduces; Keras[jax] traces it away. Full matrix + methodology: #22561 §7–§8.

Reproduction scripts: gist.

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request introduces an eager fast path for CallSpec to optimize the common case of a single positional tensor and no keyword arguments, bypassing the overhead of inspect.Signature.bind. Tests are added to verify that the fast path produces identical fields to the slow path and correctly excludes unsupported signatures. The review feedback suggests making the nested default argument check more robust by guarding len(v) with hasattr(v, "__len__") to prevent potential TypeError with custom PyTree classes, and optimizing the eager flag assignment in the fast path by using a fast isinstance check instead of backend.is_tensor.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread keras/src/layers/layer.py
Comment on lines +361 to +365
defaults_are_safe = all(
not is_backend_tensor_or_symbolic(v)
and not (tree.is_nested(v) and len(v) > 0)
for v in self._call_arg_defaults.values()
)

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.

medium

In defaults_are_safe, len(v) is called on any default value v where tree.is_nested(v) is True. However, custom PyTree classes or other custom container types registered as nested structures might not implement __len__, which would raise a TypeError during layer initialization.\n\nTo make this check more robust and prevent potential runtime errors with custom nested default arguments, consider guarding the len(v) call with hasattr(v, "__len__").

            defaults_are_safe = all(\n                not is_backend_tensor_or_symbolic(v)\n                and not (tree.is_nested(v) and hasattr(v, "__len__") and len(v) > 0)\n                for v in self._call_arg_defaults.values()\n            )

Comment thread keras/src/layers/layer.py
call_spec.tensor_arguments_names = [first_name]
call_spec.nested_tensor_argument_names = []
call_spec.first_arg = x
call_spec.eager = backend.is_tensor(x)

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.

medium

Since _fast is only called when is_backend_tensor_or_symbolic(x) is True, x is guaranteed to be either a backend tensor or a KerasTensor.\n\nWe can avoid the overhead of calling backend.is_tensor(x) (which performs backend-specific checks and dispatching) by using a fast Python isinstance check against backend.KerasTensor. Since x is either a backend tensor or a KerasTensor, if it is not a KerasTensor, it must be a backend tensor (e.g., eager).

Suggested change
call_spec.eager = backend.is_tensor(x)
call_spec.eager = not isinstance(x, backend.KerasTensor)

@codecov-commenter

codecov-commenter commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.25170% with 61 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.10%. Comparing base (eea8e6e) to head (c3fbca8).
⚠️ Report is 3 commits behind head on master.

Files with missing lines Patch % Lines
keras/src/backend/torch/core.py 70.14% 13 Missing and 7 partials ⚠️
keras/src/utils/traceback_utils.py 60.00% 9 Missing and 5 partials ⚠️
keras/src/layers/core/einsum_dense.py 70.00% 2 Missing and 4 partials ⚠️
keras/src/ops/core.py 25.00% 6 Missing ⚠️
keras/src/layers/core/dense.py 80.95% 2 Missing and 2 partials ⚠️
keras/src/ops/operation.py 62.50% 1 Missing and 2 partials ⚠️
keras/src/backend/common/variables.py 93.75% 1 Missing and 1 partial ⚠️
keras/src/layers/attention/multi_head_attention.py 89.47% 1 Missing and 1 partial ⚠️
keras/src/layers/input_spec.py 94.87% 0 Missing and 2 partials ⚠️
keras/src/losses/loss.py 81.81% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #23198      +/-   ##
==========================================
+ Coverage   80.39%   84.10%   +3.70%     
==========================================
  Files         465      465              
  Lines       69188    69385     +197     
  Branches    11379    11434      +55     
==========================================
+ Hits        55622    58353    +2731     
+ Misses      10742     8084    -2658     
- Partials     2824     2948     +124     
Flag Coverage Δ
keras 83.92% <78.23%> (+3.71%) ⬆️
keras-cpu 83.92% <78.23%> (+4.45%) ⬆️
keras-gpu ?
keras-jax 57.91% <44.89%> (-0.36%) ⬇️
keras-numpy 53.71% <44.89%> (?)
keras-openvino 59.53% <45.57%> (-0.01%) ⬇️
keras-tensorflow 59.50% <45.57%> (-0.31%) ⬇️
keras-torch 58.77% <77.21%> (-0.30%) ⬇️
keras-tpu ?

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@pctablet505 pctablet505 force-pushed the perf-P13-eager-callspec branch 2 times, most recently from 545068c to e674f81 Compare June 30, 2026 20:04
@pctablet505 pctablet505 force-pushed the perf-P13-eager-callspec branch from e674f81 to 23f56ce Compare July 1, 2026 16:37
@pctablet505 pctablet505 closed this Jul 3, 2026
@pctablet505 pctablet505 reopened this Jul 3, 2026
test_quantize_with_specific_equations draws both the layer kernel (via
build()) and the input from the global RNG, which TestCase.setUp does not
seed. The int4 cases run on very small tensors (as few as 8 output
elements), so the quantization MSE has high run-to-run variance: the
typical value sits ~5x under the tolerance, but roughly 1% of unseeded
draws exceed the int4 threshold, intermittently failing the numpy and
openvino CI legs.

Seed the RNG (set_random_seed(1337)) at the start of the test so both the
kernel init and the input are deterministic. Verified across numpy,
openvino, torch, tensorflow and jax: all six int8/int4 cases pass with the
worst MSE still >2x under its tolerance. No production code changes.

(cherry picked from commit 907e506)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants