Data management in Xsuite

Hybrid objects, xofields, xobjects

Beam elements and Particles objects are hybrid objects built with the Xobjects package. They contain, along with standard python attributes and methods, also an “xobject” that can be optionally stored on GPU and made accessible to the C code used in the implementation.

The set of attributes accessible in C and the corresponding types can be found in the xofields dictionary attached to the class. For example:

xtrack.Multipole._xofields

# contains:
# {'order': Int64,
#  'inv_factorial_order': Float64,
#  'length': Float64,
#  'hxl': Float64,
#  'hyl': Float64,
#  'radiation_flag': Int64,
#  'knl': <array ArrNFloat64>,
#  'ksl': <array ArrNFloat64>,
#  '_internal_record_id': <struct RecordIdentifier>}

Each instance of the class contains an xobject storing the corresponding data.

For example:

m = xtrack.Multipole(knl=[1,2,3])

m._xobject.knl # contains [1,2,3]

All attributes of the xobject are automatically exposed as attributes of the beam element. For example, m._xobject.length is the same as m.length.

Arrays are exposed as native Xobjects arrays in the _xobject, and as numpy or numpy-like arrays as attributes of the beam element. For example:

m = xtrack.Multipole(knl=[1,2,3])

m._xobject.knl
# is an xobjects.Array

m.knl
# is a numpy array (or numpy-like on GPU contexts)

m.knl[0] = 5 # can be modified using numpy-like syntax

It should be noted that the two are different views of the same memory area, hence any modification can be made indifferently on any of them.

The numpy view (or numpy-like on GPU contexts) gives the possibility of using numpy-compatible functions and features on the array (e.g. slicing, masking, etc.). This is especially useful to modify data in-place.

Please note that not all numpy features will work for the numpy-like arrays on GPU contexts. To make use of such features (e.g. np.sum, np.mean, etc.) you can get a copy of the array as real numpy array (but modifications will not be possible).

# only for CPU context:
np.sum(m.knl) # will throw an exception on PyOpenCl context

# only for GPU context:
np.sum(m.knl.get()) # get a numpy array as copy

# for any context:
np.sum(m._context.nparray_from_context_array(m.knl)) # get a numpy array as copy

Contexts and buffers

The xobjects are allocated in memory buffers managed by the xobjects package. Buffers can be allocated on the GPU or on the CPU memory depending on the context.

For example the following code creates two buffer in a GPU memory:

# We create a GPU context
context = xobjects.ContextCupy()

buffer1 = ctx.new_buffer()              # using default initial capacity
buffer2 = ctx.new_buffer(capacity=1000) # specifying initial capacity

A buffer can contain multiple hybrid objects. For example we can allocate two objects (mult1 and mult2) in buffer1 created above:

mult1 = xt.Multipole(knl=[1, 2, 3], _buffer=buffer1)
mult2 = xt.Multipole(knl=[1, 2, 3], _buffer=buffer1)

The capacity of the buffer is automatically increased to fit the allocated objects.

Buffers can also be created implicitly when creating the objects. This is done by passing the context instead of the buffer. For example:

context = xobjects.ContextCupy()

mult1 = xt.Multipole(knl=[1, 2, 3], _context=context)
mult2 = xt.Multipole(knl=[1, 2, 3], _context=context)

In this case a new buffer is created automatically for each of the objects. If neither a context nor a buffer is specified, the default context (on CPU) is used.

The buffer and context of an object can be inspected using the _buffer and _context attributes:

mult1._buffer # gives the buffer of the object
mult2._context # gives the context of the object

Move and copy operations

Xsuite objects have a copy method tha can be used copy the objects across buffers and contexts. For example:

# we create two multipoles in the default context
mult1 = xt.Multipole(knl=[1, 2, 3])
mult2 = xt.Multipole(knl=[3, 4, 5])

# We create a GPU context
context_gpu = xobjects.ContextCupy()

# We make copy of the first object in a GPU context (a new buffer in the
# GPU memory is created automatically)
mult1_gpu = mult1.copy(_context=context_gpu)

# We make a copy of the second multipole to a specific GPU buffer
buffer_gpu = context_gpu.new_buffer()
mult2_gpu = mult2.copy(_buffer=buffer_gpu)

# It no argument is passed to the copy method, the copy is made in the same
# context as the original object (a new buffer is created).
another_copy = mult2_gpu.copy()

Similarly, the move method can be used move objects across buffers and contexts. For example:

# we create two multipoles in the default context
mult1 = xt.Multipole(knl=[1, 2, 3])
mult2 = xt.Multipole(knl=[3, 4, 5])

# We create a GPU context
context_gpu = xobjects.ContextCupy()

