Fast Access to Columnar, Hierarchically Nested Data via Code Transformation

08/20/2017 ∙ by Jim Pivarski, et al. ∙ Princeton University CERN University of Nebraska–Lincoln 0

Big Data query systems represent data in a columnar format for fast, selective access, and in some cases (e.g. Apache Drill), perform calculations directly on the columnar data without row materialization, avoiding runtime costs. However, many analysis procedures cannot be easily or efficiently expressed as SQL. In High Energy Physics, the majority of data processing requires nested loops with complex dependencies. When faced with tasks like these, the conventional approach is to convert the columnar data back into an object form, usually with a performance price. This paper describes a new technique to transform procedural code so that it operates on hierarchically nested, columnar data natively, without row materialization. It can be viewed as a compiler pass on the typed abstract syntax tree, rewriting references to objects as columnar array lookups. We will also present performance comparisons between transformed code and conventional object-oriented code in a High Energy Physics context.



There are no comments yet.


page 1

page 2

page 3

page 4

This week in AI

Get the week's most popular data science and artificial intelligence research sent straight to your inbox every Saturday.

I Motivation

Data analysts in the field of High Energy Physics (HEP) routinely deal with terabyte and petabyte scale datasets, but access them as objects persisted in files, rather than databases. Thus, they miss out on advantages enjoyed by data analysts in other fields, such as automated scale-out, data replication, primary key indexing for faster selections, and term rewriting and query planning of high-level queries.

A major reason for this is that HEP data do not fit any of the existing database models, including common non-relational (NoSQL) ones: HEP data are hierarchically nested with arbitrary-length collections within collections, not the rectangular table served well by SQL. This feature suggests a document store like MongoDB[1], except that HEP data have regular structure that is sparsely accessed: only a few of the object attributes are needed in each query, which would waste disk access if all attributes of an object are stored contiguously, rather than in “columns,” in which all values of a given attribute, across objects, are contiguous on disk. Since HEP objects typically have hundreds of attributes, accessing a few of them in a typed columnar store is orders of magnitude faster than a schemaless document store. Unlike key-value tables, HEP data must be scanned in bulk; unlike graph databases, HEP data cross-linking is limited to small, disconnected graphs called “events” no larger than hundreds of kilobytes.

Furthermore, most of the well-known database systems use SQL or an SQL variant as the query language, but even the simplest HEP analysis functions are awkward and possibly hard to optimize as SQL. HEP analysis functions typically iterate over combinations of objects in different subcollections within each event, which would require multiple SQL explodes and joins. Although the hierarchically nested structure can be described in modern SQL,



often the first exploratory action in a HEP analysis is to plot a distribution of the highest particle pt per event. In SQL, one would have to explode the muons into a virtual table and aggregate for maximum pt, grouped by eventId, but the database system should be made aware that the millions of events are small and should each remain local to avoid millions of collations over the network. To search for short-lived particles that might have decayed into one muon and one jet, the analyst would have to iterate over all possible pairs of muons and jets, computing the mass111 for and of each combination, with or without constraining to one candidate per event. Searches for particles decaying into two muons must avoid double-counting unordered muon pairs, etc. In general, exploding subcollections into virtual tables and joining on eventId is both syntactically inconvenient for the data analyst and introduces an optimization problem for the database engineer. A short functional or procedural program applied to each event is much more natural than shoehorning the problem into SQL.

However, even query language agnostic systems like Apache Drill[2] make SQL-motivated assumptions about the structure of queries in the query planning and distribution. Apache Spark[3] drops from efficient SparkSQL[4] processing to a slower mode when the user needs to apply an arbitrary function. This is unnecessary. The features that accelerate scans over tables, namely a columnar data layout, Just-In-Time (JIT) compilation, and avoiding row materialization, can be applied to generic programming languages.

This paper describes one component of a database/fast query system for HEP data, which is in early development. Such a system will involve distributed processing, Hadoop-style data locality, indexing, and data management with columnar, rather than file, granularity. However, this paper focuses only on the execution engine, which performs JIT compilation without object materialization on hierarchically nested data structures stored in a columnar layout.

JIT compilation is central to machine learning tools like H2O


and Theano

[6], as well as Julia[7], a scientific programming language, and ROOT[8], the analysis framework that is ubiquitous in HEP. However, these tools do not avoid object materialization, even if the data are persisted in a columnar layout, as in the case of ROOT. Most HEP data are currently stored as ROOT files, which represent hierarchically nested objects in columnar form, yet the ROOT framework materializes them as C++ objects before applying analysis functions. We have augmented ROOT[9] to avoid this object materialization and modify the user’s analysis function instead. Leaving the data in columnar arrays, we walk over an Abstract Syntax Tree (AST) of the user’s analysis function, replacing object references with array element retrievals, then pass the transformed function to a traditional compiler. For the analyst’s convenience, we transform functions written in Python and compile the result with Numba[10], which allows high-level querying yet produces bytecode comparable to a compiled C function. The techniques described here could be applied to any language, but Python is popular in HEP for data exploration.

