Thanks again for doing this, @bgrommes. Your ten-column result identified a genuine performance problem in the RecordSet/DatabaseColumn implementation, so I’ve now created a reproducible benchmark, investigated the underlying cause and implemented several optimisations.
There are some rather encouraging results 🙂.
What the original benchmark revealed
Your suspicion about the DatabaseColumn snapshot mechanism pointed towards the correct area, although the implementation was doing something slightly different from what the documentation wording might suggest.
SelectSQL() materialises the complete result set before returning the RecordSet. In other words, by the time this begins:
While rs.MoveToNextRow()
// ...
Wend
all rows and all selected columns have already been read from SQL Server and stored in memory. MoveToNextRow() itself therefore only advances an index through that snapshot.
This also means that the empty-loop comparison is not quite measuring equivalent work unless the C# code also materialises the entire reader before starting its timer. The timed Objo loop is no longer performing database or network I/O.
However, the increase from one accessed field to ten accessed fields was absolutely meaningful. It exposed a real client-side cost in Objo.
Every call to:
rs.Column("Name")
was doing all of the following:
- Looking up the column ordinal by name.
- Looking up the
DatabaseColumn class in the VM.
- Creating a new Objo object to represent the column snapshot.
- Creating a separate native state object containing its value.
- Adding the object and state to a
ConditionalWeakTable.
- Looking the state up again when
.StringValue, .IntegerValue, etc. was accessed.
- In the name-based path, creating a capturing error-handling delegate.
For 250,000 rows and ten fields, that means at least 2.5 million DatabaseColumn objects, 2.5 million separate state objects, and millions of weak-table insertions and lookups. The nullable fields add some additional property accesses.
The result snapshot also repeated the column name and a NULL flag inside every stored cell, despite the column name already being available in the result-set metadata.
So no, Objo was not fetching the column from SQL Server again or copying the complete row each time. But the supposedly lightweight snapshot wrapper was considerably more expensive to construct and access than it needed to be.
I did some internal benchmarking
I added a permanent SQL Server benchmark to the repository. It runs SQL Server 2022 in Docker and creates:
- 250,000 rows.
- 22 selected columns.
- A mixture of strings, integers, Booleans and NULLs.
- Zero-, one- and ten-field access cases.
- Both name-based
Column() and ordinal-based ColumnAt() cases.
It measures result materialisation and row iteration separately, including allocated bytes and garbage-collection counts. Every value-access test accumulates a checksum so the work cannot simply be discarded.
The figures below are the median of three measured iterations after warm-up. They were recorded on my M4 Max MacBook Pro using macOS 26.5.2, .NET 10.0.7 and Objo’s Release VM execution profile.
The absolute figures are therefore not directly comparable with your Windows/Azure/debug-profile results, but the before-versus-after comparison uses the identical machine, container, data and benchmark.
Before and after
| Scenario | Before | After | Improvement |
| Empty iteration | 62.971 ms | 24.913 ms | 60.4% faster |
| One field by name | 120.110 ms | 97.769 ms | 18.6% faster |
| Ten fields by name | 1,680.865 ms | 673.611 ms | 59.9% faster |
| One field by ordinal | 119.059 ms | 93.736 ms | 21.3% faster |
| Ten fields by ordinal | 1,492.671 ms | 626.572 ms | 58.0% faster |
The ten-name case is the closest equivalent to the interesting part of your benchmark. Its loop time fell from approximately 1.68 seconds to 0.67 seconds.
The allocation results also demonstrate just how allocation-heavy this path was:
| Measurement | Before | After |
| Ten-name loop allocations | 1.826.3 GB | 1.456.8 GB |
| Ten-name Gen-0 collections | 211 | 174 |
| Result materialisation time | 390.7 ms | 296.1 ms |
| Result materialisation allocations | 289.1 MB | 201.1 MB |
Result materialisation is now approximately 24% faster and allocates about 30% less memory. The ten-field loop is approximately 60% faster.
What changed
This first series of optimisations deliberately makes no changes to the public Objo API.
DatabaseColumn is still an immutable snapshot, so this remains valid:
rs.MoveToNextRow()
Var firstName As DatabaseColumn = rs.Column("Name")
rs.MoveToNextRow()
// Still contains the first row's value.
Print(firstName.StringValue)
Internally, however:
DatabaseColumn is now represented by a specialised Objo instance that holds its native value directly.
- The separate state object and
ConditionalWeakTable have been removed.
- The
DatabaseColumn runtime class is resolved once per RecordSet, rather than once per field access.
- The capturing error-handling wrapper has been removed from the already-materialised
Column() path.
- Stored cells no longer repeat their column name and NULL flag. Column metadata is stored once per result set.
Where this leaves us
There is still room for optimisation. Even with these improvements, the existing public API must create a stable DatabaseColumn object for every Column() call. That is why the ten-field benchmark still allocates around 1.46 GB while creating 2.5 million snapshots.
An allocation-free bulk-data API could go further, but I have intentionally not added one yet. I wanted first to optimise the existing API without breaking source compatibility or changing its semantics, then measure the result properly.
The roughly 60% reduction in the ten-field loop is a very worthwhile first improvement.
Thank you for staying up late and running that extra test! It directly resulted in a significant optimisation for the next Objo Studio release.
https://feedback.objo.dev/bug/820