Skip to content

Ifc5d quantify function time#7322

Merged
maxfb87 merged 2 commits intoIfcOpenShell:v0.8.0from
maxfb87:Ifc5d_time
Nov 8, 2025
Merged

Ifc5d quantify function time#7322
maxfb87 merged 2 commits intoIfcOpenShell:v0.8.0from
maxfb87:Ifc5d_time

Conversation

@maxfb87
Copy link
Copy Markdown
Contributor

@maxfb87 maxfb87 commented Nov 5, 2025

Useful for quantify function optimization

Useful for quantify function optimization
@sboddy
Copy link
Copy Markdown
Contributor

sboddy commented Nov 5, 2025

Rather than polluting the local namespace with start/end time variables, have you seen the profile class:
https://github.com/IfcOpenShell/IfcOpenShell/blob/v0.8.0/src/bonsai/bonsai/bim/module/drawing/operator.py#L72-L84
with example usage:
https://github.com/IfcOpenShell/IfcOpenShell/blob/v0.8.0/src/bonsai/bonsai/bim/module/drawing/operator.py#L306-L359
It's nice because it lets you time discrete steps, and nest timers for a cumulative value, and is just one line in the profiled code.

@maxfb87
Copy link
Copy Markdown
Contributor Author

maxfb87 commented Nov 6, 2025

@sboddy i didn't know that, thanks for the hint!
Do you think that i can directly use it or have i to create a similar one?
I ask that because the profile function is stored in the drawing module and i use it in the ifc5d...

@sboddy
Copy link
Copy Markdown
Contributor

sboddy commented Nov 6, 2025

It's kinda basic, so just copying the import of timer and the profile class across to qto.py would be a simple way. I did notice one method in the qto.py also uses a variable profile, so you might want a different class name. Another thought is that it might be better to move the class into a utility module somewhere. So something like src/ifcopenshell-python/ifcopenshell/util/profiler.py:

from timeit import default_timer as timer

class Profiler:
    """
    A python context manager timing utility
    """

    def __init__(self, task):
        self.task = task

    def __enter__(self):
        self.start = timer()

    def __exit__(self, *args):
        print(self.task, timer() - self.start)

Then in qto.py add the import:

from ifcopenshell.util.profiler import Profiler

and your change would become:

def quantify(ifc_file: ifcopenshell.file, elements: set[ifcopenshell.entity_instance], rules: dict) -> ResultsDict:
    """Quantify elements from a rules using preset quantification rules

    Rules placed as a JSON configuration file in the ``ifc5d`` folder will be
    autodetected and loaded with the module for convenience.

    :param rules: Set of rules from `ifc5d.qto.rules`.
    """
    with Profiler("Quantify function TIME: "):
        results: ResultsDict = {}
        elements_by_classes: defaultdict[str, set[ifcopenshell.entity_instance]] = defaultdict(set)
        for element in elements:
            elements_by_classes[element.is_a()].add(element)

        for calculator, queries in rules["calculators"].items():
            calculator = calculators[calculator]
            # Cache schema entity names once per file
            schema_entity_names = lower_case_entity_names(ifc_file.schema_identifier)

            # Fast path: all queries are simple entity names
            if not set(m.lower() for m in queries.keys()) - schema_entity_names:
                casenorm = {k.lower(): k for k in queries.keys()}
                pred = lambda inst: inst.is_a().lower()

                for ty, group in itertools.groupby(sorted(elements, key=pred), key=pred):
                    group_elements = list(group)
                    # check entity type and its supertypes for matching QTO rules
                    for sty in entity_supertypes(ifc_file.schema_identifier, ty):
                        if qtos := queries.get(casenorm.get(sty)):
                            calculator.calculate(ifc_file, group_elements, qtos, results)

            # Fallback: per-query evaluation
            for query, qtos in queries.items():
                # Simple entity name: use by_type but restrict to incoming elements
                if query.lower() in schema_entity_names:
                    by_type_all = ifc_file.by_type(query)
                    # ensure we don't expand beyond provided elements subset
                    if isinstance(elements, set):
                        filtered_elements = [e for e in by_type_all if e in elements]
                    else:
                        elements_set = set(elements)
                        filtered_elements = [e for e in by_type_all if e in elements_set]
                else:
                    filtered_elements = ifcopenshell.util.selector.filter_elements(ifc_file, query, elements)

                if filtered_elements:
                    calculator.calculate(ifc_file, filtered_elements, qtos, results)

    return results

Making it a common utility would mean it could be factored out of the drawing/operator.py and also used elsewhere.

