Converting an HLT2 line to ThOr functors

This tutorial will show you how to convert an HLT2 line selection from using LoKi functors to using ThOr functors.

Introduction

We use functor expressions in Moore to define particle selections in filter and combiner algorithms. The configuration below may look familiar to you if you have experience writing Run 1/2 selections or HLT2 selections before mid-2021:

from Hlt2Conf.algorithms import ParticleFilterWithPVs


def filter_protons(protons, pvs):
    return ParticleFilterWithPVs(
        protons,
        pvs,
        Code="(PT > 500 * MeV) & (PIDp > 5) & (MIPCHI2DV(PRIMARY) > 4)",
    )

The string passed as the Code property of the filter algorithm defines a functor expression.

Specifically, the algorithm accepts a string which is translated to a C++ LoKi functor object during the algorithm’s initialization phase. Input particles are passed in to the functor which returns ‘true’ or ‘false’ if the particle does or does not pass the selection. The LoKi framework, which includes more just functors, underpinned almost all of the selection algorithms used in Runs 1 and 2 as well as those used in the early HLT2 configuration for Run 3.

We are currently undergoing a transition to a new framework, dubbed ThOr (for Throughput Oriented). ThOr allows us to exploit modern, parallel CPU architectures for increased throughput, as well as allowing us to refine the user-facing aspects of selections in LHCb.

We can express the same selection above using ThOr. It looks like this:

import Functors as F
from GaudiKernel.SystemOfUnits import MeV
from Hlt2Conf.algorithms_thor import ParticleFilter


def filter_protons(protons, pvs):
    return ParticleFilter(
        Input=protons,
        Cut=(F.PT > 500 * MeV) & (F.PID_P > 5) & (F.MINIPCHI2(pvs) > 4),
    )

As you can see, this looks pretty similar to the original configuration. This is intentional! The transition to ThOr should be fairly painless, and this tutorial is here to help.

From LoKi to ThOr

We’ll walk through a couple of concrete examples to illustrate the changes that need to be made.

You can skip to the Summary if you need a recap or if you’d like to dive right in with only a high-level explanation of the necessary changes.

Particles filters

Here is the configuration of a particle filter using the LoKi framework, taken from the Writing an HLT2 line tutorial:

from GaudiKernel.SystemOfUnits import GeV
from Hlt2Conf.algorithms import ParticleFilterWithPVs, require_all


def filter_protons(particles, pvs, pt_min=0.5 * GeV, mipchi2_min=9, dllp_min=5):
    code = require_all(
        'PT > {pt_min}',
        'MIPCHI2DV(PRIMARY) > {mipchi2_min}',
        'PIDp > {dllp_min}').format(
            pt_min=pt_min,
            mipchi2_min=mipchi2_min,
            dllp_min=dllp_min)
    return ParticleFilterWithPVs(particles, pvs, Code=code)

As we’ve seen in the introduction, LoKi functors are expressed as strings whereas ThOr functors are built from Python objects. So the first thing we’ll do is remove the string quotes ' and drop the format call because we can use the cut values directly:

from GaudiKernel.SystemOfUnits import GeV
from Hlt2Conf.algorithms import ParticleFilterWithPVs
import Functors as F


def filter_protons(particles, pvs, pt_min=0.5 * GeV, mipchi2_min=9, dllp_min=5):
    code = F.require_all(
        PT > pt_min,
        MIPCHI2DV(PRIMARY) > mipchi2_min,
        PIDp > dllp_min)
    return ParticleFilterWithPVs(particles, pvs, Code=code)

This will fail very quickly if we try to run it because there are no PT, MIPCHI2DV, or other functor-like symbols defined in the scope of the function.

We need to import the ThOr functor module as use the functor objects from that:

import Functors as F
from GaudiKernel.SystemOfUnits import GeV
from Hlt2Conf.algorithms import ParticleFilterWithPVs


def filter_protons(particles, pvs, pt_min=0.5 * GeV, mipchi2_min=9, dllp_min=5):
    code = F.require_all(
        F.PT > pt_min,
        F.MIPCHI2DV(PRIMARY) > mipchi2_min,
        F.PIDp > dllp_min)
    return ParticleFilterWithPVs(particles, pvs, Code=code)

