LBM:Palabos 数据处理机制

Posted by on 01/2/2012 in PalaBos/OLB | 1 comment

数据处理器(非局部操作)

 

徐辉 于巴黎

 

Dynamics类及其数据处理机制在Palabos程序扮演着核心和纽带作用。从概念上讲,可将dynamics类看作是原始的格子Boltzmann原型的执行机制,然后,数据处理器是更为一般多块数据并行处理策略的实现。Dynamics相对简单,因为格子Botlzmann框架比较简单,只要没有边界、网格加密、并行编程,或者其他高阶的结构成分。相比起来,数据处理器理解上就稍微有些困难,原因在于使用数据处理器可以处理一些较为困难的任务,而dynamics不处理这些任务。借助于数据处理器,可以执行模型相关的一些非局部的任务,并用来执行标量、张量场相关的操作。同时,也可以用来创建各种类型块之间的耦合关系。正如dynamics对象一样,数据处理器掌控数据归约操作。当然,数据器必须是非常有效的,并且是内在并行化的。

数据处理器的重要性是显然的,特别是在处理标量场和张量场的时候。和块结构的格子不同,场数据没有指向dynamics对象的智能化格子胞。所以,数据处理器成了唯一执行数据收集操作的有效的方式。考虑一个例子:TensorField3D是一个3D的张量场,其表示一速度场(每一个单元代表一个速度矢量Array)。这种类型的场可以通过LB模拟而的得到,需要调用computeVelocity函数。我们可能也感兴趣去通过有限微分去计算速度场的空间导数。唯一合理处理方式是通过数据处理器,因为它是最捷径的,可以并行的处理,具有可扩展性。这就是当在调用computeVorticity()、computeStrainRate()等函数时,Palabos所做的事情。注意,基于数据处理器,可以通过写一个简单的张量场的循环来计算相应的数值微分。

总之,数据处理器是一个强大的对象,它们也具有一定的复杂性,但是在概念想比Palabos的其他组成简单。显然,数据处理器是Palabos用接口部分,也是最难理解的。为了降低对部分的理解,这里会给出一些简单的介绍,以便告诉大家如何使用Palabos来进行更多的人物的实施。

使用帮助函数来壁面直接写数据处理器

根据数据处理器的使用方式,数据处理器可被分为三个类别。第一个类别是建立模拟,指定格子自区域上的dynamics对象、初始化分布函数等。这些方法将在密度、速度及其边界条件的定义中进行解释。第二类囊括了添加格子并执行物理模型的数据处理器(很多的物理模型已经预定义了,基于数据处理器可以使用已有的模型来做更多的事情)。最后一类用来进行数据评估和数据后处理(例如:计算速度梯度)。

局部操作的便利封装

想象一下,你不得不在一个块结构上执行一个局部的初始化任务,例如,不需要访问相临格子值的任务。Palabos中执行这样任务的函数是不充足的。一个轻量级的可选方式是写一个数据据处理函数类,这个类只要继承于OneCellFunctionalXD类,并实现如下的虚函数即可(2D为例子):

virtual void execute(Cell& cell) const;

在函数体中,简单的执行所需要的操作即可。如果操作依赖空间位置,相应的类可继承于OneCellIndexedFunctionalXD类,并实现如下虚方法(2D为例):

virtual void execute(plint iX, plint iY, Cell& cell) const;

这些函数的实例化可以通过调用如下的方法来实现:

//无索引例子
applyIndexed(lattice, domain, new MyOneCellFunctional);
//有所以例子
applyIndexed(lattice, domain, new MyOneCellIndexedFunctional);

可以参见例子examples/showCases/multiComponent2d。

这些单个元胞函数比起数据处理函数缺乏一般性,因为他们不能被非局部操作所使用。进一步,数值上,这些函数一定程度上是具有更低的效率,因为Palabos需要执行一个虚函数的调用,并在相应的区域上执行这个函数。尽管如此,效率的降低在初始化阶段会被完全忽略掉。

写数据处理器

在一个矩阵上执行一个操作的通用方式是写一个循环来遍历矩阵中所有的元素,或者,至少是在一个给定的子区域上逐格子的执行这个操作。如果矩阵的内存被分割为更小的部分,诸如Palabos中的多块,这些组成部分将会被分步到并行机的各个节点,你的循环也将被分割为相应的子循环。数据处理器的目的是自动的执行这些划分。它提供了子区域的坐标,并在子区域上执行特定的操作。

