[RFC] OpenMP dialect representation of num_teams, thread_limit and target SPMD

Problem statement

According to the OpenMP specification, the num_teams and thread_limit clauses on teams constructs combined or nested inside of a target construct are evaluated on the host device on entry to the target construct. Additionally, the loop trip count for target SPMD kernels (target teams distribute parallel {do,for} or semantically equivalent nesting of constructs) is also expected to be evaluated in the host so that the OpenMP runtime kernel launch can be set up properly.

Even though I have not been able to find explicit mentions for this in the specification, clang also evaluates the num_threads clause of the parallel construct on the host prior to launching target SPMD kernels as well, similarly to what is done for thread_limit.

The issue is that the omp.target operation is IsolatedFromAbove, so the following representation would not be legal due to live-ins present in the target region:

// Initialize %0, %1, %2, %3
// ...
omp.target thread_limit(%0 : i32) {
  omp.teams num_teams(%1 : i32) thread_limit(%2 : i32) {
    omp.parallel num_threads(%3 : i32) {
      omp.distribute {
        omp.wsloop {
          omp.loop_nest ... {
            ...
            omp.yield
          }
          omp.terminator
        } {omp.composite}
        omp.terminator
      } {omp.composite}
      omp.terminator
    } {omp.composite}
    omp.terminator
  }
  omp.terminator
}

Furthermore, the loop trip count is implicit in the current representation of omp.loop_nest, so for it to be evaluated on the host, bounds and step variables would also have to be accessible in the host in the same way.

Potential solutions

This RFC is to decide on a representation at the dialect level to address these situations. I have thought of a few options, but I’d be open to other alternatives as well.

Attaching information to omp.target

This alternative consists in adding arguments for each of these pieces of data to the omp.target operation. Doing this would make the MLIR to LLVM IR translation trivial, because it ensures these values are lowered outside of the target region, in the host, and it is very easy to identify the value that represents each clause and the trip count. However, it also results in this operation holding information that applies to another directive, breaking one of the main design rules of the dialect.

Since the thread_limit clause can be attached to both omp.teams and omp.target, for this approach we would have to either introduce a teams_thread_limit clause to avoid collisions when it is specified for both constructs or force users creating the MLIR representation to address any conflicting values before setting a single value in the omp.target operation.

// Initialize %0, %1, %2, %3
// ...
omp.target num_teams(%0 : i32) num_threads(%1 : i32) thread_limit(%2 : i32)
           teams_thread_limit(%3 : i32) {
  omp.teams {
    omp.parallel {
      omp.distribute {
        omp.wsloop {
          omp.loop_nest ... {
            ...
            omp.yield
          }
          omp.terminator
        } {omp.composite}
        omp.terminator
      } {omp.composite}
      omp.terminator
    } {omp.composite}
    omp.terminator
  }
  omp.terminator
}

Map values

One way of ensuring certain values present in the host are available inside of the target region is through the use of the map clause. New mapped variables could be added to the omp.target operation to be able to refer to them for these host-evaluated clauses. From the MLIR creation perspective, this is not much different from the previous alternative, although it would require the addition of omp.map.info operations, as well as storing / loading from pointer-like types. Once delayed privatization support for omp.target is implemented, it might be possible to simplify this by using firstprivate clauses instead.

One of the main disadvantages of this approach is that, unless some cleanup pass is introduced with it, it will result in variables being mapped that are not needed inside of the target region. It is also more difficult when translating the omp.target operation to LLVM IR for the host to track which values represent each of these clauses.