This will also fail very quickly if we try to run it because some ThOr functor names are different from their LoKi counterparts. Some, like PT, are the same, whilst others, like PID_P, are different. Using the Functor translation tables we can change our expression to use ThOr names:

import Functors as F
from GaudiKernel.SystemOfUnits import GeV
from Hlt2Conf.algorithms import ParticleFilterWithPVs


def filter_protons(particles, pvs, pt_min=0.5 * GeV, mipchi2_min=9, dllp_min=5):
    code = F.require_all(
        F.PT > pt_min,
        F.MINIPCHI2(PRIMARY) > mipchi2_min,
        F.PID_P > dllp_min)
    return ParticleFilterWithPVs(particles, pvs, Code=code)

The final step in making the cut a valid ThOr functor expression is to pass the primary vertex container directly to the functor that needs it, MINIPCHI2, replacing the LoKi-specific PRIMARY placeholder:

import Functors as F
from GaudiKernel.SystemOfUnits import GeV
from Hlt2Conf.algorithms import ParticleFilterWithPVs


def filter_protons(particles, pvs, pt_min=0.5 * GeV, mipchi2_min=9, dllp_min=5):
    code = F.require_all(
        F.PT > pt_min,
        F.MINIPCHI2(pvs) > mipchi2_min,
        F.PID_P > dllp_min)
    return ParticleFilterWithPVs(particles, pvs, Code=code)

An additional change we need to make is to wrap the expression in the FILTER functor. This converts the expression from one which acts on individual objects, e.g. one particle, to one which acts on a container of objects, which is what we want our filter to do:

import Functors as F
from GaudiKernel.SystemOfUnits import GeV
from Hlt2Conf.algorithms import ParticleFilterWithPVs


def filter_protons(particles, pvs, pt_min=0.5 * GeV, mipchi2_min=9, dllp_min=5):
    code = F.require_all(
        F.PT > pt_min,
        F.MINIPCHI2(PRIMARY) > mipchi2_min,
        F.PID_P > dllp_min)
    return ParticleFilterWithPVs(particles, pvs, Code=F.FILTER(code))

There are only two steps left.

  1. Change the require_all helper to a function that works on ThOr functor objects rather than strings.

  2. Change the filter algorithm to one which accepts ThOr functors.

We’ll tackle these in one step by importing the appropriate helpers from the Hlt2Conf.algorithms_thor module:

import Functors as F
from GaudiKernel.SystemOfUnits import GeV
from Hlt2Conf.algorithms_thor import ParticleFilter


def filter_protons(particles, pvs, pt_min=0.5 * GeV, mipchi2_min=9, dllp_min=5):
    code = F.require_all(
        F.PT > pt_min,
        F.MINIPCHI2(pvs) > mipchi2_min,
        F.PID_P > dllp_min)
    return ParticleFilter(particles, F.FILTER(code))

Note that we’re now passing F.FILTER(code) as a positional argument.

But there’s another subtley here! Did you see it? The filter algorithm no longer has a dependency on the primary vertex container pvs. This is because it is now the functor which has the dependency, so the algorithm no longer needs one.

And that’s it. This selection is now using ThOr functors.

Particle combiners

Combiners look more difficult to translate than filters because they have more properties, but the mechanics of translation are almost identical.

We’ll take this combiner algorithm, again from the Writing an HLT2 line tutorial:

from GaudiKernel.SystemOfUnits import MeV, mm
from Hlt2Conf.algorithms import ParticleCombinerWithPVs, require_all


