RNTuple: how to read a single vector from a vector of vectors


It’s been pointed here The optimal way to store variable tracks count in a TTree - #28 by LeWhoo that RNTuple may be much more efficient in reading a variable number of vectors than a TTree.

Just to remind: each of my events has a different number of traces. Each trace is composed of 6 vector (also variable length). To store this variability in TTree I’ve created a branch holding vector<my_trace_class>, and the TTree was able to split it into 6 vector<vector>. However, in this way the TTree reads a whole vector<vector> per entry - basically vector X of all the traces. The likely user case is to read vector X of only one trace, or just a single value of this vector, through all the events (entries). With roughly 180 traces per event, with TTree I am reading ~180 times too much.

The question is, how to do it properly with RNTuple? At the moment I just followed what I’ve created for the TTree:

   std::shared_ptr<vector<vector<float>>> se_x = model->MakeField<vector<vector<float>>>("SimEfield_X");
   std::shared_ptr<vector<vector<float>>> se_y = model->MakeField<vector<vector<float>>>("SimEfield_Y");
   std::shared_ptr<vector<vector<float>>> se_z = model->MakeField<vector<vector<float>>>("SimEfield_Z");
   std::shared_ptr<vector<vector<float>>> ss_x = model->MakeField<vector<vector<float>>>("SimSignal_X");
   std::shared_ptr<vector<vector<float>>> ss_y = model->MakeField<vector<vector<float>>>("SimSignal_Y");
   std::shared_ptr<vector<vector<float>>> ss_z = model->MakeField<vector<vector<float>>>("SimSignal_Z");

Then for reading I do:

	auto se_x = model->MakeField<vector<vector<float>>>("SimEfield_X");
//	auto se_y = model->MakeField<vector<vector<float>>>("SimEfield_Y");
//	auto se_z = model->MakeField<vector<vector<float>>>("SimEfield_Z");
//	auto ss_x = model->MakeField<vector<vector<float>>>("SimSignal_X");
//	auto ss_y = model->MakeField<vector<vector<float>>>("SimSignal_Y");
//	auto ss_z = model->MakeField<vector<vector<float>>>("SimSignal_Z");

   auto ntuple = RNTupleReader::Open(std::move(model), "F", "test.root");

auto stime = std::chrono::high_resolution_clock::now();
   for (auto entryId : *ntuple) {
auto etime = std::chrono::high_resolution_clock::now();	

I can see differences in the measured readout time when I comment/uncomment more of the fields. However, I understand, that in this case the RNTuple also reads the whole vector<vector>. How to make it read just a single vector from inside the external vector? Or perhaps a completely different approach to storing the variable number of traces should be used in the case of RNTuple?

ROOT Version: 6.24.00

With the LoadEntry() API, even using a reduced model, optimal reads are difficult to achieve due to the nested vectors. Instead of working with vector<vector<...>>, I’d suggest to created named structs. It allows you to give names to all the individual nesting levels of the event and makes the code more accessible to readers. For instance, in your case a class structure might look like this:

struct Hit {
  float se_x;
  float se_y;
  float se_z;
  float ss_x;
  float ss_y;
  float ss_z;

struct Track {
  std::vector<Hit> hits;

struct Event {
  std::vector<Track> tracks;

If you don’t interpret but compile the code, which should be done for benchmarks, you would need to create dictionaries for these classes.

For benchmarks and performance critical code, I’d further suggest the RNTupleView API instead of the LoadEntry() API. The RNTuple views often avoid the memory copies, but are bit less comfortable to use.

I attached a script that shows how to create events with the above structure and how to read only se_z. You might notice that naming vectors of structs in RNTuple is still a little ugly because the class name creeps in (“tracks.Track” etc.). This is on our todo list to be improved.

traces.C (1.6 KB)

As a follow up, here is a version that reads se_z with RDataFrame.

void ReadWithRDF() {
  TH1F h("se_z", "se_z", 100, 250, 350);

  auto df = ROOT::Experimental::MakeNTupleDataFrame("NTuple", "data.root");
  df.Foreach([&h](const std::vector<std::vector<float>> &se_z) {
    for (const auto &hit : se_z) {
      for (auto val : hit)
  }, {"events.tracks.hits.se_z"});


Like the view version, the RDF version also only reads the data for se_z from disk. However, it does create an actual std::vector<std::vector<float>> in memory, which makes it slower than the view counterpart.

If the problem was reading an std::vector<float> (i.e., non-nested vector), one could directly use the Histo1D action in RDF instead of the “fallback” Foreach action.

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.