// Initialize %0, %1, %2, %3
// ...
omp.target map_entries(%0 -> %arg0, %1 -> %arg1, %2 -> %arg2 : !llvm.ptr, !llvm.ptr, !llvm.ptr)
           thread_limit(%3 : i32) {
  %num_teams = llvm.load %arg0 : !llvm.ptr -> i32
  %thread_limit = llvm.load %arg1 : !llvm.ptr -> i32
  %num_threads = llvm.load %arg2 : !llvm.ptr -> i32
  omp.teams num_teams(%num_teams : i32) thread_limit(%thread_limit : i32) {
    omp.parallel num_threads(%num_threads : i32) {
      omp.distribute {
        omp.wsloop {
          omp.loop_nest ... {
            ...
            omp.yield
          }
          omp.terminator
        } {omp.composite}
        omp.terminator
      } {omp.composite}
      omp.terminator
    } {omp.composite}
    omp.terminator
  }
  omp.terminator
}

Add passthrough map-style argument to omp.target

This approach is similar to the previous one, but instead of introducing mappings for variables that might not actually be used inside of the target region, it would consist in creating a new map-like passthrough argument. This would only be used for cases where we need to be able to match a host value to a clause for an operation inside of the target region to be evaluated in the host. It would be illegal to use these values for any other purpose, since they would not exist inside of the target region.

Even though this would also require some work at the MLIR to LLVM IR level to match each clause in a nested operation to be evaluated in the host with its host value, it would be potentially simpler to do because there would be no local allocations or load operations, etc. to jump through.

// Initialize %0, %1, %2, %3
// ...
omp.target passthrough(%0 -> %arg0, %1 -> %arg1, %2 -> %arg2 : i32, i32, i32)
           thread_limit(%3 : i32) {
  omp.teams num_teams(%arg0 : i32) thread_limit(%arg1 : i32) {
    omp.parallel num_threads(%arg2 : i32) {
      omp.distribute {
        omp.wsloop {
          omp.loop_nest ... {
            ...
            omp.yield
          }
          omp.terminator
        } {omp.composite}
        omp.terminator
      } {omp.composite}
      omp.terminator
    } {omp.composite}
    omp.terminator
  }
  omp.terminator
}

Keep MLIR representation unchanged

Another possibility is to make transformations during the MLIR to LLVM IR translation stage by cloning or hoisting the initialization of these clauses prior to the kernel launch call, while avoiding the introduction of any changes to the MLIR representation. The main advantage of this is that the MLIR representation remains the same, making it easier for users of the dialect. However, the initialization of these clauses would exist inside of the target region (and other nested operations), which would not match where that is actually done.

The fact these initializations would be based on mapped or private values of the target region, possibly shared with some other operations, would make the MLIR to LLVM IR translation harder to implement and maintain, and easier to break.

// Initialize %0
// ...
omp.target thread_limit(%0 : i32) {
  // Initialize %1, %2
  // ...
  omp.teams num_teams(%1 : i32) thread_limit(%2 : i32) {
    // Initialize %3
    // ...
    omp.parallel num_threads(%3 : i32) {
      omp.distribute {
        omp.wsloop {
          omp.loop_nest ... {
            ...
            omp.yield
          }
          omp.terminator
        } {omp.composite}
        omp.terminator
      } {omp.composite}
      omp.terminator
    } {omp.composite}
    omp.terminator
  }
  omp.terminator
}

Handling trip count

The trip count is a property of a collapsed loop nest that must be able to be evaluated in advance to executing the loop. This restriction, in the case of a target SPMD loop, extends to being evaluated outside of the target region, on the host. This enables the trip count to be passed to the runtime when launching a kernel.

For this to be achieved, a solution based on what is decided with regards to the num_teams and thread_limit clauses should probably be followed as well, unless having different approaches makes more sense. The peculiarity of this case is that the trip count is currently not explicitly represented like these clauses are, but instead it can be calculated from the set of bounds and steps of the omp.loop_nest operation.

In that case, the decision to make would be whether to create an argument to hold the calculated value of the trip count, placed in the omp.target or omp.loop_nest depending on the approach taken, and add MLIR code before the omp.target operation to calculate it from the bounds and steps. Alternatively, the whole list of bounds and steps could be added as arguments or mapped/forwarded through the omp.target operation. The first option has the disadvantage of adding redundancy, so it creates the potential of the trip count argument not matching the number of iterations represented by the omp.loop_nest. The second option would introduce the need for more values to be mapped/forwarded or arguments being added to omp.target, depending on the approach taken.

