Skip to content

[interop] Cache subclass checks - #116

Open
guitargeek wants to merge 1 commit into
compiler-research:mainfrom
guitargeek:patch-2
Open

guitargeek wants to merge 1 commit into
compiler-research:mainfrom
guitargeek:patch-2

Conversation

@guitargeek

Copy link
Copy Markdown
Collaborator

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.

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.
@guitargeek guitargeek self-assigned this 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;

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.

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)))

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.

Here IsComplete is only expensive the first time for ROOT then should be cheap. Is that not the case?

@guitargeek

Copy link
Copy Markdown
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

No deployments
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