Skip to content

Parallel I/O output module enhancements#655

Draft
SeanBryan51 wants to merge 31 commits intoparallelio-playgroundfrom
parallelio-playground-output-module-enhancements
Draft

Parallel I/O output module enhancements#655
SeanBryan51 wants to merge 31 commits intoparallelio-playgroundfrom
parallelio-playground-output-module-enhancements

Conversation

@SeanBryan51
Copy link
Collaborator

@SeanBryan51 SeanBryan51 commented Nov 18, 2025


📚 Documentation preview 📚: https://cable--655.org.readthedocs.build/en/655/

@SeanBryan51 SeanBryan51 force-pushed the parallelio-playground-output-module-enhancements branch from 4b2f171 to 8abda2c Compare November 18, 2025 22:39
@SeanBryan51
Copy link
Collaborator Author

SeanBryan51 commented Nov 18, 2025

Hi @Whyborn, I've opened this PR to highlight the output module specific diffs (includes the aggregators and the list data structure for output variables). The implementation still contains a bunch of TODO's, but it would be awesome if I could get your feedback at this stage.

I've been testing it only in serial (testing in parallel will only be possible once the met data can be read in via parallel I/O) and currently gives the same answers as before for Qh.

However, I did have to change the NetCDF format in the old output module from classic to NetCDF4/HDF5 to easily compare outputs between new and old. This was a hack as the new NetCDF API has the NetCDF4 format hardcoded whenever creating or opening new files. I'll make that configurable so that both classic and NetCDF4 is supported.

@Whyborn
Copy link
Contributor

Whyborn commented Nov 19, 2025

First pass comments:

  • I think the output should be dropped from cable_output_aggregator_t- the aggregator works exactly perfectly fine outside the output module, and can be used any time you need temporal aggregation for output or science.
  • I don't think the output variable should handle the aggregation frequency. I think the control should be with the relevant science (I guess particularly the relevant science output module) module as to when an aggregator should be accumulated.
  • What's the benefit to having the active argument in the cable_output_add_variable call, over an if statement?
  • I think the general approach should be to have the aggregators defined first, and then pass as arguments to the cable_output_add_variable call. Something like:
class(aggregator_t), dimension(:), allocatable :: Qh_aggregators
...
Qh_aggregators = create_aggregators(canopy%fh, ["mean", "max"])
call cable_output_add_variable(
  ...
  aggregator=Qh_aggregators
  ...
)

This way it makes the aggregators available for use where necessary (e.g. the Tmx) variable. Although I'm not sure if you could make the elements of the returned Qh_aggregators targets?

  • I don't think the shapes should be pre-defined. I think they should be built by the caller by passing dimensions, something like:
cable_output_shape_land_patch = create_decomposition(["land", "patch"])
cable_output_shape_land_patch_soil = create_decomposition(["land", "patch", "soil"])

where the dimensions are defined earlier in initialisation with something like:

call define_cable_dimension("soil", 6)
call define_cable_dimension("rad", 2)

I think this makes the design more easily extensible? This makes it trivial for someone developing new code which may have new dimensions to output their own stuff.

  • In aggregators.f90, what's the point of the aggregator_store_t class? This seems like it could be
type aggregator_store_t
  class(aggregator_t), allocatable :: aggregators(:)
  integer :: num_aggregators
end type aggregator_store_t

where num_aggregators functions in the same way the current num_aggregators functions.

  • I do think we need a time module, but we may be able to import that from elsewhere for a full featured timing module.

@SeanBryan51
Copy link
Collaborator Author

Thanks @Whyborn for your thoughts!

  • I think the output should be dropped from cable_output_aggregator_t- the aggregator works exactly perfectly fine outside the output module, and can be used any time you need temporal aggregation for output or science.
  • I don't think the output variable should handle the aggregation frequency. I think the control should be with the relevant science (I guess particularly the relevant science output module) module as to when an aggregator should be accumulated.