We should also note that the code transformation technique described here is similar to that of Mattis et al.[11], though we statically transform and compile Python functions, whereas Mattis et al. implemented object proxies in PyPy and let PyPy’s tracing JIT compiler dynamically optimize them. This technique can be viewed as a general alternative to object deserialization: when faced with user code that expects objects but the data are in another form, one could either transform the data to fit the code’s expectation (deserialization) or transform the code to fit the data. When manipulating code, the data format is only interpreted once at compile-time; when manipulating data, the format is interpreted once per object, with extra memory allocations and copying, so code transformation is preferable when possible. Code transformation for columnar data is becoming a common technique in databases and search engines[12], and we apply it to HEP data with a view toward building it into a HEP database, comparing its performance to analysis on materialized C++ objects in ROOT.

Ii Data Representation

Ii-a PLUR Type System

We begin by describing the scope of data types we are considering. To simplify the transformations, we restrict this set as much as possible while still being useful. In particular, the data types described here only encode data; they don’t determine how data are used, such as which functions can be legally called on them. However, an interpretation layer can be overlaid on this representation without affecting the format or code transformations.

The set of possible types is generated by four constructors:

  • Primitive: fixed byte-width booleans, integers, floating point numbers, and characters. Even fixed-size matrices of numbers could be considered primitives— the important point is that the width is known to the compiler.

  • List: arbitrary-length, ordered collections of other types. Each instance may have a different width, including empty lists, and the list may contain any other type, including nested lists and Lists of Records. All objects in the list must have the same type (homogeneous).

  • Union: an object that may be one of several types (“sum types” in type theory). Each instance can have a different type, but its type must be chosen from a predetermined list. This provides more flexibility than class inheritance (e.g. Particles that may be Muons or Jets), but less than dynamically typed Python.

  • Record: containers mapping field names to types (“product types” in type theory). Each instance must contain all fields, like a class in C++ or Python.

We call this type system PLUR, an acronym of the four constructors. A complete type schema in PLUR is a tree of Lists, Unions, and Records whose leaves are Primitive types.

One thing to notice is that this system does not allow for recursively defined types. For instance, one cannot make a Tree Record containing Trees. Thus, all data structures have a finite maximum depth determined by the schema. In practice, this is not a limitation, as trees and even arbitrary graphs can be (and routinely are) built in HEP by pointing to members of other subcollections with list indices.

Another thing to notice is that we have chosen not to require names for Records, as classes in C++ and Python must be named. This is to allow for more flexible type-checking, in which the structure of a Record (a minimum set of field names and types) is sufficient to determine if it can be used in a function. For example, a mass function only needs to verify that the two particles it is given have pt, eta, and phi fields, instead of having to introduce type annotations or explicit polymorphism into Python code.

Stronger type safety can be applied by overlaying this type system with names and adding dispatch rules. For instance, a Listbyte can be distinguished from a string of text with a name like UTF8String. Functions like capitalize would be applicable to strings in a way that they are not applicable to Listbyte, and functions like len could return the number of variable-width Unicode characters, rather than the number of raw bytes. However, these details are not important for the columnar representation: UTF8Strings and Listbytes are stored and accessed the same way, so the PLUR type system does not make a distinction. Similarly, unordered collections like sets and key-value mappings are just Lists at the storage level.

Ii-B OAMap: Objects to Arrays

Any data that can be described by a PLUR schema can be represented in columnar arrays. This mapping from objects to arrays is analogous to Object Relational Mapping (ORM) of databases, so we call it Object Array Mapping (OAM).

HEP data in ROOT files are encoded in an OAM, though ROOT’s encoding transforms any object with a C++ type into arrays. The C++ type system would be a large project to convert from data transformation rules into code transformation rules, so we limit our discussion to the PLUR type system. The data transformation rules we describe below happen to be very similar to ROOT’s and also a subest of Apache Arrow[13]. We call it OAMap, and provide an implementation[14] on GitHub. In our tests, we convert ROOT data into OAMap on-the-fly.

OAMap does not specify a storage mechanism: any means of storing arrays in a namespace may be used. This could be a Python dict of Numpy arrays, an HDF5 file, a filesystem directory of raw files, or a distributed object store. Only a PLUR schema is required to interpret the data, and this schema can even be encoded as a naming convention in the names of the arrays, eliminating the need for type metadata. The following set of rules transform an object of type with name into a set of named arrays.