def make_lambdacs(protons,
                  kaons,
                  pions,
                  pvs,
                  am_min=2080 * MeV,
                  am_max=2480 * MeV,
                  apt_min=2000 * MeV,
                  amindoca_max=0.1 * mm,
                  vchi2pdof_max=10,
                  bpvvdchi2_min=25):
    combination_code = require_all("in_range({am_min}, AM, {am_max})",
                                   "APT > {apt_min}",
                                   "AMINDOCA('') < {amindoca_max}").format(
                                       am_min=am_min,
                                       am_max=am_max,
                                       apt_min=apt_min,
                                       amindoca_max=amindoca_max)

    vertex_code = require_all("CHI2VXNDOF < {vchi2pdof_max}",
                              "BPVVDCHI2() > {bpvvdchi2_min}").format(
                                  vchi2pdof_max=vchi2pdof_max,
                                  bpvvdchi2_min=bpvvdchi2_min)

    return ParticleCombinerWithPVs(
        particles=[protons, kaons, pions],
        pvs=pvs,
        DecayDescriptors=["[Lambda_c+ -> p+ K- pi+]cc"],
        CombinationCut=combination_code,
        VertexCut=vertex_code)

We start by removing the string quotes and format calls:

from GaudiKernel.SystemOfUnits import MeV, mm
from Hlt2Conf.algorithms import ParticleCombinerWithPVs, require_all


def make_lambdacs(protons,
                  kaons,
                  pions,
                  pvs,
                  am_min=2080 * MeV,
                  am_max=2480 * MeV,
                  apt_min=2000 * MeV,
                  amindoca_max=0.1 * mm,
                  vchi2pdof_max=10,
                  bpvvdchi2_min=25):
    combination_code = require_all(in_range(am_min, AM, am_max),
                                   APT > apt_min,
                                   AMINDOCA('') < amindoca_max)

    vertex_code = require_all(CHI2VXNDOF < vchi2pdof_max,
                              BPVVDCHI2() > bpvvdchi2_min)

    return ParticleCombinerWithPVs(
        particles=[protons, kaons, pions],
        pvs=pvs,
        DecayDescriptors=["[Lambda_c+ -> p+ K- pi+]cc"],
        CombinationCut=combination_code,
        VertexCut=vertex_code)

Next, we import the ThOr functors module to have access to the functor objects:

import Functors as F
from GaudiKernel.SystemOfUnits import MeV, mm
from Hlt2Conf.algorithms import ParticleCombinerWithPVs, require_all


def make_lambdacs(protons,
                  kaons,
                  pions,
                  pvs,
                  am_min=2080 * MeV,
                  am_max=2480 * MeV,
                  apt_min=2000 * MeV,
                  amindoca_max=0.1 * mm,
                  vchi2pdof_max=10,
                  bpvvdchi2_min=25):
    combination_code = F.require_all(in_range(am_min, AM, am_max),
                                   F.APT > apt_min,
                                   F.AMINDOCA('') < amindoca_max)

    vertex_code = F.require_all(F.CHI2VXNDOF < vchi2pdof_max,
                              F.BPVVDCHI2() > bpvvdchi2_min)

    return ParticleCombinerWithPVs(
        particles=[protons, kaons, pions],
        pvs=pvs,
        DecayDescriptors=["[Lambda_c+ -> p+ K- pi+]cc"],
        CombinationCut=combination_code,
        VertexCut=vertex_code)

Using the Functor translation tables we translate from ThOr functor names to LoKi functor names:

import Functors as F
from Functors.math import in_range
from GaudiKernel.SystemOfUnits import MeV, mm
from Hlt2Conf.algorithms import ParticleCombinerWithPVs, require_all


def make_lambdacs(protons,
                  kaons,
                  pions,
                  pvs,
                  am_min=2080 * MeV,
                  am_max=2480 * MeV,
                  apt_min=2000 * MeV,
                  amindoca_max=0.1 * mm,
                  vchi2pdof_max=10,
                  bpvvdchi2_min=25):
    combination_code = F.require_all(in_range(am_min, F.MASS, am_max),
                                   F.PT > apt_min,
                                   F.MAXDOCACUT(amindoca_max))

    vertex_code = F.require_all(F.CHI2DOF < vchi2pdof_max,
                              F.BPVFDCHI2(pvs) > bpvvdchi2_min)

    return ParticleCombinerWithPVs(
        particles=[protons, kaons, pions],
        pvs=pvs,
        DecayDescriptors=["[Lambda_c+ -> p+ K- pi+]cc"],
        CombinationCut=combination_code,
        VertexCut=vertex_code)