I agree that it would be nice if time related control were handled at a higher level (for example with the relevant science output module as you say). However, it wasn't obvious to me how this approach could allow for driving the intermediate aggregators required for Tmx and Tmn, where the intermediate aggregator accumulates at all frequencies and aggregates at daily frequencies, and the resulting aggregator accumulates at daily frequencies and aggregates at monthly frequencies. The cable_output_aggregator_t, along with the handling of the aggregation and accumulation frequencies at the aggregator level, was introduced so that aggregators can be driven independently at any accumulation or aggregation frequency which seemed easier for supporting the intermediate aggregator case. There might be a better solution here though, so I'm definitely more than happy to discuss further.

  • What's the benefit to having the active argument in the cable_output_add_variable call, over an if statement?

For two reasons:

  1. I think it's nicer for enabling variables depending on some sort of boolean logic. For example for groups of variables, we could just .or. the variable specific logical with the group logical rather than updating the output inclusion type instance as is done here.
  2. The advantage of using active over nesting the call within if (active) is that this allows us to build a list of all possible supported outputs (both active and inactive) which could used to generate documentation on the supported outputs. This was inspired from the CTSM/CLM folks.
  • I think the general approach should be to have the aggregators defined first, and then pass as arguments to the cable_output_add_variable call. Something like:
class(aggregator_t), dimension(:), allocatable :: Qh_aggregators
...
Qh_aggregators = create_aggregators(canopy%fh, ["mean", "max"])
call cable_output_add_variable(
  ...
  aggregator=Qh_aggregators
  ...
)

This way it makes the aggregators available for use where necessary (e.g. the Tmx) variable. Although I'm not sure if you could make the elements of the returned Qh_aggregators targets?

Oh yep I remember you mentioning this in our discussions. I think this is something we could definitely add in the future - I was hesitant to introduce this now as I want to avoid diverging in functionality from the current output module which assumes a single aggregation method per variable.

As for a list of objects being targets, I got around that problem by working with arrays of type aggregator_handle_t which contain a reference to an aggregator rather than the actual aggregator instance.

  • I don't think the shapes should be pre-defined. I think they should be built by the caller by passing dimensions, something like:
cable_output_shape_land_patch = create_decomposition(["land", "patch"])
cable_output_shape_land_patch_soil = create_decomposition(["land", "patch", "soil"])

where the dimensions are defined earlier in initialisation with something like:

call define_cable_dimension("soil", 6)
call define_cable_dimension("rad", 2)

I think this makes the design more easily extensible? This makes it trivial for someone developing new code which may have new dimensions to output their own stuff.

I like this suggestion, I agree it's definitely not that obvious what one needs to do to create a new CABLE_OUTPUT_SHAPE_TYPE_*. I think rather than determining the shape of the decomposition object from the defined dimensions, I would just initialise a new decomposition via the io_decomp_* procedures, or use the io_decomp instance. Removing the SHAPE_TYPE_* variables means I will need to rethink about how each variable will infer the temporary buffer used for grid cell averaging. I'll reflect on that a bit.

  • In aggregators.f90, what's the point of the aggregator_store_t class? This seems like it could be
type aggregator_store_t
  class(aggregator_t), allocatable :: aggregators(:)
  integer :: num_aggregators
end type aggregator_store_t

where num_aggregators functions in the same way the current num_aggregators functions.

aggregator_store_t was introduced because allocatable arrays of a polymorphic type are problematic (i.e. class(aggregator_t), allocatable :: aggregators(:)), as the compiler doesn't know in general what the type (and hence size) of each element will be on allocation. In practice, you need a container derived type which itself is not polymorphic, like aggregator_store_t, as the list element. It's similar to how you would declare an array of pointers in fortran. Interestingly the IBM compiler seems to actually allow for allocatable arrays of a polymorphic type: https://www.ibm.com/docs/en/xl-fortran-aix/16.1.0?topic=concepts-array-constructors, but its not standard as far as I know.

  • I do think we need a time module, but we may be able to import that from elsewhere for a full featured timing module.

I agree, a timing module for wider use in the code would be great. The time_step_matches function was my hacky attempt to clean up the timing logic code in write_output, definitely something we could introduce properly (independent of these changes).