If is a Primitive, append the primitive value to an array named , creating it if it doesn’t yet exist.

If is a List with contained type and length , find an array named (list offset). If it does not yet exist, create it with a single element . Then select the last element from this array and append .

Next, iterate through each item in the List and apply the rule for with name (list data).

If is a Union with possible types and the value has actual type (where ), find or create an array named (union tag) and append .

Next, follow the rule for name (union data ) and type .

If is a Record with field names and field types , follow the rule for each pair , (where ), using (record field ) as a name and as a type.

A Record does not generate any arrays to represent its structure (as Lists and Unions do); the connection between fields in an array is entirely encoded in the PLUR schema or naming convention. To ensure that array names can be properly parsed, field names must not contain the character “-” (or a different delimiter should be chosen).

One must be sure to include empty/trivial arrays for types that were not touched due to missing data (Lists that are all empty at a given level or Union type possibilities that never occur in the data) or make the reading procedure insensitive to missing arrays. A simple way to include all arrays is to create them with a first pass over the type schema and only append to them in the pass over data.

Ii-C OAMap: Arrays to Objects

The procedure described above losslessly stores the PLUR schema in array names and the object data in arrays. To demonstrate this, we describe an algorithm below that would materialize objects from arrays. As explained in the introduction, we prefer code transformation to object materialization, but it is important to prove that the transformation is indeed lossless.

First, select arrays whose names begin with the prefix and pop the prefix from their names. For each of these arrays , create an index whose initial value is 0. Then recursively apply the following rules.

If only one array exists and its name is the empty string, then the type is a Primitive. Return the value at index and increment by 1.

If one array name begins with “-Lo” and all others begin with “-Ld”, then the type is a List. Take as the length of the List and increment by 1 for the array that begins with “-Lo”.

Pop the “-Ld” from the beginning of all other array names and apply the rule for that set of arrays times to fill the List’s contents.

If one array name begins with “-Ut”, and all others begin with “-Ud”, then the type is a Union.

Take as the tag for the datum and increment by 1 for the array that begins with “-Ut”.

Pop the from the beginning of all other array names associated with tag and apply the rule for that subset.

If all array names begin with “-R_”, then the type is a Record. Partition the set of arrays by field name , pop the from the beginning of the array names, and apply the rule separately for each partition to fill each field.

If any other configuration of arrays and names is encountered, the arrays are malformed. If indices go beyond the lengths of the arrays or do not perfectly end on the last element of each array, then the arrays are malformed.

Ii-D Random Access and Redundancy

The reason that List structures are represented by data offsets (arrays whose names end in “-Lo”), rather than List lengths, is to permit random access. For instance, if we had a ListListfloat named “x” and we wanted the float at index , we would compute

At each level, the contents of the “-Lo” array are the starting indices of the next-deeper structure.

In fact, if we want to reconstruct only one object in a List, we simply apply the arrays-to-objects algorithm for that List’s “-Ld” arrays with all indices starting at .

The Union structure, as described so far, is not random accessible. Data arrays for each type possibility are only filled each time a value of that type is encountered, which cannot be every time for every type. Each type possibility must be indexed by a different offset, but these offsets can all be packed into the same array becuase exactly one type must be encountered per instance. The arrays-to-objects algorithm described in the previous subsection avoids this issue by walking over the data sequentially.

To make Unions random accessible, we need to add a union offset array “-Uo”, which can be generated from the tag array “-Ut” by

   for all
  for  until length of x-Ut do
  end for

Now we can access Union objects randomly: to reconstruct a Union at index , we find the tag at x-Ut[] and follow the arrays-to-objects algorithm on the set of arrays named with the corresponding “-Ud”, all starting at index given by . Arrow calls this a “dense union.”

Ii-E Relationship to Apache Arrow and ROOT

We chose to implement a subset of the Arrow AOM to increase the usefulness of our tools beyond HEP. Arrow unifies in-memory data frames across a variety of Big Data platforms[15], so a code transformation tool that assumes this encoding may be directly applied to data from these platforms. The version of OAMap tested here lacks Arrow’s nullable types, but it is being extended to cover these cases. OAMap generalizes beyond Arrow in that arrays may be provided on demand for large, out-of-memory datasets.

ROOT’s encoding differs from OAMap in that some List offsets are represented as byte offsets, which can be converted to object offsets by an affine transformation, and in some cases as List lengths, which must be summed. Also, OAM is optional in ROOT and is only carried out one List level deep, but most HEP data are presented in this form anyway. Different C++ types like STL vectors and arrays are encoded differently, so we normalize all list-like types to PLUR Lists in our on-the-fly conversion.

Iii Code Transformation