# We move the first object in a GPU context (a new buffer in the
# GPU memory is created automatically)
mult1.move(_context=context_gpu)

# We move the second object to a specific GPU buffer
buffer_gpu = context_gpu.new_buffer()
mult2.move(_buffer=buffer_gpu)

Memory management in xtrack trackers

When the tracker is build, all beam elements are moved to one buffer in the context specified when the tracker is created. For example:

# We create a few beam elements
mult1 = xt.Multipole(knl=[1, 2, 3])
drift1 = xt.Drift(length=1)
mult2 = xt.Multipole(knl=[3, 4, 5])
drift2 = xt.Drift(length=1)
# Each element is allocated in a different buffer in the default context.
# For example mult1._buffer is not equal to mult2._buffer, etc.

# we create a line with the above beam elements
line = xt.Line(elements=[mult1, drift1, mult2, drift2])
# each element remains in its original buffer

# we create a tracker with the above line
context = xobjects.ContextCupy()
line.build_tracker(_context=context)
# the tracker can be instpected in line.tracker

# this creates a new buffer in the memory associated to the context
# (accessible as line.tracker._buffer) and moves all the elements to this
# buffer.
# Now mult1._buffer is equal to mult2._buffer, etc. and they are all equal
# to line.tracker._buffer.

References

References can be used to have fields of different objects to point to the same data. To do so all both the referencing objects and the referenced objects must be in the same buffer. For example:

import xobjects as xo

class Inner(xo.HybridClass):
    _xofields = {
        'num': xo.Int64,
    }

class Outer(xo.HybridClass):
    _xofields = {
        'inner': Inner,
        'ref_to_inner': xo.Ref(Inner), # is reference
    }

# We create a buffer
buffer = xo.ContextCupy().new_buffer()

# We create an object of type Inner
inner = Inner(num=1, _buffer=buffer)

# We create two objects of type Outer
outer1 = Outer(_buffer=buffer)
outer2 = Outer(_buffer=buffer)

# We set the reference of outer1 and outer2 to inner
outer1.ref_to_inner = inner
outer2.ref_to_inner = inner

# We change the value of inner.num
inner.num = 2

# We check that the value of outer1.inner.num and outer2.inner.num have
# changed as well
print(outer1.inner.num) # prints 2
print(outer2.inner.num) # prints 2

Advanced memory behaviours with HybridClass

When instantiating, moving, copying, or assigning values to fields of a HybridClass, especially if such a class contains references, in some advanced cases the expected behaviour of such operations is not obvious. Below we present comprehensive set of scenarios that demonstrate when values are copied, and which operations are disallowed.

We shall use the following example classes throughout this section:

import xobjects as xo

class Inner(xo.HybridClass):
    _xofields = {
        'num': xo.Int64,
    }

class Outer(xo.HybridClass):
    _xofields = {
        'inner': Inner,
        'ref': xo.Ref(Inner),
    }

As well as the following function, which prints a summary of where a HybridClass is located in memory.

def whereis(obj: xo.HybridClass, _buffers=[]):
    context = obj._context.__class__.__name__
    if obj._buffer in _buffers:
        buffer_id = _buffers.index(obj._buffer)
    else:
        buffer_id = len(_buffers)
        _buffers.append(obj._buffer)
    offset = obj._offset
    print(f"context={context}, buffer={buffer_id}, offset={offset}")

Initialising nested objects

Below Outer is instantiated in the same buffer as Inner, and so the reference field outer.ref is bound to the same xobject as inner. Therefore, any changes to one are applied to another.

buf = xo.context_default.new_buffer()
inner = Inner(num=42, _buffer=buf)
outer = Outer(inner=inner, ref=inner, _buffer=buf)

whereis(outer)          # => context=ContextCpu, buffer=0, offset=8
whereis(outer.inner)    # ditto, since outer.inner is the first field of outer
whereis(outer.ref)      # => context=ContextCpu, buffer=0, offset=0
whereis(inner)          # ditto, since the reference points to the original object

inner.num = 14          # changing inner...
print(outer.ref.num)    # (=> 14) changes outer.ref...
print(outer.inner.num)  # (=> 42) but not the copied outer.inner

Since a reference to an object in a different buffer to the one owning the reference is disallowed, below, when Outer is instantiated with an inner object coming from a different buffer, an error is produced.

# If unspecified, every object gets its own buffer:
inner = Inner(num=7)
outer = Outer(inner=inner, ref=inner)
# Gives MemoryError - Cannot make a reference to an object in a different
#                     buffer.

Same behaviour can be observed when instantiating Outer with an inner coming from a different context (and therefore a different buffer):

context_cpu = xo.ContextCpu()
context_ocl = xo.ContextPyopencl()