作为数据处理器的开发者,你将会与所谓的数据处理函数紧紧关联,这些函数通过执行重复任务一部分为用户提供简化的接口。这里有很多不同的类型的数据处理器函数,后面会列出。作为示例,这里考虑这样一个情形,定义一个数据处理器,其作用在单个的2D块结构的格子上或者多块格子上,不执行数据的归约,目的仅仅是交换f[1]和f[5]的值在给定的格子上。这个操作可以被执行经由一个简单的单个元胞上的函数。

数据处理函数继承于BoxProcessingFunctional2D_L(L指数据处理器作用在单个元胞上),实施方式为:

templateclass Descriptor>
class Invert_1_5_Functional2D : public BoxProcessingFunctional2D_L {
public:
// ... implement other important methods here.
void process(Box2D domain, BlockLattice2D& lattice)
{
for (plint iX=domain.x0; iX for (plint iY=domain.y0; iY Cell& cell = lattice.get(iX,iY);
// Use the function swap from the C++ STL to swap the two values.
std::swap(cell[1], cell[5]);
}
}
}
};

函数的第一参数用来指定区域,这些区域是原始的区域的被划分的子区域。第二个参数为要进行数据处理的格子。这个参数都是原子操作,因为从函数的被过程调用的观点来看,Palabos已经把原始的块区域进行了划分,并访问它的内部原子块。如果与写一个单个元胞上的函数相比,显然,这里你需要一些额外的工作来做——写循环语句。

与单个元胞上的函数相比,另外的一个优点是执行非局部的操作变得可能。在下面的例子中,f[1]的值与f[5]的值在右相邻的格子上被交换:

void process(Box2D domain, BlockLattice2D& lattice)
{
for (plint iX=domain.x0; iX for (plint iY=domain.y0; iY Cell& cell = lattice.get(iX,iY);
Cell& partner = lattice.get(iX+1,iY);
std::swap(cell[1], partner[5]);
}
}
}

为避免访问超过格子范围外的冒险,需遵循如下的规则:

  • 在最临近格子上(D2Q9,D3Q19,等),处理是非局部的,可理解为可以写lattice.get(iX+1,iY),而不能写lattice.get(iX+2,iY),这就是最临近原则。在扩展的临近格子上,可以拓展需要访问的范围。允许的非局部数量由constant Descriptor::vicinity来决定。
  • 数据处理器的数据非局部操作作用在信封域是不允许的。

为了总结这部分,让我们总结下,什么事情经由数据处理器可实施,什么不能实施。在提功的范围内允许访问所有的格子(包含最临近格子,根据格子的拓扑临近更多的格子),可以读取并修改它们。所执行的操作是空间依赖的,但是空间依赖性必须是通用的,不可依赖与指定的区域坐标(处理函数的区域参数)。这一点是及其重要的,这里列出数据处理规则0:

数据处理的规则0:

数据处理器必须被写为这样的方式,执行数据处理在给定的区域上必须对子区域相同的影响,在子区域执行数据处理必须连续的执行。

实际中,这意味着,基于区域参数(x0,x1,y0,y1,z0,z1),不允许执行任何的逻辑决策,或者直接基于索引(iX,iY)。相反地,这些局部的索引必须首先被转换为全局的,不依赖于数据处理器的子划分。

数据处理函数的类别

根据使用数据处理器的块类型,这里有几类不同的数据处理函数,列出如下:

Class BoxProcessingFunctionalXD

void processGenericBlocks(BoxXD domain, std::vector*> atomicBlocks);

这个类在实际中更不未被使用。它是错误回馈选项,用来处理任意数据任意类型块,类的通用类型为(AtomicBlockXD)。使用之前,必须把它们转换为它们自身的实际类型。

Class LatticeBoxProcessingFunctionalXD

void process(BoxXD domain, std::vector*> lattices);

使用这个类处理任意数目的块结构格子,并且,潜在地创建格子的耦合。作为一个例子,这个数据处理函数用来定义定义任意数据格子之间的耦合,例如ShanChan多组分模型src/multiPhysics/shanChenProcessorsXD.h和.hh。这种数据处理函数类型使用并不频繁。

Class ScalarFieldBoxProcessingFunctionalXD

void process(BoxXD domain, std::vector*> fields);

使用在标量场上,与上述类似。

Class TensorFieldBoxProcessingFunctionalXD

void process(BoxXD domain, std::vector*> fields);

使用在张量场上,与上述类似。

Class BoxProcessingFunctionalXD_L

void process(BoxXD domain, BlockLatticeXD& lattice);

数据处理器仅作用在单个元胞上。

Class BoxProcessingFunctionalXD_S

void process(BoxXD domain, ScalarFieldXD& field);

