C++ ConstExpr Compile-time Lookup Table Generation

In big projects it is not uncommon for there to be large generated data-sets that based on simple parametric functions. Especially in graphical applications.

This often means writing a program to generate the results and then having a large data file that is imported or sometimes even copied into a normal code file. This can lead to lots of problems that are caused by changes in functions that generate them, synchronising them with the project or even just a programmer accidentally changing a single value in the list.

To avoid this you can generate the tables in the application, however this adds the problem of startup costs which are not good for the end-user experience.

To avoid these issues in modern C++ it is possible to generate the function result tables at compile. Here I will show a simple demo of how to generate and access one of these tables for any function which can be evaluated at compile time.

Approach

The general approach to this problem is to have a constant table that can be generated once at compile time and then a number of functions for accessing this data at runtime in a controlled error free way.

For this implementation the array will be a 'static constexpr std::array<Type, Size>' that will be dynamically created at compile time. This will be created using template functions to population the array with the correct size and type.

To access and read this array we will want a template function which takes the desired input value for the function and performs the lookup with the correct parameters. By making the compile time array static in the lookup function we will be able to wrap this nicely all in one place.

We will also provide the lookup functions that will allow the user to pass around the array how they like. This is so that if they want to use the array in multiple function it wont instantiate a static instance of the array for each one.

Array Generation

This at first looks a bit complex, but is actually quite simple once it has been parsed out.
The function MakeLookUpTable constructs a sequence from 1->N of numbers to pass to the helper (std::make_index_sequence<N>{}). This code sequence is then used in a variadic fashion to construct the array in MakeLookUpTableHelper. 
In MakeLookUpTableHelper the sequence 'CounterSequence' is used to construct the array by changing its value from 1,2,3,4,5, to the value of the input for the function f(x) for that index.

So if we wanted 5 samples across the the range 0-1 it would change the sequence from 1,2,3,4,5 to 0, 0.2, 0.4... (we can switch to N+1 to be inclusive)

These values are then passed to our function to get the result for the function for that value and it is stored in the array.

//##########Table Generation
//Takes a given list of indices and computes the input value for the index across the range.
template<class Function, std::size_t... CounterSequence>
constexpr auto MakeLookUpTableHelper(Function f, const float _min, const float _stepSize, std::index_sequence<CounterSequence...>)
->std::array<typename std::result_of<Function(float)>::type, sizeof...(Indices)>
{
	return { { f(_min + (CounterSequence*_stepSize))... } };
}

//Passes through the correct index list object
template<int N, class Function>
constexpr auto MakeLookUpTable(Function f, const float _min, const float _stepSize)
->std::array<typename std::result_of<Function(std::size_t)>::type, N>
{
	return MakeLookUpTableHelper(f, _min, _stepSize, std::make_index_sequence<N>{});
}

Lookup Functions

The look up functions are relatively quite simple.

For the direct lookups 'TableInterp(float _x)' we simple statically instantiate our array and then using the values we have stored for the size and range of the array we can extract the correct value back from the table.

In the case of the interop function we are doing the same thing (usage note: calling both of these will create two identical tables in memory...) but we are taking two values from the table and using the percentage distance to the next value we blend linearly to the next value. This allows us to approximate values between those stored in the table.

For the versions of the function that take an 'arrayIn' we are providing a method for the user to generate a table or swap in a run-time editable table or any mix. This lets the programmer choose how they want to control the memory and is just nice to have.

#define TARGET_RANGE_MIN 0.f
#define TARGET_RANGE_MAX 6.28f
#define TARGET_RANGE_SAMPLES 1000
#define TARGET_RANGE_STEP_SIZE (float)(TARGET_RANGE_MAX-TARGET_RANGE_MIN) /(float)TARGET_RANGE_SAMPLES
#define TARGET_RANGE_GET_INDEX(x) ((float)((x + (0.000001f) - TARGET_RANGE_MIN))) / ((float)TARGET_RANGE_STEP_SIZE)
//##########Table Query
template <float(*approx)(float)>
constexpr float TableNoInterp(float _val)
{
	static constexpr auto lookupTable = MakeLookUpTable<TARGET_RANGE_SAMPLES>(approx, TARGET_RANGE_MIN, TARGET_RANGE_STEP_SIZE);

	return lookupTable[TARGET_RANGE_GET_INDEX(_val)];
}

template <typename T>
constexpr float TableNoInterp(float _val, T& arrayIn)
{
	return arrayIn[TARGET_RANGE_GET_INDEX(_val)];
}

template <float(*approx)(float)>
constexpr float TableInterp(float _val)
{
	static constexpr auto lookupTable = MakeLookUpTable<TARGET_RANGE_SAMPLES>(approx, TARGET_RANGE_MIN, TARGET_RANGE_STEP_SIZE);

	float pos = TARGET_RANGE_GET_INDEX(_val);
	int lowIndex = (int)pos;
	int highIndex = lowIndex + 1;
	float val0 = lookupTable[lowIndex];
	float val1 = lookupTable[highIndex];

	float scale = pos - floor(pos);
	float res = ((1.0f - scale) * val0) + ((scale)* val1);

	return res;
}