OAMap’s most important feature is that the structure of any type can be expressed by a single integer: the index that would be used to start the arrays-to-objects algorithm. A type and a name prefix uniquely specifies a set of arrays, so the only values required at runtime are the locations of instance data within those arrays.

Only one index is required because:

  • a Primitive value is located at one index in its array;

  • a List’s length can be computed from one index () and all of its contents are derived from the value of its offset array at that index;

  • a Union is specified by a tag from its tag array and an offset from its offset array, which occur at the same index. All of the contents are derived from the offset;

  • a Record is just a bundle of fields with no structure array. However, the contents of all fields in a Record object start at the same index.

Therefore, any function that operates on Primitive, List, Union, and Record objects, no matter how complicated, can be replaced with a function that operates on integer indices. Replacing each instance of a PLUR-typed object with its index and all functions and code constructs operating on objects with the equivalent operations for indices transforms the code to match the data encoding, rather than shaping the data to match the object-oriented vocabulary of the code.

To see this as an alternative to deserialization, consider an extreme scenario in which all arrays are in a binary file on disk that has been memory-mapped to look like an array. Wherever the source code would have required a structured object, the compiled code operates directly on the raw bytes on disk. No additional representation of the data is created.

This technique can be applied to code written in any language, but all of the explicit examples are applied to Python code because this is our chosen query language.

Iii-a General Strategy

Unlike SQL, functional and procedural programming languages can assign and possibly reassign variables, extract substructure as new variables, loop over substructure, and pass objects to other functions, where they are identified with new names. It is not sufficient to only transform symbols that coincide with object names, since parts of the data structure may be spread among user-defined variables. To avoid missing code that needs to be transformed, we must track the PLUR type of all symbols in the function.

We therefore need a typed AST, in which PLUR types and array names are associated with all expression nodes that hold non-Primitive PLUR type. (Knowledge of other types is not necessary but not harmful.) These types and names must be propagated from symbols to expressions through assignment operators so that the correct array references may be injected into the code. We performed code transformation and type propagation in a single sweep.

There are several constraints on the code to be transformed. It cannot create or change PLUR-typed objects, which is reasonable for a query language in which the input dataset and auxiliary inputs are immutable. The analysis function may call external functions, but the AST of those functions must either be accessible so they can be transformed, too, or they must accept only Primitive or non-PLUR arguments. Functions cannot be passed as objects and then called on PLUR types, since an unknown function can’t be statically transformed. A variable name can’t have different PLUR types in the same scope, which is a normal constraint for statically typed code, but unusual for dynamically typed Python. All of these are restricted by the Numba compiler as well, and since we pass our transformed functions to Numba, they do not represent additional constraints. Our code transformation process may be viewed as extending Numba from arrays and simple Python types to include any immutable, PLUR-typed object.

The following AST nodes must be transformed:

  • symbol reference, which might have PLUR type;

  • assignment, which can pass PLUR type from an expression to a new symbol;

  • list subscript (square brackets in most languages), which might slice or extract from a PLUR List;

  • attribute subscript (dot in most languages), which might extract a field from a PLUR Record;

  • function calls, which can pass PLUR types to a new function, widening the scope of the transformation sweep to include that function, and may return a new PLUR type/prefix that must be tracked;

  • for loops which might iterate over a PLUR List;

  • special functions, like len (List length) and isinstance (check type) in Python, which have to be handled in special ways when called on PLUR types.

Any use of a PLUR-typed object that isn’t specially handled must be treated as an error to avoid incorrect code, since these objects are replaced by plain integers at runtime. These errors would appear to the user as compilation errors, with line numbers and meaningful error messages.

Iii-B Required Transformations

Symbol references are leaves of the AST and therefore first to be transformed in the recursive walk. In the original function, an identifier that refers to a PLUR-typed object becomes an identifier for its integer index, so a symbol reference becomes array extraction.

For Primitive and List types:

x (referring to object) array[x] (x is index)

where array is the array associated with a Primitive or the offset array associated with a List.

Union objects should be immediately replaced with an object of specific type. Since that type is not known during code transformation, every branch must be followed, and subsequent code transformations must be predicated on the runtime tag value. The symbol table must therefore be branchable, a list of possible symbol tables that multiplies as unions are encountered. Union types lengthen the compilation process, but have minimal impact on runtime (an extra integer check). Branching is tamped by any isinstance checks written by the user (see below).

References to Record symbols do not require any transformation, though they are reinterpreted as indices that would be passed to their fields when subscripting (see below).

Assignment merely passes the PLUR type and associated array names to a new symbol. In the type-inference pass, this means adding the type information to a symbol table.

Assignment is more complex if one tries to handle pattern matching, such as Python’s tuple unpacking (see “Flourishes” below).