This translation is a little more involved than the filter example above. We’ll go over the changes explicitly here as they’re quite common:

  • The in_range functor does not live in the Functors module directly but in Functors.math, so we import it from there.

  • The A series of LoKi functors, which act on Arrays of particles before the vertex fit, are expressed more explicitly in ThOr.

    • The AM functor becomes F.MASS.

    • The APT functor becomes F.PT.

    • The AMINDOCA('') < value functor becomes F.MAXDOCACUT(value).

  • The BPVVDCHI2 translates to BPVFDCHI2 which has an explicit data dependency on the primary vertex container pvs.

Because the functor names have changed we will also change some of the argument names of the make_lambdacs function to better reflect what they’re used for:

import Functors as F
from Functors.math import in_range
from GaudiKernel.SystemOfUnits import MeV, mm
from Hlt2Conf.algorithms import ParticleCombinerWithPVs, require_all


def make_lambdacs(protons,
                  kaons,
                  pions,
                  pvs,
                  comb_m_min=2080 * MeV,
                  comb_m_max=2480 * MeV,
                  comb_pt_min=2000 * MeV,
                  comb_doca_max=0.1 * mm,
                  vchi2pdof_max=10,
                  bpvfdchi2_min=25):
    combination_code = F.require_all(in_range(comb_m_min, F.MASS, comb_m_max),
                                   F.PT > comb_pt_min,
                                   F.MAXDOCACUT(comb_doca_max))

    vertex_code = F.require_all(F.CHI2DOF < vchi2pdof_max,
                              F.BPVFDCHI2(pvs) > bpvfdchi2_min)

    return ParticleCombinerWithPVs(
        particles=[protons, kaons, pions],
        pvs=pvs,
        DecayDescriptors=["[Lambda_c+ -> p+ K- pi+]cc"],
        CombinationCut=combination_code,
        VertexCut=vertex_code)

With the hardest part complete, we now replace the require_all and ParticleCombinerWithPVs functions with their ThOr counterparts from Functors:

import Functors as F
from Functors.math import in_range
from GaudiKernel.SystemOfUnits import MeV, mm
from Hlt2Conf.algorithms_thor import ParticleCombiner


def make_lambdacs(protons,
                  kaons,
                  pions,
                  pvs,
                  am_min=2080 * MeV,
                  am_max=2480 * MeV,
                  apt_min=2000 * MeV,
                  amindoca_max=0.1 * mm,
                  vchi2pdof_max=10,
                  bpvvdchi2_min=25):
    combination_code = F.require_all(in_range(am_min, F.MASS, am_max),
                                   F.PT > apt_min,
                                   F.MAXDOCA < amindoca_max)

    vertex_code = F.require_all(F.CHI2DOF < vchi2pdof_max,
                              F.BPVFDCHI2(pvs) > bpvvdchi2_min)

    return ParticleCombiner(
        [protons, kaons, pions],
        DecayDescriptor="[Lambda_c+ -> p+ K- pi+]cc",
        CombinationCut=combination_code,
        CompositeCut=vertex_code)

As with the filter, the combiner no longer has a data dependency on the primary vertex container because the functor has that dependency directly. Note finally that this algorithm accepts a single decay descriptor (which can reconstruct up to two decays if the []cc syntax is used).

Note

The combiner can take additional cuts on the various two-body sub-combinations, for example one could do:

twobody_code = F.MAXDOCACHI2CUT(10.)
return ParticleCombiner(
    [protons, kaons, pions],
    DecayDescriptor="[Lambda_c+ -> p+ K- pi+]cc",
    Combination12Cut=twobody_code,
    CombinationCut=combination_code,
    CompositeCut=vertex_code)

This mirrors the behaviour of the N3BodyDecays and N4BodyDecays algorithms used in Runs 1 and 2.

You are encouraged to apply selections on sub-combinations as this can greatly increase the average execution time of your algorithm by rejecting early combinations which anyhow wouldn’t pass the later cuts.

Effective sub-combination cuts include invariant mass requirements (as, for example, the \(pK^{-}\) pair originating from a true \(\Lambda_{c}^{+} \to pK^{-}\pi^{+}\) decay will have an invariant mass within some kinematic limits) and pairwise distance requirements.

