[interop] Cache subclass checks - #116
Open
guitargeek wants to merge 1 commit into
Open
guitargeek wants to merge 1 commit into
guitargeek wants to merge 1 commit into
Conversation
Passing an object of a derived class to a formal base-class argument, as
well as binding a method call whose 'self' is an instance of a class
derived from the method's declaring class, makes the bindings check
interop::IsSubclass() on every call, and the clang derivation check is
not free.
Memoize IsSubclass() per class pair in the interop wrapper, where the
calls are already serialized by the interop lock. A class that is still
incomplete can gain bases once its definition is loaded, so a negative
answer is only cached when both classes are complete.
For example, calling a C++ function that takes a base-class reference
with a derived instance:
import time
import cppjit
cppjit.cppdef(r"""
struct SubBase {
virtual ~SubBase() = default;
virtual int value() const { return 1; }
};
struct SubDerived : SubBase {
int value() const override { return 2; }
};
int take_base(const SubBase& b) { return b.value(); }
""")
d = cppjit.gbl.SubDerived()
cppjit.gbl.take_base(d) # warm-up
start = time.perf_counter()
for _ in range(200000):
cppjit.gbl.take_base(d)
print(f"200k calls: {time.perf_counter() - start:.2f} s")
This takes 0.08 s instead of 0.22 s per 200k calls in a pristine build
(0.18 s with only the parent commit's caches applied; ~2x further).
Release build against the pinned CppInterOp, min of 7 repetitions,
interleaved A/B. An instrumented build confirms the converter queries
IsSubclass() once per call on this path, so the saved cost is the clang
derivation check itself.
vgvassilev
reviewed
Sep 27, 2026
| // pair. A class that is still incomplete can gain bases once its | ||
| // definition is loaded, so a negative answer is only cached for complete | ||
| // classes. | ||
| static std::map<std::pair<const void*, const void*>, bool> s_subclass_cache; |
Contributor
There was a problem hiding this comment.
The challenge with these cases is error recovery and code reloading/undo. I think this is fine but nominally we should have that in CppInterOp which also should know better when to invalidate these caches. Can we track this in an issue somewhere?
| if (cached != s_subclass_cache.end()) | ||
| return cached->second; | ||
| bool result = Cpp::IsSubclass(derived, base); | ||
| if (result || (Cpp::IsComplete(derived) && Cpp::IsComplete(base))) |
Contributor
There was a problem hiding this comment.
Here IsComplete is only expensive the first time for ROOT then should be cheap. Is that not the case?
Collaborator
Author
|
Thanks for the comments! I'll address them later. For now, what is also important to know is that the PRs I'm opening today all address significant performance regressions in the ROOT tutorials or CMS Combine workflows with the cppjit upgrade in ROOT. |
aaronj0
pushed a commit
to aaronj0/root
that referenced
this pull request
Oct 1, 2026
Passing an object of a derived class to a formal base-class argument, as
well as binding a method call whose 'self' is an instance of a class
derived from the method's declaring class, makes the bindings check
interop::IsSubclass() on every call, and the clang derivation check is
not free.
Memoize IsSubclass() per class pair in the interop wrapper, where the
calls are already serialized by the interop lock. A class that is still
incomplete can gain bases once its definition is loaded, so a negative
answer is only cached when both classes are complete.
For example, calling a C++ function that takes a base-class reference
with a derived instance:
import time
import cppjit
cppjit.cppdef(r"""
struct SubBase {
virtual ~SubBase() = default;
virtual int value() const { return 1; }
};
struct SubDerived : SubBase {
int value() const override { return 2; }
};
int take_base(const SubBase& b) { return b.value(); }
""")
d = cppjit.gbl.SubDerived()
cppjit.gbl.take_base(d) # warm-up
start = time.perf_counter()
for _ in range(200000):
cppjit.gbl.take_base(d)
print(f"200k calls: {time.perf_counter() - start:.2f} s")
This takes 0.08 s instead of 0.22 s per 200k calls in a pristine build
(0.18 s with only the parent commit's caches applied; ~2x further).
Release build against the pinned CppInterOp, min of 7 repetitions,
interleaved A/B. An instrumented build confirms the converter queries
IsSubclass() once per call on this path, so the saved cost is the clang
derivation check itself.
Matches compiler-research/cppjit#116.
aaronj0
pushed a commit
to aaronj0/root
that referenced
this pull request
Oct 1, 2026
Passing an object of a derived class to a formal base-class argument, as
well as binding a method call whose 'self' is an instance of a class
derived from the method's declaring class, makes the bindings check
interop::IsSubclass() on every call, and the clang derivation check is
not free.
Memoize IsSubclass() per class pair in the interop wrapper, where the
calls are already serialized by the interop lock. A class that is still
incomplete can gain bases once its definition is loaded, so a negative
answer is only cached when both classes are complete.
For example, calling a C++ function that takes a base-class reference
with a derived instance:
import time
import cppjit
cppjit.cppdef(r"""
struct SubBase {
virtual ~SubBase() = default;
virtual int value() const { return 1; }
};
struct SubDerived : SubBase {
int value() const override { return 2; }
};
int take_base(const SubBase& b) { return b.value(); }
""")
d = cppjit.gbl.SubDerived()
cppjit.gbl.take_base(d) # warm-up
start = time.perf_counter()
for _ in range(200000):
cppjit.gbl.take_base(d)
print(f"200k calls: {time.perf_counter() - start:.2f} s")
This takes 0.08 s instead of 0.22 s per 200k calls in a pristine build
(0.18 s with only the parent commit's caches applied; ~2x further).
Release build against the pinned CppInterOp, min of 7 repetitions,
interleaved A/B. An instrumented build confirms the converter queries
IsSubclass() once per call on this path, so the saved cost is the clang
derivation check itself.
Matches compiler-research/cppjit#116.
aaronj0
pushed a commit
to aaronj0/root
that referenced
this pull request
Oct 1, 2026
Passing an object of a derived class to a formal base-class argument, as
well as binding a method call whose 'self' is an instance of a class
derived from the method's declaring class, makes the bindings check
interop::IsSubclass() on every call, and the clang derivation check is
not free.
Memoize IsSubclass() per class pair in the interop wrapper, where the
calls are already serialized by the interop lock. A class that is still
incomplete can gain bases once its definition is loaded, so a negative
answer is only cached when both classes are complete.
For example, calling a C++ function that takes a base-class reference
with a derived instance:
import time
import cppjit
cppjit.cppdef(r"""
struct SubBase {
virtual ~SubBase() = default;
virtual int value() const { return 1; }
};
struct SubDerived : SubBase {
int value() const override { return 2; }
};
int take_base(const SubBase& b) { return b.value(); }
""")
d = cppjit.gbl.SubDerived()
cppjit.gbl.take_base(d) # warm-up
start = time.perf_counter()
for _ in range(200000):
cppjit.gbl.take_base(d)
print(f"200k calls: {time.perf_counter() - start:.2f} s")
This takes 0.08 s instead of 0.22 s per 200k calls in a pristine build
(0.18 s with only the parent commit's caches applied; ~2x further).
Release build against the pinned CppInterOp, min of 7 repetitions,
interleaved A/B. An instrumented build confirms the converter queries
IsSubclass() once per call on this path, so the saved cost is the clang
derivation check itself.
Matches compiler-research/cppjit#116.
aaronj0
pushed a commit
to aaronj0/root
that referenced
this pull request
Oct 2, 2026
Passing an object of a derived class to a formal base-class argument, as
well as binding a method call whose 'self' is an instance of a class
derived from the method's declaring class, makes the bindings check
interop::IsSubclass() on every call, and the clang derivation check is
not free.
Memoize IsSubclass() per class pair in the interop wrapper, where the
calls are already serialized by the interop lock. A class that is still
incomplete can gain bases once its definition is loaded, so a negative
answer is only cached when both classes are complete.
For example, calling a C++ function that takes a base-class reference
with a derived instance:
import time
import cppjit
cppjit.cppdef(r"""
struct SubBase {
virtual ~SubBase() = default;
virtual int value() const { return 1; }
};
struct SubDerived : SubBase {
int value() const override { return 2; }
};
int take_base(const SubBase& b) { return b.value(); }
""")
d = cppjit.gbl.SubDerived()
cppjit.gbl.take_base(d) # warm-up
start = time.perf_counter()
for _ in range(200000):
cppjit.gbl.take_base(d)
print(f"200k calls: {time.perf_counter() - start:.2f} s")
This takes 0.08 s instead of 0.22 s per 200k calls in a pristine build
(0.18 s with only the parent commit's caches applied; ~2x further).
Release build against the pinned CppInterOp, min of 7 repetitions,
interleaved A/B. An instrumented build confirms the converter queries
IsSubclass() once per call on this path, so the saved cost is the clang
derivation check itself.
Matches compiler-research/cppjit#116.
aaronj0
pushed a commit
that referenced
this pull request
Oct 3, 2026
Passing an object of a derived class to a formal base-class argument, as
well as binding a method call whose 'self' is an instance of a class
derived from the method's declaring class, makes the bindings check
interop::IsSubclass() on every call, and the clang derivation check is
not free.
Memoize IsSubclass() per class pair in the interop wrapper, where the
calls are already serialized by the interop lock. A class that is still
incomplete can gain bases once its definition is loaded, so a negative
answer is only cached when both classes are complete.
For example, calling a C++ function that takes a base-class reference
with a derived instance:
import time
import cppjit
cppjit.cppdef(r"""
struct SubBase {
virtual ~SubBase() = default;
virtual int value() const { return 1; }
};
struct SubDerived : SubBase {
int value() const override { return 2; }
};
int take_base(const SubBase& b) { return b.value(); }
""")
d = cppjit.gbl.SubDerived()
cppjit.gbl.take_base(d) # warm-up
start = time.perf_counter()
for _ in range(200000):
cppjit.gbl.take_base(d)
print(f"200k calls: {time.perf_counter() - start:.2f} s")
This takes 0.08 s instead of 0.22 s per 200k calls in a pristine build
(0.18 s with only the parent commit's caches applied; ~2x further).
Release build against the pinned CppInterOp, min of 7 repetitions,
interleaved A/B. An instrumented build confirms the converter queries
IsSubclass() once per call on this path, so the saved cost is the clang
derivation check itself.
Matches #116.
aaronj0
pushed a commit
to root-project/root
that referenced
this pull request
Oct 3, 2026
Passing an object of a derived class to a formal base-class argument, as
well as binding a method call whose 'self' is an instance of a class
derived from the method's declaring class, makes the bindings check
interop::IsSubclass() on every call, and the clang derivation check is
not free.
Memoize IsSubclass() per class pair in the interop wrapper, where the
calls are already serialized by the interop lock. A class that is still
incomplete can gain bases once its definition is loaded, so a negative
answer is only cached when both classes are complete.
For example, calling a C++ function that takes a base-class reference
with a derived instance:
import time
import cppjit
cppjit.cppdef(r"""
struct SubBase {
virtual ~SubBase() = default;
virtual int value() const { return 1; }
};
struct SubDerived : SubBase {
int value() const override { return 2; }
};
int take_base(const SubBase& b) { return b.value(); }
""")
d = cppjit.gbl.SubDerived()
cppjit.gbl.take_base(d) # warm-up
start = time.perf_counter()
for _ in range(200000):
cppjit.gbl.take_base(d)
print(f"200k calls: {time.perf_counter() - start:.2f} s")
This takes 0.08 s instead of 0.22 s per 200k calls in a pristine build
(0.18 s with only the parent commit's caches applied; ~2x further).
Release build against the pinned CppInterOp, min of 7 repetitions,
interleaved A/B. An instrumented build confirms the converter queries
IsSubclass() once per call on this path, so the saved cost is the clang
derivation check itself.
Matches compiler-research/cppjit#116.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Passing an object of a derived class to a formal base-class argument, as well as binding a method call whose 'self' is an instance of a class derived from the method's declaring class, makes the bindings check interop::IsSubclass() on every call, and the clang derivation check is not free.
Memoize IsSubclass() per class pair in the interop wrapper, where the calls are already serialized by the interop lock. A class that is still incomplete can gain bases once its definition is loaded, so a negative answer is only cached when both classes are complete.
For example, calling a C++ function that takes a base-class reference with a derived instance:
This takes 0.08 s instead of 0.22 s per 200k calls in a pristine build (0.18 s with only the parent commit's caches applied; ~2x further). Release build against the pinned CppInterOp, min of 7 repetitions, interleaved A/B. An instrumented build confirms the converter queries IsSubclass() once per call on this path, so the saved cost is the clang derivation check itself.