List subscripts replace a List index with a Primitive value if it is a List of Primitives or the index of the next substructure down if it is any other type. This can be performed in two steps: (1) transform

x[i] offset[x + i]

and then (2) transform the result of this as though it were a symbol reference (rule described above) using the List’s contained type.

An attempt to subscript any PLUR type other than a List is an error. The above only works if the index resolves to integer type, not (for example) a Python slice. Handling slices would be considered a flourish.

Attribute subscripts extract a field from a Record by name. In most languages, the field names are syntactically required to be a string known at compile-time with constraints on the characters.

We do this transformation in two steps, like the list subscript above: (1) transform

x.fieldname x

and then (2) transform the result of this as though it were a symbol reference (rule described above) using the selected field type.

An attempt to subscript any PLUR type other than a Record is an error.

Function calls may include non-Primitive PLUR types in their arguments or not. If a function does not take any non-Primitive PLUR types as arguments, it can be left as-is.

If it references non-PLUR types, then we must obtain the AST for that function and propagate PLUR types through it, starting from the argument types. If the function has previously been transformed with the same types, we may reuse the previously transformed function (as long as we pass array names as its new arguments). If it was transformed with different types, we must generate a new transformed copy of the function, propagating the new PLUR types through it. We are effectively treating the function as though it were type-parameterized in every argument, the way that Julia[7] does with functions that don’t have type annotations.

For example, a mass function that takes two arguments, particle1 and particle2, would be transformed twice if called with (Muon, Muon) types at one site and (Muon, Jet) at another. The second transformation verifies that the Jet Record has all the required fields to calculate a mass.

If a function is recursive, this process would not terminate without return-type hints (e.g. for any legal inputs, mass returns a floating-point number). Refusing to transform recursive functions would not overly restrict HEP analysis functions, though Python 3’s type annotations may be helpful for such cases.

A function may return PLUR type; the transformed function either returns a Primitive or an integer index that must be propagated from the call point in the original function.

For loops may iterate over a PLUR List. The transformed List is just an integer index for the offset array, so it must be replaced by an iterator over offsets:

x range(array[x], array[x + 1])

The loop variable is now an integer representing indices into the List’s contents, as desired.

Special functions that return meaningful data about non-Primitive PLUR types must be handled on a case-by-case basis. As with any function call, if a non-Primitive PLUR type is an argument, the function’s identity must be known during the transformation sweep. Below are two important cases.

len: (get length of List) must return the length when given a PLUR List, which is computed as

len(x) off[x + 1] - off[x]

without transforming the argument x (unlike other function calls, which operate on transformed arguments).

isinstance: (check type) must return true if the argument has a given type, false otherwise. If the symbol table is branched, some symbol tables may be eliminated in scopes guarded by an isinstance check (“and” and “if”). This is the primary way a user might benefit from a Union type.

The type or types to check would have to be known as literal names, not computed references, during the transformation sweep. If the argument to check has List or Record type, the whole expression can be statically replaced with a literal True or False, depending on whether the desired types are in the set of types to check. If it is a Union, it must be replaced with a tag check.

Iii-C Flourishes

The implementation of the code transformation is open-ended: one can provide more or less support for PLUR-typed objects, as well as optimizations. The only invariant is that the transformed code must either work exactly as object-oriented code would or fail to be transformed (compilation error).

Iii-C1 List Overflows

One special case requires special attention: what to do about List index overflows? In the minimal transformation described above, PLUR List overflows behave like C array overflows, in that they return undefined results. The errors are less obvious and therefore more dangerous than typical C array overflows because PLUR values just beyond a List’s boundary belong to the same attribute in the next List, so they probably have the right scale and distribution, subtly biasing analysis results.

A simple way to eliminate mistakes like this is to add a range check to the transformed code. Indices that fail the range check should raise a runtime exception. It would be correct, but many of these range checks would be unnecessary and would slow down processing. For instance, the list subscript might be in the body of an if statement where the user did range-checking manually, or it could be in a for loop bounded by the list length (a common case).

A conservative approach would be to apply range checking by default and remove it from unnecessary cases as they are identified. The performance tests in the last section have no range checks, but the OAMap library has range checking at the time of writing.

Iii-C2 Pythonic Indices

Negative index handling in Python is a user-familiarity enhancement, in which negative values start counting from the end of the list. Without this enhancement, negative indices would be caught by a range check, so it is not strictly required. Without a range check, it is extremely dangerous, as users would get subtly wrong results by assuming normal Pythonic behavior. (Pythonic indices are currently implemented in OAMap.)

Iii-C3 Eliminating Zero-Lookups