数据处理器仅作用在单个标量场上。

Class BoxProcessingFunctionalXD_T

void process(BoxXD domain, TensorFieldXD& field);

数据处理起仅作用在单个张量场上。

Class BoxProcessingFunctionalXD_LL

void process(BoxXD domain, BlockLatticeXD& lattice1, BlockLatticeXD& lattice2);

用来处理或者耦合两个格子(不同的描述)数据处理器。类似,也有一个SS的版本处理两个标量场,还有,TT版本用来处理两个不同张量场(可以有不同的维数)。

Class BoxProcessingFunctionalXD_LS

void process(BoxXD domain, BlockLatticeXD& lattice, ScalarFieldXD& field);

用来处理或者耦合格子和标量场。有LT和ST两个版本处理格子的张量和标量张量。

对于每一个处理函数,都有一个“reductive”版本(例如: ReductiveBoxProcessingFuncionalXD_L),来执行归约操作。

需要重载的方法

除了处理方法之外,一个数据处理函数必须重载3个方法。三个方法使用例子(可以参见class Invert_1_5_Functional2D):

BlockDomain::DomainT appliesTo() const
{
return BlockDomain::bulk;
}

void getModificationPattern(std::vector& isWritten) const
{
isWritten[0] = true;
}

Invert_1_5_Functional2D* clone() const
{
return new Invert_1_5_Functional2D(*this);
}

作为开始,你需要提供一个clone()方法,它是Palabos的一个规范。接下来,需要告知Palabos,数据块中那一部分需要被数据处理器所修改。在当前例子中,仅仅有一块。一般情形中,向量isWritten大小等于所涉及数据块的数目。必须对每一个部分指定一个标志true/false。在他们当中,这个信息被Palabos所使用,并决策在执行数据处理器之后是否块需要进行内部的通讯。

第三个方法appliesTo用来决定是否数据处理器仅仅作用在模拟的实际区域(BlockDomain::bulk)或者是否包含通讯信封(BlockDomain::bulkAndEnvelope)。需要记住,原子块作为多块的一部分被单个元胞层所拓展(或者被多个格子层拓展),并合并在块之间的通讯域。一个子块的信封与另外子块的bulk区域是重叠的,信息在bulk区与信封格子间进行复制。这使得执行非局部的数据处理避免了越界的风险。通常,在原子块的bulk区域上执行数据处理已经是足够的了。包含信封在如下情形下是需要的:如果你需要为每个或者某些元胞指定一个新的dynamics对象,或者你想修改dynamics对象的内部状态。在这些情形下,包含信封是必要的,因为dynamics对象属性和内容在通讯步没有在原子块之间传递。仅仅是元胞数据被传递 (分步函数和外部标量)。

如果你决定包含信封到数据处理的应用区域,必须遵守属下两条规则:

  • 数据处理器必须在整体上是局部的,因为没有额外的信封用来处理非局部的数据访问;
  • 数据处理器能够对涉及的块至多一个进行写访问(向量isWritten至多在一个地方从getModificationPattern()返回起值true)。

绝对和相对位置

在数据处理器循环中使用坐标iX和iY是没有用的,因为它们表示的是子块的局部标量,起自身被置于所有多块中的一个随机位置。为了确定依赖空间位置的坐标,局部坐标必须转换为全局坐标:

// 访问多块中的原子块位置
Dot2D relativePosition = lattice.getLocation();
// 坐标转换
plint globalX = iX + relativePosition.x;
plint globaly = iY + relativePosition.x;

示例性例子参考examples/showCases/boussinesqThermal2d/。

类似,如果你想在多个块上执行数据处理,相对位置在所有涉及的块中没必要一样。如果你在全区坐标中测量一些东西,处理方法的区域参数总是与所有的涉及的块想重叠。尽管如此,使用数据处理器的所有多块没必要针对同样的内部数据分布起作用,可以有潜在的局部坐标的不同解释。处理方法的区域参数总是被提供作为第一原子块的局部坐标。为了得到其他块的坐标,需进行如下转换:

Dot2D offset_0_1 = computeRelativeDisplacement(lattice0, lattice1);
Dot2D offset_0_2 = computeRelativeDisplacement(lattice0, lattice2);

plint iX1 = iX + offset_0_1.x;
plint iY1 = iY + offset_0_1.y;
plint iX2 = iX + offset_0_2.x;
plint iY2 = iY + offset_0_2.y;