And that’s it. This selection is now using ThOr functors as well.

Primary vertices

The examples above assume some pvs object exist, which represents the container of primary vertices reconstructed in the event.

The standard way of obtaining this container in HLT2 lines is like this:

from RecoConf.reconstruction_objects import make_pvs

def particle_maker(make_pvs=make_pvs):
    pvs = make_pvs()
    # Do something with pvs

Algorithms which use ThOr functors expect PVs in a different format. There’s one change you need to make to accommodate this:

from RecoConf.reconstruction_objects import make_pvs

That is: rather than using the RecoConf.reconstruction_objects.make_pvs function to create the pvs object, use the RecoConf.reconstruction_objects.make_pvs function instead.

The log file

You may notice these sorts of messages appearing in your log file once you start using ThOr functors:

FunctorFactory       INFO New functor library will be created.
FunctorFactory       INFO Compilation of functor library took n seconds

ThOr works by compiling the functor expression constructed in Python into a C++ object and then executing this C++ object in the algorithm. This message just says that it is compiling the C++ functor on the fly, rather than using a cache-based approach which is used when running HLT2 in production.

Missing functors

It may happen that the LoKi functor you want to use does not yet have an equivalent ThOr functor. This is one place where you can make a big difference!

You can write the ThOr functor you need. This is a huge help because almost all functors are used by many people, so many other analysts will benefit from your efforts.

There are just a couple of steps to get started:

  1. Check on the Upgrade HLT2 Mattermost channel to double check that a suitable functor doesn’t already exist. It might be that the Functor translation tables is incomplete or incorrect.

  2. Open an issue on the Moore repository that describes the missing functor. Assign yourself to it and add the ThOr label. Someone will soon comment with instructions on what code you should dive in to.

Don’t worry if you’ve not done much work on LHCb code before. There are always folks available to help you along the way!

Compatibility with LoKi

An easy way to develop your lines during the transition is to switch to ThOr function by function.

Most HLT2 lines are written as the composition of fairly small, self-contained functions which each do one thing and return the result, such as a container of filtered protons.

The algorithms from the Hlt2Conf.algorithms_thor module we’ve used in this tutorial, ParticleFilter and ParticleCombiner, are compatible with the algorithms from the Hlt2Conf.algorithms module. They can be mixed within an HLT2 line selection.

This means that you can convert a single function, say a filtered-particle maker, check that your line still works by running the options files you use for testing, and then move on to converting the next function.

LoKi compatibility also means that you can leave certain transitions for later, for example if a ThOr functor equivalent is not yet available and you don’t want your development of that missing functor to hold up the rest of the transition. In this case you could leave a single filter in your selection as a LoKi algorithm.

Still, it’s best to convert as much as you can to ThOr. It will confuse newcomers to see a mixture of LoKi and ThOr functors in your line’s configuration, and going forward we will begin to deprecate LoKi before dropping support for it.

Summary

The ThOr selection framework aims to greatly increase the throughput of HLT2 selections. Part of this framework is a new way of expression functor-based selections in algorithm configuration like filters and combiners.

In brief, one can convert a LoKi-based selection algorithm to a ThOr-based one using the following steps:

  1. Convert strings to expressions of objects (i.e. remove the quote characters " and '').

  2. Import the ThOr functors module import Functors as F and any units you have used inside functor strings from GaudiKernel.SystemOfUnits.

  3. Convert LoKi functor names to ThOr functor names using the Functor translation tables, e.g. PT to F.PT, PIDp to PID_P.

  4. Pass primary vertices as data dependencies to ThOr functors that need PVs and remove the PV dependency from algorithms.

    • The primary vertex objects accepted by ThOr functors are ‘v2’ PVs created using the make_pvs function. Import this function rather than make_pvs.

  5. Change the selection algorithm to its ThOr equivalent. ThOr algorithms can be imported from Hlt2Conf.algorithms_thor. There is a require_all helper function as well (which you can import from Functors) if you use that.

    • For 3-or-more-body combiners, consider using sub-combination cuts to improve throughput.

Using Mjölnir is optional! ⚡️🔨⚡️