As an optimization, one can statically identify array lookups that always return zero: the first element of every list offset array is zero, and the outermost list offset array is always evaluated at its first element. Without explicitly removing it, this unnecessary code would be executed at every step in an iteration over a List. (PLUR-unaware compilers cannot remove it.)

Iii-C4 For loop flattening

Related to the above, nested for loops that exhaustively walk over List contents, such as

[commandchars= {}] for inner in outer: for x in inner: do˙something(x)

can be collapsed into a single for loop over the innermost contents (x). The innermost data are stored contiguously, so a single loop would suffice, and it is much more likely to be vectorized by the compiler. (A PLUR-unaware compiler cannot make this optimization because it does not know that list offset arrays are monotonically increasing.)

Iii-C5 Fixed-size Arrays and Matrices

PLUR Lists can have any length, which includes a constant length, at the expense of redundant offset arrays with linearly increasing contents.

It would be possible to add the concept of a fixed-length List to the type system and propagate its implementation through OAMap and the code transformation rules. However, an important special case in which the fixed-length dimensions directly contain Primitives is available for free by accepting Numpy arrays with multidimensional shape parameters as Primitives.

Iii-C6 Type Constructors

As stated above, passing a non-Primitive PLUR object to any unrecognized function is a compilation error. This especially includes constructors for dynamic objects like Python lists and dictionaries, since we cannot statically track where they are used. However, one may wish to allow some immutable objects to contain PLUR objects, such as Python tuples to allow Python tuple-unpacking in assignments. We then become obliged, however, to track these objects as containing PLUR types.

Iii-C7 Pattern Matching

Some languages make extensive use of typed pattern matching; PLUR types would need to be tracked through syntactical structures such as these. In Python, tuple-unpacking is the most obvious instance of pattern-matching, and type inference through it is possible (implemented in the version of OAMap used in these tests).

Iii-C8 Equality and Order

Since PLUR types describe inert data (as opposed to functions or active elements like file handles), it would be reasonable to define value-based equality and possibly an ordering, effectively treating comparison operators like “==” and “<” as special functions (as well as “sorted”).

Reference-based equality, expressed in Python as an “is” operator, is easiest to implement. For objects typed at identical nodes of the PLUR schema, references are the same if their indices are equal. At compile-time, this is a schema comparison, and at runtime, we replace “is” with “==”.

Iii-C9 Fallback to Object Materialization

One way to support particularly difficult cases, such as external functions without code transformation, is by materializing objects using the arrays-to-objects algorithm. This gives up on zero-copy efficiency, but it may be worthwhile to mix fast code with expressive code. Then, rather than new features allowing specific cases of user code to run at all, new features would allow the user code to run faster.

Iii-C10 Function dispatch

As described in Section II-A, the PLUR type system only distinguishes between storage types. If the same storage type with different names should be dispatched to different functions or different versions of a function, that would be handled in code transformation. As with special functions (len and isinstance), the identities of these functions must be known at compile-time.

Iv Implementation

We are developing the OAMap toolkit[14] as the execution engine of a HEP database/query system, but it is also usable on its own. This toolkit includes the code transformation routine outlined above, which is intended for high-throughput processing, and proxy classes for low-latency exploration on the Python commandline. The proxies are Python classes that yield data on demand, using Python’s property, __getitem__, and __getattr__ to emulate static members by fetching data (from memory, disk, or network) as necessary. These proxies most clostly resemble the work of Mattis et al. in PyPy[11], except that PyPy is JIT compiled on the fly, making the proxy and code transformation approaches equivalent. However, Numpy and CPython provide access to much-needed scientific libraries.

The use of proxies would also be equivalent to code transformation in a fully compiled language like C++. The propagation of PLUR type would be performed by the C++ compiler, rather than manually through a Python AST. However, C++ would not be a convenient query language, and type-level programming in C++ is not as powerful as direct AST manipulation. (For example, it might not be possible to collapse for loops based on our knowledge that list offset arrays are monotonically increasing.) Julia would be an excellent compromise, as it is a high-level language without manual memory management, it automatically JIT compiles, and provides access to the Julia AST, but C++ and Python are much more commonly used in HEP.

Nearly all HEP data are stored in ROOT files, so we must be able to read this format efficiently. We have implemented a modification to ROOT, called BulkIO, that allows us to skip ROOT’s usual object materialization methods (TTree::GetEntry and TBranch::GetEntry). These updates are scheduled to become part of the base ROOT distribution in ROOT version 6.14.

Although the performance studies in the next section use ROOT with the BulkIO enhancements, this access method is also available as a pure Python package called uproot[16].

Iv-a Performance Studies