If the decision is to keep the MLIR representation unchanged, there would be no need for a decision on this point.

Initial thoughts

At this time, I think the passthrough approach and forwarding bounds and step in place of the trip count seems to be the best tradeoff between MLIR representation clarity, MLIR creation complexity and MLIR to LLVM IR lowering complexity, but I would like to hear your thoughts.

Thanks @skatrak for writing this detailed RFC.

Would the non-composite version of this construct be using firstprivatization? If so, isn’t delayed privatization the best solution for this? I see that there is already support for delayed privatisation for target in the frontend and OpenMP dialect. If it is just about adding the support in the MLIR to LLVM translation, then we should be doing that and taking the firsprivate route in my opinion.

Thanks @kiranchandramohan for sharing your thoughts.

If you are referring to the case below, I don’t think x is implicitly firstprivate on the target construct. Maybe this is something @mjklemm might be able to comment on, but my thinking is that if x is evaluated in the host to be used there, there’s actually no reference to it inside of the target region and hence no need to make a copy to it available there (unless there are other uses of x or the user mapped/privatized it explicitly).

#pragma omp target
{
  #pragma omp teams num_teams(x)
  {
    ...
  }
}

The main disadvantage of that approach, assuming we already had a full working firstprivate implementation for target, is that we’d be adding the (perhaps very small) overhead of passing some more values that may not be used from the host to the device when launching the kernel. If the interpretation of the example above is that x is indeed firstprivate because being appearing inside of the num_teams clause counts as a reference to it inside of the target region, then I agree that would probably be the right solution.

OK. That makes sense. And the solution to use passthrough sounds OK to me.

Can we come up with a more descriptive name than passthrough?

I like the idea of the passthrough map because it doesn’t introduce any need to copy data to the device that is not necessary. Though, if firstprivate is mandated in the case above, we don’t lose anything by relying on that feature.

The concern that comes to mind about the passthrough approach is if the same variable is implicitly or explicitly privatized or mapped, we’d end up with something like this:

%x = ... : i32
omp.target private(@x.privatizer %x -> %arg0 : i32) passthrough(%x -> %arg1 : i32) {
  omp.teams num_teams(%arg1 : i32) {
    // Use %arg0 to refer to 'x'...
    omp.terminator
  }
  omp.terminator
}

That MLIR representation itself is fine, but I wonder if flang lowering will be happy about mapping the same symbol to different MLIR values.

That’s a good point. It would be specifically for this purpose, so host_eval as you proposed might a good alternative.

After discussing this with @jansjodi, I think the passthrough approach using the host_eval name seems like a reasonable way to go, regardless of whether the same variable could also be privatized or mapped. That representation conveys the semantics well, and it can be made to work with the existing flang lowering process.

Regarding the choice of calculating the target SPMD trip count in MLIR and adding a redundant passthrough argument to omp.loop_nest vs passing through all of the bounds and steps, I think the second option makes more sense. We wouldn’t have to re-implement in MLIR something that’s already available in the OpenMPIRBuilder and the passthrough representation doesn’t result in any additional data mapping.

I won’t start working on an implementation before next week, so that if people do not agree with this proposal, there will be time to raise any concerns.

I was actually prototyping a feature these days which absolutely needs the num_teams and thread_limit of a target teams nest on the host before launching the target region. I thought the first option to attach the info to the omp.target is the cleanest solution.

Even though it is technically information for another directive, the teams and target are inherently linked and the compiler should really think of them as one in my opinion (according to the standard teams must be closely nested in target, there must be only one teams in a target, target cannot contain any other statements other than teams if a teams is nested in it, etc. So I would actually really argue that there should be a omp.target_teams operation but that is a comment for another thread.)