(Be aware, that is all just copy and pasted, and untested. Might need poking, although it looks right.)

@maxfb87
Copy link
Copy Markdown
Contributor Author

maxfb87 commented Nov 6, 2025

Yeah, i think creating a common utility would be a good idea. I'll go with that, thanks!

Another question, do you know why the name is profile? Is it a common practice or a sort of standard? Because a more understandable name would be something related with time (like timer or similar...), right?

@sboddy
Copy link
Copy Markdown
Contributor

sboddy commented Nov 6, 2025

The standard terminology for measuring function run time is "profiling". Python actually has built-in libraries for profiling:
https://docs.python.org/3/library/profile.html
This class is closer to the module it actually imports than profiling per se:
https://docs.python.org/3/library/timeit.html
I figure the originator was just too tired to come up with something original for something that neatly extended timeit ;-)

@maxfb87
Copy link
Copy Markdown
Contributor Author

maxfb87 commented Nov 6, 2025

Ok...so does it make sense to use the python library instead of a self-created one?

@steverugi
Copy link
Copy Markdown

Hi, @maxfb87 and @sboddy please explain what this is supposed to do (in simple terms)
many thanks

@maxfb87
Copy link
Copy Markdown
Contributor Author

maxfb87 commented Nov 6, 2025

@steverugi nothing special, it allows to measure the performance of a specific function (in this case the quantify function).
I need it in order to do a little (hopefully) improvement of the script

@sboddy
Copy link
Copy Markdown
Contributor

sboddy commented Nov 6, 2025

"Well, akshullay" ;-) this PR and my suggestion will time any arbitrary section of code. It is just a convenience wrapper around some calls to the timeit module. If you just want to log the execution time of each single call to this function it would be better to put the with statement one level up, i.e. the line that calls quantify:

from ifc5d.qto import quantify
from ifcopenshell.util.profiler import Profiler

with Profiler("Quantify function TIME: "):
    quantify(<<passed variables>>)

And not actually touching the qto.py file. It is a much smaller, cleaner diff too.

The profile module is a library for doing collecting broad statistics about how long functions/methods take. Instead of getting a single figure, it will break down each subsequently called function. (no of calls, cumulative time, time per call, etc.) There are viewers that let you look at performance visually with this captured info, i.e. See #5696 (comment) where I was profiling Bonsai exiting drawing mode back to model mode.

There is also cProfile module let's you do something similar using a with statement as above, rather than profiling the whole app. I haven't used this myself so far, so what follows is just spitballing :-) This would let you collect statistics on each call within quantify. So something like:

from ifc5d.qto import quantify
import cProfile

with cProfile.Profile() as pr:
    quantify(<<passed variables>>)

    pr.print_stats()

This would dump something like this:

214 function calls (207 primitive calls) in 0.002 seconds

Ordered by: cumulative time

ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     1    0.000    0.000    0.002    0.002 {built-in method builtins.exec}
     1    0.000    0.000    0.001    0.001 <string>:1(<module>)
     1    0.000    0.000    0.001    0.001 __init__.py:250(compile)
     1    0.000    0.000    0.001    0.001 __init__.py:289(_compile)
     1    0.000    0.000    0.000    0.000 _compiler.py:759(compile)
     1    0.000    0.000    0.000    0.000 _parser.py:937(parse)
     1    0.000    0.000    0.000    0.000 _compiler.py:598(_code)
     1    0.000    0.000    0.000    0.000 _parser.py:435(_parse_sub)

This will give you much more evidence about what part of quantify is taking too long.

@steverugi
Copy link
Copy Markdown

thanks guys, much appreciated

@maxfb87
Copy link
Copy Markdown
Contributor Author

maxfb87 commented Nov 7, 2025

@sboddy i have added a profiler function. Could you take a look please?

@sboddy
Copy link
Copy Markdown
Contributor

sboddy commented Nov 7, 2025

Making the obvious assumption that you tested it, and it worked, it looks good to me. Not sure if you'd like to clean up the drawing/operator.py file to use this too. No point in having duplicated code.

@maxfb87
Copy link
Copy Markdown
Contributor Author

maxfb87 commented Nov 8, 2025

@sboddy yeah you are right i was thinking about create a new issue in order to improve the visibility of the new profiler function

@maxfb87 maxfb87 merged commit ed2b2de into IfcOpenShell:v0.8.0 Nov 8, 2025
1 of 3 checks passed
@maxfb87 maxfb87 deleted the Ifc5d_time branch November 8, 2025 19:41
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.

3 participants