As an execution engine, the transformed code must be convenient and fast. We chose Python over C++ for convenience and must not pay for that choice in performance. For this reason, we have been performance-testing OAMap throughout its development, informally comparing against “bare metal” speeds of one-off C programs. We always use Numba with “nopython=True,” so the code it compiles with LLVM is almost perfectly equivalent to a C program compiled with Clang. Our informal tests bore this equivalence, but were unrealistically sourced with random data.

Real-world uses of OAMap would be sourced with HEP data in ROOT files (or a database equivalent). The most relevant comparison then is between a C++ analysis function in ROOT, from ROOT data through object materialization, and an OAMap-transformed Python function, from ROOT data but accessed as in-place arrays. We used the BulkIO enhancements to stream data into Numpy arrays and OAMap to transform and then compile the same analysis functions. C++ and Python versions of the analysis functions are listed in Figure 1.

max p in Python [commandchars= {}] maximum = 0.0 for muon in event.Muon: if ¿ maximum: maximum = fill˙histogram(maximum) max p in C++ [commandchars= {}] float maximum = 0.0; for (i=0; i ¡ muons.size(); i++) if (muons[i]-¿pt ¿ maximum) maximum = muons[i]-¿pt; fill˙histogram(maximum); eta of best by p in Python [commandchars= {}] maximum = 0.0 best = -1 for muon in event.muons: if ¿ maximum: maximum = best = muon if best != -1: fill˙histogram(best.eta) eta of best by p in C++ [commandchars= {}] float maximum = 0.0; Muon* best = nullptr; for (i=0; i ¡ muons.size(); i++) if (muons[i]-¿pt ¿ maximum) – maximum = muons[i]-¿pt; best = muon; ˝ if (best != nullptr) fill˙histogram(best-¿eta); mass of pairs in Python [commandchars= {}] n = len(event.muons) for i in range(n): for j in range(i+1, n): m1 = event.muons[i] m2 = event.muons[j] mass = sqrt( 2***( cosh(m1.eta - m2.eta) - cos(m1.phi - m2.phi))) fill˙histogram(mass) mass of pairs in C++ [commandchars= {}] int n = muons.size(); for (i=0; i ¡ n; i++) for (j=i+1; j ¡ n; j++) – Muon* m1 = muons[i]; Muon* m2 = muons[j]; double mass = sqrt( 2*m1-¿pt*m2-¿pt*( cosh(m1-¿eta - m2-¿eta) - cos(m1-¿phi - m2-¿phi))); fill˙histogram(mass); ˝ p sum of pairs in Python [commandchars= {}] n = len(event.muons) for i in range(n): for j in range(i+1, n): m1 = event.muons[i] m2 = event.muons[j] s = + fill˙histogram(s) p sum of pairs in C++ [commandchars= {}] int n = muons.size(); for (i=0; i ¡ n; i++) for (j=i+1; j ¡ n; j++) – Muon* m1 = muons[i]; Muon* m2 = muons[j]; double s = m1-¿pt + m2-¿pt; fill˙histogram(s); ˝
Fig. 1: Sample analysis functions in Python (before code transformation) and object-oriented C++, showing only the body of the loop over events.

The first, “max p,” is an example of a query that would be difficult but not impossible in SQL. Instead of exploding a muons table and grouping by a unique eventId, we keep a running maximum that resets in each event.

The second, “eta of best by p,” is an extension of that idea: we select a muon by maximizing pt and then plot its eta. This is even more awkward in SQL, but very common in HEP.

In the listing, note that best, which is a muon object, is initialized as in Python and nullptr in C++. At the time of testing, the code transformer had no concept of a nullable PLUR type, though OAMap has this feature now. Instead of initializing the object as and checking for that value as a negative index, we can now write it more naturally as None and assigning the same variable as a muon Record and a number would be a compile-time error.

The third function, “mass of pairs,” would require a full outer join in SQL but is a nested for loop in Python and C++, carefully indexed to avoid duplication. This kind of “unique pairs” loop is very common (often with a selection, e.g. requiring opposite-sign charges), and the mass formula is one of the most frequently executed in HEP.

The fourth, “p sum of pairs,” is a diagnostic of the third. As we will show below, the mass calculation is the slowest of the sample functions, and it was unclear whether the nested indexing was responsible or the complex formula. This function has the same nesting structure but a simpler formula (one that is occasionally useful in HEP).

We ran each analysis function on a 5.4 million event dataset of simulated Drell-Yan collisions (a realistic physics sample, one of about a dozen that might be involved in a real HEP analysis). The tests were performed on an i2.xlarge instance on Amazon Web Services, which features a large, fast SSD (though in the end, we opted for tests with prewarmed cache).

To avoid decompression and/or physical device access as a dominant contributor to these tests, we prepared an uncompressed ROOT file and loaded it into Linux page cache with vmtouch[17]. This lets us see the differences due to other factors more clearly.