template <typename T>
constexpr float TableInterp(float _val, T& arrayIn)
{
	float pos = TARGET_RANGE_GET_INDEX(_val);
	int lowIndex = (int)pos;
	int highIndex = lowIndex + 1;
	float val0 = arrayIn[lowIndex];
	float val1 = arrayIn[highIndex];

	float scale = pos - floor(pos);
	float res = ((1.0f - scale) * val0) + ((scale)* val1);

	return res;
}

Usage

The usage of this code is then very simple:

//### Normal Code
//The table to be compiled at runtime must be a constexpr
constexpr float TestFun(float _x)
{
	return (_x*_x) + _x;
}

int main()
{
	//Example of table generation.
	auto a = MakeLookUpTable<TARGET_RANGE_SAMPLES>(TestFun, TARGET_RANGE_MIN, TARGET_RANGE_STEP_SIZE);

	//Example of using the lookup functions
	float result			= TableInterp<TestFun>(0.5f);
	float resultNoInterp	= TableNoInterp<TestFun>(0.5f);

	resultNoInterp	= TableNoInterp<decltype(a)&>(0.5f, a);
	result			= TableInterp<decltype(a)&>(0.5f, a);

	std::cout << "Iterp Result = " << result << "\n";
	std::cout << "NoIterp Result = " << resultNoInterp << "\n";

	return 0;
}

Not so bad!
I hope this is useful to someone!

All the code together:

#include <array>
#include <iomanip>
#include <iostream>
#include <cmath>

#define TARGET_RANGE_MIN 0.f
#define TARGET_RANGE_MAX 6.28f
#define TARGET_RANGE_SAMPLES 1000
#define TARGET_RANGE_STEP_SIZE (float)(TARGET_RANGE_MAX-TARGET_RANGE_MIN) /(float)TARGET_RANGE_SAMPLES
#define TARGET_RANGE_GET_INDEX(x) ((float)((x + (0.000001f) - TARGET_RANGE_MIN))) / ((float)TARGET_RANGE_STEP_SIZE)

//##########Table Generation
//Takes a given list of indices and computes the input value for the index across the range.
template<class Function, std::size_t... CounterSequence>
constexpr auto MakeLookUpTableHelper(Function f, const float _min, const float _stepSize, std::index_sequence<CounterSequence...>)
->std::array<typename std::result_of<Function(float)>::type, sizeof...(CounterSequence)>
{
	return { { f(_min + (CounterSequence*_stepSize))... } };
}

//Passes through the correct index list object
template<int N, class Function>
constexpr auto MakeLookUpTable(Function f, const float _min, const float _stepSize)
->std::array<typename std::result_of<Function(std::size_t)>::type, N>
{
	return MakeLookUpTableHelper(f, _min, _stepSize, std::make_index_sequence<N>{});
}


//##########Table Query
template <float(*approx)(float)>
constexpr float TableNoInterp(float _val)
{
	static constexpr auto lookupTable = MakeLookUpTable<TARGET_RANGE_SAMPLES>(approx, TARGET_RANGE_MIN, TARGET_RANGE_STEP_SIZE);

	return lookupTable[TARGET_RANGE_GET_INDEX(_val)];
}

template <typename T>
constexpr float TableNoInterp(float _val, T& arrayIn)
{
	return arrayIn[TARGET_RANGE_GET_INDEX(_val)];
}

template <float(*approx)(float)>
constexpr float TableInterp(float _val)
{
	static constexpr auto lookupTable = MakeLookUpTable<TARGET_RANGE_SAMPLES>(approx, TARGET_RANGE_MIN, TARGET_RANGE_STEP_SIZE);

	float pos = TARGET_RANGE_GET_INDEX(_val);
	int lowIndex = (int)pos;
	int highIndex = lowIndex + 1;
	float val0 = lookupTable[lowIndex];
	float val1 = lookupTable[highIndex];

	float scale = pos - floor(pos);
	float res = ((1.0f - scale) * val0) + ((scale)* val1);

	return res;
}

template <typename T>
constexpr float TableInterp(float _val, T& arrayIn)
{
	float pos = TARGET_RANGE_GET_INDEX(_val);
	int lowIndex = (int)pos;
	int highIndex = lowIndex + 1;
	float val0 = arrayIn[lowIndex];
	float val1 = arrayIn[highIndex];

	float scale = pos - floor(pos);
	float res = ((1.0f - scale) * val0) + ((scale)* val1);

	return res;
}


//### Normal Code
//The table to be compiled at runtime must be a constexpr
constexpr float TestFun(float _x)
{
	return (_x*_x) + _x;
}

int main()
{
	//Example of table generation.
	auto a = MakeLookUpTable<TARGET_RANGE_SAMPLES>(TestFun, TARGET_RANGE_MIN, TARGET_RANGE_STEP_SIZE);

	//Example of using the lookup functions
	float result			= TableInterp<TestFun>(0.5f);
	float resultNoInterp	= TableNoInterp<TestFun>(0.5f);

	resultNoInterp	= TableNoInterp<decltype(a)&>(0.5f, a);
	result			= TableInterp<decltype(a)&>(0.5f, a);

	std::cout << "Iterp Result = " << result << "\n";
	std::cout << "NoIterp Result = " << resultNoInterp << "\n";

	return 0;
}