inner = Inner(num=99, _context=context_cpu)
outer = Outer(inner=inner, ref=inner, _context=context_ocl)
# Gives MemoryError - Cannot make a reference to an object in a different
#                     buffer.

When fields are assigned to an already instantiated hybrid object, as opposed to doing that in the initialiser, the behaviour is analogous to the above.

Moving (nested objects)

In general, we cannot move the objects of type Outer from the examples before, as Outer contains references:

buffer = xo.context_default.new_buffer(capacity=256)
inner = Inner(num=0x1020_3040_5060_7080, _buffer=buffer)
outer = Outer(inner=inner, ref=inner, _buffer=buffer)

outer.move(_context=xo.ContextPyopencl())
# Gives an error as the object cannot be moved, as it contains references
# to other objects.

We also prohibit moving any of the fields of outer, as they are part of an underlying fixed structure defined by the xo.Struct associated with the hybrid class Outer:

outer.inner.move(_context=xo.ContextPyopencl())
# Gives an error as the object cannot be moved, as it contains references
# to other objects.

In all cases when we move an object specifying _offset manually, we risk the corruption of the data in the buffer. See the below example of a potentially destructive behaviour.

buffer = xo.context_default.new_buffer(capacity=256)
inner = Inner(num=0x1122_3344_5566_7788, _buffer=buffer)
inner2 = Inner(num=0x1020_3040_5060_7080, _buffer=buffer)

# let us see the value of inner2.num:
print(inner2.num)  # => 0x1020_3040_5060_7080

inner.move(_offset=4, _buffer=buffer)

# as a result of the above move, inner2 is corrupted, as we moved
# inner such that it overlaps with inner2 in the buffer
print(inner2.num)  # => 0x1020_3040_1122_3344

When _offset is not given, xsuite will automatically move the object safely to the free space in the buffer, expanding it, if needed.

inner1 = Inner(num=135)
inner2 = Inner(num=531)

whereis(inner1)             # => context=ContextCpu, buffer=6, offset=0
whereis(inner2)             # => context=ContextCpu, buffer=7, offset=0

buffer = xo.context_default.new_buffer(capacity=16)

inner2.move(_buffer=buffer)
inner1.move(_buffer=buffer)

# inner1 and inner2 are moved to buffer, safely next to each other:
whereis(inner1)             # => context=ContextCpu, buffer=8, offset=8
whereis(inner2)             # => context=ContextCpu, buffer=8, offset=0

The same holds true for moving objects between contexts:

# We make sure our two objects are on the CPU context:
inner1 = Inner(num=10, _context=xo.ContextCpu())
inner2 = Inner(num=-10, _context=xo.ContextCpu())

inner1.move(_context=xo.ContextPyopencl())
inner2.move(_context=xo.ContextPyopencl())

# After we move them to the OpenCL context, they are by default in separate buffers
whereis(inner1)             # => context=ContextPyopencl, buffer=9, offset=0
whereis(inner2)             # => context=ContextPyopencl, buffer=10, offset=0

# We can place them in the same buffer, as before. Let us try the CUDA context:
context_cuda = xo.ContextCupy()
buffer = context_cuda.new_buffer(capacity=1) # (note that the buffer will grow)

inner1.move(_buffer=buffer, _context=context_cuda)
inner2.move(_buffer=buffer, _context=context_cuda)

# We can see that the objects are next to each other:
whereis(inner1)             # => context=ContextCupy, buffer=11, offset=0
whereis(inner2)             # => context=ContextCupy, buffer=11, offset=8

It is important to know, that some of the types will be different between contexts. This applies in particular to arrays:

class TestArrays(xo.HybridClass):
    _xofields = {
        'array': xo.Int8[8],
    }

test_cpu = TestArrays(array=range(8), _context=xo.ContextCpu())
test_cl = TestArrays(array=range(8), _context=xo.ContextPyopencl())
test_cupy = TestArrays(array=range(8), _context=xo.ContextCupy())

print(test_cpu.array)       # => list(range(8))
print(test_cl.array)        # ditto
print(test_cupy.array)      # ditto

print([type(x.array) for x in (test_cpu, test_cl, test_cupy)])
            # => [numpy.ndarray, pyopencl.array.Array, cupy.ndarray]

Xobject conventions on memory initialization

Xobject always accepts a combination of _context, _buffer, _offset to indentify and/or allocate the memory to which data is written:

_context

_buffer

_offset

xobject

None

None

None

ContextCPU is used to allocate an new buffer, allocate new memory at 0 offset

not None

None

None

_context is used to allocate a new buffer and allocate new memory at 0 offset

None

not None

None

_buffer is used to allocate new memory at the first free offset

None

not None

not None

memory at _offset in _buffer is used without allocation

Other combinations are not meaningful and should raise an exception (to be implemented).