Both tests were single-threaded and had plenty of working space memory. The benefits of parallelization are beyond the scope of this paper, and also factorize from our single-threaded tests. If we double the single-threaded speed of this embarrassingly parallel problem, we double the speed of a parallelized version (unless that parallel processor is swamped with overhead).

The results of the study are shown in Figure 2. The shorthand “ROOT” signifies a conventional ROOT workflow with object materialization and C++, and “code transformation” is our workflow with BulkIO, no object materialization, and transformed Python code.

Fig. 2: Event processing rates, including read and execute times, for 5.4 million events in the test sample. Grouped bars indicate different analysis functions and colors indicate different workflows. See the text for details.

The four colors signify variants on these workflows. “ROOT full dataset” means we let ROOT fill all 42 attributes of the Muon objects, which is clearly unnecessary for our functions. The event processing rate for this case is 0.4 MHz, regardless of the content of the function. Reading and filling the objects overwhelms all other factors. (This case is only included for completeness.)

“ROOT selective on full” uses ROOT’s opt-in mechanism to avoid filling all attributes in the objects, but still used the 42-field object definitions and original data file. The event processing rate is 1.29 MHz, regardless of the function. Handling all of the attributes still dominates.

“ROOT slim dataset” performs the same selective read on a specially prepared dataset and object definition that has only 3 fields: pt, eta, and phi. We now see different rates for the four functions: 6.68, 5.96, 3.56, and 6.34 MHz.

“Code transformation on full ROOT dataset” is the only test in this batch that uses transformed Python code. It accesses the full dataset; the slim dataset yields similar results because this method is unconcerned with unused columns. The four functions are processed at 17.9, 12.1, 6.09, and 17.2 MHz, respectively, considerably faster than object materialization.

The slowest of the four functions is “mass of pairs.” Unlike the first two functions, this involves a doubly nested loop over muons to find distinct pairs, as well as a much more complex formula. The fourth function has the same loop structure without the complex formula, and it is as fast as the single loops. Upon further investigation, we found that the trigonometric and hyperbolic cosines account for the majority of the time spent in this function.

The factor that the ROOT tests and OAMap test had in common is that they both extracted data from ROOT files and used part of the ROOT framework to read them. OAMap can operate on any arrays, regardless of whether they came from ROOT, so we performed another test in which we extracted all the data as Numpy arrays in memory. The ROOT files were in memory, too, because we prewarmed the Linux cache with vmtouch, but some processing is still required to seek to the relevant parts of the file to expose the arrays.

We compare the OAMap result (copied from the previous plot on the new scale) from ROOT and from raw arrays in Figure 3 for two reasons: (1) it shows just how much room there is between these execution rates and the data-access rate, since somewhat more complex functions will be slower and we want to know when to expect it to be a bottleneck, and (2) because a HEP database system would conceivably cache frequently accessed arrays in memory, and this shows the difference between a cache-hit and a cache-miss. The effect of the slow mass calculation is dramatic: “mass of pairs” runs at 12.8 MHz while “p sum of pairs” runs at 56.2 MHz.

Fig. 3: Event processing rates for the same four analysis functions, but only for PLUR workflows. Colors represent data sources: full ROOT dataset and Numpy arrays in memory.

V Conclusions

We have demonstrated that it is possible to transform code to meet a data format, rather than deserializing data to fit the code’s expectations, as long as JIT compilation is available. We have also demonstrated that, once transformed and compiled, an idiomatic Python analysis function outperforms the same function written in idiomatic C++ with object materialization.

This should not be taken as a claim that Python is faster than C++, just as SQL is not faster than C++, but that we have applied one of the tools used by databases to accelerate queries to a general-purpose language. The compilation of Python by Numba provides parity between a restricted subset of Python (statically typable, no first-class functions) and C++, and the code transformation avoids the cost of object materialization. In principle, the same speedup could be achieved in C++ or Julia with the appropriate proxy classes.

Finally, the usefulness of this technique is not limited to HEP. The need for complex loop dependencies can hardly be HEP-specific222As an indication of this need, Netflix proposed changes to Spark to allow higher-order functions in SparkSQL, which would transform subcollections without a full explode-and-join: Although many industries and fields of academia are currently using SQL or languages similar to SQL for data analysis, how much is being left unexplored because the tools are not suited for the task?

It is our hope that this technique finds application in many different fields, just as the techniques of database-style analysis have inspired our work in HEP.


This work was supported by the National Science Foundation under Grants No. 1450377 and 1450323. The authors wish to thank Philippe Canal for his expert help in understanding the ROOT file format and ROOT I/O subsystem.