@Whyborn
Copy link
Contributor

Whyborn commented Nov 19, 2025

However, it wasn't obvious to me how this approach could allow for driving the intermediate aggregators required for Tmx and Tmn, where the intermediate aggregator accumulates at all frequencies and aggregates at daily frequencies, and the resulting aggregator accumulates at daily frequencies and aggregates at monthly frequencies. The cable_output_aggregator_t, along with the handling of the aggregation and accumulation frequencies at the aggregator level, was introduced so that aggregators can be driven independently at any accumulation or aggregation frequency which seemed easier for supporting the intermediate aggregator case.

Does this allow aggregators to be driven independently? It seems to me like specifically doesn't allow this, because every aggregator which accumulates e.g. on_timestep, daily etc would be accumulated at the same time. This would mess with things that happen at the end of the day, using some aggregation of on_timestep quantities. I think the Tmn and Tmx variables are examples- I'm fairly sure these feed into CASA science. That would mean, to get the correct daily minima and maxima, the order of operations would have to look like:

do_biogeophysics -> do_timestep_accumulation -> do_CASA -> do_daily_accumulation

There has to be a way to trigger accumulation at specific times, for instances like this. This is why I think the aggregators have to be available to work with as standalone objects.

I think it's nicer for enabling variables depending on some sort of boolean logic. For example for groups of variables, we could just .or. the variable specific logical with the group logical rather than updating the output inclusion type instance as is done here.
The advantage of using active over nesting the call within if (active) is that this allows us to build a list of all possible supported outputs (both active and inactive) which could used to generate documentation on the supported outputs. This was inspired from the CTSM/CLM folks.

Yea I can see that, it might be more easily readable to have every call contain a

output%variable .or output%variable_group .or. output%variable_module

instead of a construction like

if (output%variable_module) then
  if (output%variable_group) then
    if (output%variable) then
      ...
    end 
  end
end

Although I don't see why the latter couldn't also be used to create the same documentation in the same way.

I like this suggestion, I agree it's definitely not that obvious what one needs to do to create a new CABLE_OUTPUT_SHAPE_TYPE_. I think rather than determining the shape of the decomposition object from the defined dimensions, I would just initialise a new decomposition via the io_decomp_* procedures, or use the io_decomp instance. Removing the SHAPE_TYPE_ variables means I will need to rethink about how each variable will infer the temporary buffer used for grid cell averaging. I'll reflect on that a bit.

Yea that's what I meant, whether the io_decomp is created via dimensions names or just numbers, which could be accessible with something like dim_length(dim_name), is much of the same to me. I do think it's important that the various dimensions can be accessed in this way, since it's something done already throughout the code via use statements, and this would help eliminate that method of variable sharing.

aggregator_store_t was introduced because allocatable arrays of a polymorphic type are problematic (i.e. class(aggregator_t), allocatable :: aggregators(:)), as the compiler doesn't know in general what the type (and hence size) of each element will be on allocation. In practice, you need a container derived type which itself is not polymorphic, like aggregator_store_t, as the list element. It's similar to how you would declare an array of pointers in fortran. Interestingly the IBM compiler seems to actually allow for allocatable arrays of a polymorphic type: https://www.ibm.com/docs/en/xl-fortran-aix/16.1.0?topic=concepts-array-constructors, but its not standard as far as I know.

Are you sure? I thought arrays of polymorphic classes were part of the standard, I used them in some of my aggregator testing. You just need select type blocks to access any of the child components.

I agree, a timing module for wider use in the code would be great. The time_step_matches function was my hacky attempt to clean up the timing logic code in write_output, definitely something we could introduce properly (independent of these changes).

There's the datetime-fortran which we already have a spack package for? I haven't actually looked at it's features yet though.

@SeanBryan51
Copy link
Collaborator Author

SeanBryan51 commented Nov 25, 2025

Hi @Whyborn, I've made the updates we discussed last week. I think it's looking much better now with your suggestions on how aggregators are organised in the output module, thanks as always for the comments! Please let me know if there is anything else that catches your eye on the design.