参考examples/showCases/boussinesqThermal2d/。在如下情形,偏移量需要被计算:

  • 使用数据处理器的多块没有同样的数据分布,因为创建他们方式是不同的;
  • 使用数据处理器的多块没有同样的数据分布,因为没有相同的大小。例如使用computeVelocity计算子区域上的速度。
  • 数据处理器包含信封。这这种情形下,相对的偏移来自bulk节点与来自不同的原子块的信封是耦合的。

执行、集成与封装数据处理函数

这里有两个基本的使用数据处理器的方。在第一中情形中,数据处理仅仅被执行一次通过调用executeDataProcessor。第二中情形,数据处理器被添加到块中,通过调用函数addInternalProcessor,采用内部数据处理角色。内部数据处理器是块的一部分,可以别多次执行通过调用executeInternalProcessors。这种途径在数据处理器被看作是流体求解器一部分是被采用。作为一个例子,考虑非局部的边界条件,多组分多相流各个组分直接的耦合,或者流体与温度场的耦合(Boussinesq近似)。在块结构格子中,内部进程有一个特殊的角色,因为方法executeInternalProcessors是在执行collideAndStream()和stream()后被自动执行。这个行为基于这样的假设,collideAndStream()表示一个完整的格子方法的循环,如果使用stream(),stream()处于这个循环结束后。内部进程被看作是格子迭代的一部分,在刚结束一个碰撞和迁移后执行。

便利起见,对executeDataProcessor和addInternalProcessor调用对于每一个数据处理函数重定义。新的函数被applyProcessingFunctional和integrateProcessingFunctional分别调用。作为一个例子,这里在整个区域上执行一个基于BoxProcessingFunctional2D_LS数据处理如下:

applyProcessingFunctional (
new MyFunctional, lattice.getBoundingBox(),
lattice, scalarField );

所有的数据处理函数被封装在一个简便的函数中。示例,计算2D速度模的三个函数之一computeVelocityNorm见src/multiBlock/multiDataAnalysis2D.hh:

templateclass Descriptor>
void computeVelocity( MultiBlockLattice2D& lattice,
MultiTensorField2D::d>& velocity,
Box2D domain )
{
applyProcessingFunctional (
new BoxVelocityFunctional2D, domain, lattice, velocity );
}

内部数据处理的执行顺序

这里有三个不同的方式来控制内部数据处理函数的调用顺序(调用executeInternalProcessors())。首先,每一数据处理器被分配到一个进程级别,这些进程基本被遍历经由一个增序,有0级别开始。默认情形,所有内部进程由0开始,但是,也可以使用其他的顺序,可以指定addInternalProcessor或者integrateProcessingFunctional最后一个可选参数。在一个进程级别内部,数据处理器被执行按照被添加到块中的顺序。施加一个执行顺序之外,数据处理器对于一个给定级别的贡献在于它对多块内部通讯模式具有影响。作为一个事实,在执行了一个写相关的数据处理后,通讯不会被立即执行,仅当在从一个层次切换到下一个层次。在这种方式下,所有的一个级别数据处理MPI通讯被捆绑并更为有效的执行。为了澄清这种情形,这里给出给出一个块格子循环的细节,这个块格子具有0和1层次的数据处理器,并在调用collideAndStream后被自动的执行:

  • 执行局部碰撞和迁移;
  • 在0层次执行数据处理。至此,没有通讯被执行。因此,数据处理器在这个层次上执行非局部操作的能力是受限的,因为在通讯信封中的元胞数据是错误的;
  • 执行原子块之间通讯更新信封;
  • 执行1层次上的数据处理;
  • 如果块格子或者其他的格子、外部块被层次1上的数据处理器修改,相应的信封被更新。

尽管这个行为看起来有点复杂,但它导致了一个程序介入性行为,并提供了一个一般的控制数据器执行的方式。它应该被特殊的强调,如果数据处理器B依赖于其他的数据处理器A的数据。必须确保一个正确的A与B之间的因果关系被执行。在所有的示例中,B必须在A被执行后再执行。此外,如果B是非局部的,A仅仅是bulk数据处理器,执行一个数据通讯在A和B之间是需要的。因此,A和B必须被定义在不同的进程层次。

如果手动执行数据处理器,可以选择执行给定层次的数据处理器通过指定executleInternalProcessors中可选参数来规定进程级别。值得一提的是,一个进程级别可以具有一个负值。负值的特点在于其不会被调用executeInternalProcessors()而被自动执行,其只能通过调用executeInternalProcessors(plint level)指定级别而手动执行。每次调用 applyProcessingFunctional是低效的,因为这里有一个将数据分配到原子块上的开销。

 

Also: From my blog: LBMers: PalaBos-Olb

    4,231 views

1 Comment

  1. hi??

Submit a Comment