This point is indeed annoying to deal with… I need to look around the fortran to mlir frontend code a bit more to see how difficult it would be but we would optimally want to compute the minimum of the two values on the host (doable as per the teams nested in target requirements above) and put that as the value for both teams and target’s thread_limit.

But at the end of the day I think the value needs to be attached to the omp.target, otherwise we cannot do anything useful with it at the point we want to launch the target region from the host and getting it from a nested teams is flaky and unreliable at the mlir->llvm translation stage so it would be best done in the frontend.

Thank you Ivan for your comments.

We originally implemented this approach downstream to get an initial implementation of target teams distribute parallel do working as well. However, I don’t agree it is the cleanest solution. It might possibly be the easiest to implement, but it comes with important drawbacks from the dialect design perspective: it forces us to either add clauses to omp.target that don’t apply to it, even resulting in a collision between target and teams thread_limit or to break our own dialect design rules concerning the representation of combined constructs (link) to introduce an omp.target_teams, as you mentioned.

In a way they are, but not always. teams can also appear outside of a target region, and target doesn’t always have to have a nested teams construct. So, I get what you mean about target teams being able to be interpreted as a unit, but it doesn’t have to. In a way, a similar question came up with the representation of composite constructs, which act as a unit but each leaf construct can also be specified separately. In that case, we decided to not introduce new operations but instead represent them as a nesting of operations as well.

If you’re interested, you can see the implementation of this approach in our downstream development branch, but I think the passthrough approach should still be the solution. I’m already working on this, after a PR stack to simplify handling of block arguments, to avoid issues between host_eval and map or private clauses.

omp.target doesn’t have to give a meaning to host-evaluated clauses that apply to nested constructs in the dialect. Instead, it just needs to give a way for them to be evaluated outside of the region and forwarded to the corresponding operation, which is the one that gives it a meaning. This doesn’t have to make translation to LLVM IR any more difficult, since this can be abstracted with a couple of extra class declarations. Any flakiness you’re concerned about can be addressed with proper operation verifiers.

(I am really sorry I could not chime in sooner on this but I only looked at this issue independently of this discussion a couple of days ago.)

I think in this case the omp.target and omp.teams work together to give the num_teams clause its meaning. We must be able to evaluate the clause on the host and use it when launching the kernel to launch the appropriate amount of blocks (blcoks as in CUDA speak) This is the case at least in general for GPUs as older GPUs cannot recursively launch a new kernel for the nested teams. I suppose that is why it is a requirement in the standard. There is also an overhead of initially launching a single-threaded kernel for the omp.target and then “launching” the teams as another parallel kernel launch from the device. At least to me, that is what omp.teams giving the meaning to the num_teams clause would imply.

I am sorry it is a bit unclear to me how we would use the passthrough values to achieve launching the kernel with the appropriate blocks and then how would conflicting thread_limit attached on both the omp.target and omp.teams be handled using the passthrough approach?

In my approach we would just attach the information to both the omp.target and omp.team and each one of them can use it if needed.


(Sorry the below is a tangent on the representation of target teams so not entirely related to this thread but maybe useful to have a discussion in the context the handling of thread_limit)

This case is different compared to parallel for or teams distribute or other combined constructs because it does not allow for any other code between the target and teams and it is unclear how that code executes if it exists.

So something like this happens:

omp.target {
  A()
  ...
}

omp.target’s semantics are not very clear here. It looks as the code in the target executes on one thread on the device until we launch the teams.

But what it means currently is that if there happens to be a nested omp.teams further down, then it is as if A() was in the teams because it will get “executed” in all teams.

So the code directly nested in the omp.target, like A() in these two examples have vastly different semantics.

omp.target {
  A()
  omp.teams {
  }
}
omp.target {
  A()
}

I would argue that there are three operations here.

  • omp.target - executes single-threaded on the device.
  • omp.target_teams - executes on the device and launches the teams simultaneously
  • omp.teams - launches teams, used only for teams not nested in target

And these cover all of the possible combinations according to the standard.

Due to how the standard reads (the requirements I posted above) I think it is better thought of as one unit for the purposes of compilation.

I think the main issue here is that omp.target does not define teams, so it’s not supposed to hold a num_teams clause. Conceptually, target just defines a target task executed by a single thread. It’s true that restrictions regarding the nesting of a teams construct inside of target and the host evaluation of certain teams clauses enable us to produce a single kernel call. But, even then, num_teams refers to the teams construct, so it logically belongs to the omp.teams operation.

If we imagine that omp.target allowed live-ins into the region, I think we both agree this would be the proper representation (the clause is evaluated in the host and attached to the applicable operation):

// #pragma omp target teams num_teams(x)
%num_teams = ... : i32
omp.target {
  omp.teams num_teams(%num_teams : i32) {
    ...
  }
  omp.terminator
}

So, I think the question here is not about where the clause information logically belongs, but rather how do we address the limitation that omp.target being IsolatedFromAbove imposes.

The passthrough approach is about creating an MLIR-only mapping between host-evaluated outside values and values inside of the target region, represented as entry block arguments. This mapping, unlike the map, private and reduction clauses, is not intended to result in actual code generation to allocate, initialize or copy data. It would have the very limited use of linking host-evaluated values with clauses inside of the omp.target. So, the previous example would become this:

// #pragma omp target teams num_teams(x)
%num_teams = ... : i32
omp.target host_eval(%num_teams -> %num_teams_fwd : i32) {
  omp.teams num_teams(%num_teams_fwd : i32) {
    ...
  }
  omp.terminator
}

The verifier for omp.target would make sure that forwarded values are only used in the very restricted cases these are for: num_teams and thread_limit in omp.teams, loop bounds and step of a nested omp.loop_nest (and possibly the num_threads of an omp.parallel) if it’s representing a target SPMD kernel. No other uses of these values would be legal.

In the MLIR to LLVM IR translation of omp.target for the host, we would have an LLVM IR value for these host-evaluated clauses, since their MLIR initialization happened before the omp.target operation, and we would know what they are representing by looking at the uses of the corresponding forwarded value. With that, we are able to store the host values into the proper kernel arguments structure, pass it to the __tgt_target_kernel call or anywhere else we needed to.

The passthrough requires one step more than attaching these clauses directly to omp.target (finding out what values have been forwarded and what clauses they are for), but it keeps the representation consistent with the rules of the dialect.

I think we can leave the target teams representation discussion pending for now, until we close the main discussion. I can just point you to the discussion over composite construct representation, where adding operations for each of them was considered (the “composite operations” approach, which is the same you’re suggesting doing with target teams): [RFC] Representing combined/composite constructs in the OpenMP dialect.

The first one is illegal [5.2:338:4-7]
A teams region must be strictly nested either within the implicit parallel region that surrounds the whole OpenMP program or within a target region. If a teams construct is nested within a target construct, that target construct must contain no statements, declarations or directives outside of the teams construct.

We’ve made a choice to represent all compound constructs as a sequence of leafs or composite constructs. Many of them have nesting restrictions, not just teams, and we’re not basing the representation on the nesting requirements. Those are all checked in the OMP structure checker in semantic checks.

There is one anomalous clause ompx_bare that applies to target teams as a whole, but we can just attach it to either one of both of these constituents, whichever is more convenient. We’d still need to verify the use of the clause in the semantic checks.

The 5.2 spec doesn’t explicitly state what happens if you have different thread_limit clauses on target and teams. Since this is only the upper bound on the number of theads, we can use the smaller one in such case.

@kparzysz @skatrak

Thank you for the detailed clarifications and sorry for the late interaction on this. I don’t have any other comments regarding this rfc.

Sorry for the late reaction on this. But teams can stand on it’s own in the “implicit parallel” region, which is OpenMP language for the the sequential part of the code. So, you can have teams outside of target, as long as it is not inside a parallel or anything else.