I'm going to try add support for specifying non-time varying data in the output and restart files. For this I'm thinking we could introduce a cable_output_add_parameter subroutine (similar to cable_output_add_variable) for specifying the non-time varying parameters in the output, and a similar cable_output_add_restart_variable for the restart variables.

@Whyborn
Copy link
Contributor

Whyborn commented Nov 25, 2025

Just a couple of comments:

  • The cable_output_add_variable routine doesn't have an aggregation_frequency yet- I think we need that and a minimum frequency
  • I think it would be good to decouple the output and the grid cell averaging (and reductions in general). A bit like the aggregators- it's a functionality useful for the output, but should be considered a distinct module that the output module uses.
  • Along that line, I think it would be nice to define reducers/mapper by name. So you could define the variable like:
if (output%patch)
  decomp = decomp_land_real32
  reducer = "patch"
else
  decomp = decomp_land_patch_real32
  reducer = "none"
end if

call cable_output_add_variable(
  name="Qh",
  ...
  decomp=decomp,
  reducer=reducer
)

And then in the write_variable you could have:

subroutine write_variable(output_variable, output_file, time_index)
...
  if (output_variable%reducer == "patch") then
    call output_file%write_darray(
        var_name=output_variable%name,
        values=compute_patch_average(aggregator%storage)
        decomp=output_variable%decomp,
        frame=time_index
  else if (output_variable%reducer == "none") then
    call output_file%write_darray(
        var_name=output_variable%name,
        values=aggregator%storage
        decomp=output_variable%decomp,
        frame=time_index
  end if

Then you wouldn't have to have all the buffers. The compute_patch_average would be a function interface, with different rank/type module procedures.

  • Can you remind me why the all the decompositions for different element types are required to have distinct variables? Aren't the decomps basically just indexing arrays?

@SeanBryan51
Copy link
Collaborator Author

SeanBryan51 commented Nov 26, 2025

  • The cable_output_add_variable routine doesn't have an aggregation_frequency yet- I think we need that and a minimum frequency

I'm definitely happy to add the frequency limit to cable_output_add_variable as that information is no doubt specific to each variable. I removed the aggregation_frequency argument because the aggregation frequency is tied to the output frequency which is set at the "profile" level.

  • I think it would be good to decouple the output and the grid cell averaging (and reductions in general). A bit like the aggregators- it's a functionality useful for the output, but should be considered a distinct module that the output module uses.

That sounds good to me, I will rename the module containing the grid cell averaging stuff to be more general (and maybe put this under utils/)?

  • Can you remind me why the all the decompositions for different element types are required to have distinct variables? Aren't the decomps basically just indexing arrays?

The distinction of types for decompositions is required by PIO via the basepiotype argument of pio_initdecomp.

  • Along that line, I think it would be nice to define reducers/mapper by name.

I'm happy to introduce a string argument for reducer instead of the grid_cell_average logical.

Then you wouldn't have to have all the buffers. The compute_patch_average would be a function interface, with different rank/type module procedures.

I did consider using a function interface instead of a subroutine interface, however I opted for the subroutine approach with the temporary buffer as this avoids introducing a potentially large allocation when computing the grid cell average. I want to limit the number of unnecessary allocations and copy operations in the write procedures where possible. It might be possible to return a preallocated array from a function. I can look into this a bit more.

Thank you again for the feedback on this!

@SeanBryan51
Copy link
Collaborator Author

Thanks for the comments @ccarouge, I'll add in those suggestions!

@SeanBryan51 SeanBryan51 force-pushed the parallelio-playground-output-module-enhancements branch from 03eaa9b to fb4febe Compare December 2, 2025 03:32
@SeanBryan51 SeanBryan51 force-pushed the parallelio-playground-output-module-enhancements branch from 4724830 to 9263f1e Compare December 10, 2025 00:55
@SeanBryan51
Copy link
Collaborator Author

Just an update on this PR! I'm currently adding functionality for restarts to the new output module by adding a restart flag component to cable_output_variable_t. This will roll back the last two commits I made which introduce a separate module for handling restart files: src/offline/cable_serial.F90: write restarts via new restart modules and Add cable_restart_mod and cable_restart_write_mod. There are a few things in the works to get this implemented, namely:

  1. Currently the new output module only allows for writing distributed arrays so I'm currently adding support for writing non-distributed (global) arrays as this is required for some restart fields.
  2. Quality of life change: instead of specifying the netCDF variable dimensions when declaring output variables, specify the dimensions of the in-memory array and instead infer the netCDF dimensions from these - this is more meaningful as the netCDF dimensions tend to vary based on the configuration, e.g. whether individual patches are written or the x-y vs land grid format (only temporary of course). This also simplifies the API for declaring output variables (see next point).
  3. Remove the decomp and temp_buffer_* components from cable_output_variable_t and instead infer these at write time - this removes some data redundancy as the reduction_method and the in-memory array determine decomp and temp_buffer_*. This also simplifies the declaration of output variables in the code as the decomposition also depends on configuration options - accounting for these options makes the code for declaring output variables unnecessarily bloated.

More to come 🙂

@SeanBryan51 SeanBryan51 force-pushed the parallelio-playground-output-module-enhancements branch from 9263f1e to 3094a2b Compare January 22, 2026 05:38
@SeanBryan51
Copy link
Collaborator Author

Hi @Whyborn, apologies it has been a while since I've followed up on this! I had to stop myself from tweaking small things 🙃

The output module now has functionality for writing restarts and has gone through a pretty aggressive restructuring. Since a lot of the previous commits on the output module are no longer relevant with the restructuring, I've blown away most of commit history and grouped the commits into these categories:

  • Utilities (e.g. enum type, aggregator type)
  • NetCDF API enhancements
  • Non-output module related bug fixes or comments
  • Output module implementation including restart file stuff

Please let me know what you think! (at a time that's convenient of course, it's a pretty dense PR 😅 )

The next thing on the plate after this is would be to introduce working variables for all diagnostics, and then add all output and restart variables and test for (approximate) bitwise reproducibility.

@SeanBryan51 SeanBryan51 force-pushed the parallelio-playground-output-module-enhancements branch from 876c111 to 64b3cf4 Compare January 30, 2026 01:44
@SeanBryan51 SeanBryan51 force-pushed the parallelio-playground-output-module-enhancements branch from 4b4facd to a294717 Compare February 6, 2026 05:05
@SeanBryan51 SeanBryan51 force-pushed the parallelio-playground-output-module-enhancements branch from 619998e to 07f646e Compare February 10, 2026 01:36
SeanBryan51 added a commit that referenced this pull request Feb 11, 2026
…alls to netCDF (#681)

This change is a bug fix and also a refactor. Currently, enabling the
land use scheme (via `l_landuse`) causes the following error when
writing out the land use restart file:

```
 writing cable restart: land use
 Error writing iveg parameter/variable to output file (SUBROUTINE write_output_p
 arameter_r1)
 NetCDF: Not a valid ID                                                         
```

This is because the incorrect netCDF file ID is used in
`cable_output.F90` when writing the land use restart file:


https://github.com/CABLE-LSM/CABLE/blob/b2d3f8df5c055a5942f1f5e86288f736582afd74/src/offline/cable_output.F90#L51

Rather than fixing this directly, this change replaces all calls to
cable_write.F90 procedures with direct calls to netCDF. This is done so
that in the future we can safely replace `cable_output.F90` and
`cable_write.F90` with a renewed output module as part of the parallel
I/O output module changes (see
#641 and
#655).

Note that the land use scheme is not yet currently functional, even with
these changes, as `landuse3.F90` is partially implemented.

## Type of change

Please delete options that are not relevant.

- [x] Bug fix
- [x] Refactor

## Checklist

- [x] The new content is accessible and located in the appropriate
section
- [x] I have checked that links are valid and point to the intended
content
- [x] I have checked my code/text and corrected any misspellings

## Testing

- [x] Are the changes bitwise-compatible with the main branch? If
working on an optional feature, are the results bitwise-compatible when
this feature is off? If yes, copy benchcab output showing successful
completion of the bitwise compatibility tests or equivalent results
below this line.

```
2026-02-05 14:20:00,805 - INFO - benchcab.benchcab.py:380 - Running comparison tasks...
2026-02-05 14:20:00,831 - INFO - benchcab.benchcab.py:381 - tasks: 168 (models: 2, sites: 42, science configurations: 4)
2026-02-05 14:22:56,227 - INFO - benchcab.benchcab.py:391 - 0 failed, 168 passed
```

<!-- readthedocs-preview cable start -->
----
📚 Documentation preview 📚:
https://cable--681.org.readthedocs.build/en/681/

<!-- readthedocs-preview cable end -->
Note that some biogeophysics diagnostic variables overlap with CASA-CNP
diagnostic variables, e.g. `canopy%fgpp` and `canopy%fnpp`. When
CASA-CNP is enabled, these diagnostic variables are re-calculated using
CASA-CNP variables before writing the diagnostic variables to the output
file. The overriding of diagnostic variables is done to match the
current behaviour of the output module - there may be scope to enable
both CASA and non-CASA variants of these diagnostics in the future.
…f life changes

In src/offline/cable_mpimaster.F90 and src/offline/cable_serial.F90,
daily aggregators are reset immediately after writing instead of in the
next iteration as this breaks for the last ktau iteration.

New components have been added to `cable_output_variable_t`. These
include `scale`, `offset` which are useful for applying unit
conversions, and also `field_name` and `netcdf_name` to facilitate both
restart variable names (`field_name`) and output variable names
(`netcdf_name`).

All components of the cable_output_variable_t except `field_name` and
`aggregator` now have defaults. This cleans up the interface for
defining output variables.

Improved validation checks on registered output variables.

A new `coordinate_variables` component has been added to
`cable_output_profile_t` such that coordinate variables can be treated
differently from the requested outputs due to differing metadata
requirements.

Removed start_year hack from drivers by overriding cable_user%yearstart
to syear for the default MetType case.
SeanBryan51 added a commit that referenced this pull request Feb 24, 2026
This change removes duplicate module variables for namelist options
`l_casacnp`, `l_landuse`, `l_laiFeedbk`, and `l_vcmaxFeedbk`.

Cherry-picked from #655

## Type of change

Please delete options that are not relevant.

- [x] Bug fix

## Checklist

- [x] The new content is accessible and located in the appropriate
section
- [x] I have checked that links are valid and point to the intended
content
- [x] I have checked my code/text and corrected any misspellings

## Testing

- [x] Are the changes bitwise-compatible with the main branch? If
working on an optional feature, are the results bitwise-compatible when
this feature is off? If yes, copy benchcab output showing successful
completion of the bitwise compatibility tests or equivalent results
below this line.

```
2026-02-24 11:49:25,946 - INFO - benchcab.benchcab.py:380 - Running comparison tasks...
2026-02-24 11:49:25,982 - INFO - benchcab.benchcab.py:381 - tasks: 168 (models: 2, sites: 42, science configurations: 4)
2026-02-24 11:52:24,750 - INFO - benchcab.benchcab.py:391 - 0 failed, 168 passed
```

Spatial runs are also bitwise compatible:

```
nccmp -d runs/spatial/tasks/crujra_access_R*_S0/archive/output000/cable_out.nc
nccmp -d runs/spatial/tasks/crujra_access_R*_S1/archive/output000/cable_out.nc
nccmp -d runs/spatial/tasks/crujra_access_R*_S2/archive/output000/cable_out.nc
nccmp -d runs/spatial/tasks/crujra_access_R*_S3/archive/output000/cable_out.nc
```

<!-- readthedocs-preview cable start -->
----
📚 Documentation preview 📚:
https://cable--692.org.readthedocs.build/en/692/

<!-- readthedocs-preview cable end -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants