Nibble tabulation for the Sobol draw #105
Replies: 3 comments 2 replies
|
Interesting idea! It doesn't seem like it would scale well to 32-bits though whereas the shift-chain approach is just a few extra operations thanks to the log-scaling of the diagonal factoring on dimensions 1 and 3. It might also be worth checking the cost when only generating a 1,2 or 3 dimensions instead of 4? Its less clear to me how much code can be pruned in those cases since you are taking advantage of the fact that a Even with the small table here, cache misses could be a concern when inside a real application that puts other pressure on the cache. I'm not sure of a good way to measure this though as profiling inside a full renderer likely looses the sampling cost in the noise. Also curious to see results on GPU. It would interesting to look at the raw SASS instructions on nvidia because I am not sure how 16-bit and 64-bit operations are handled (and PTX is not what actually gets run on the device). But assuming the benchmarks hold up, I'm for whatever is faster. As the old saying goes "profiling gives you a leg up over experts that don't need to" ;) Given how many device types are out in the wild, it might be good to let the end-user pick a method at compile time. Something like: #ifndef OQMC_SOBOL_EVAL_METHOD
// user did not pick a method, default to what we think is best
#ifdef __CUDA_ARCH__
#define OQMC_SOBOL_EVAL_METHOD 1 // shift-masks for cuda device code
// ... other heuristics here as we find them ...
#else
#define OQMC_SOBOL_EVAL_METHOD 0 // nibble-table for CPU code
#endif
#endifThen all anyone needs to compare methods is redefine that macro themselves before including the OpenQMC header and benchmark in their specific situation. |
|
I gave the method a try and managed to reproduce similar results. I wired the OpenQMC samplers into a pbrt-v4 fork for my blue noise work: https://github.com/wantonsushi/pbrt-v4-openqmc. I thought it'd be useful here to try measuring the performance in "real world use". From the fork, I timed 5 scenes, 5x each, and nibble was faster in 4 of 5, 1.01x to 1.02x. Exhaustive was faster on only 2 of 5 scenes, the rest had at least one rep go the other way so I'd call it noise there. Sobolbn vs pmjbn, pmjbn was faster in all 5, 1.02x to 1.03x. Nibble-sobol vs pmj, pmj was still faster in all 5 around 1.00x to 1.01x, which is not what I expected given the cache argument. Also, in the pbrt fork, I wired up the samplers in a way so that, even though pbrt only asks for 1D and 2D samples, we draw all four, cache the unused dimensions, and serve the following requests from the cached results. If done in this style, pruning for 1/2/3 dimension samples as @fpsunflower mentioned doesn't really become a concern. But OpenQMC's Regarding GPU, I think the serialization is a property of constant memory rather than of the table (i.e., a warp reading 32 different nibbles out of Also extra note: it seems Will try to open a PR for SZ in the coming days. |
|
That's a great quote @fpsunflower! Haven’t heard it before. Will be using that. I'd agree that it is very hard to reason about the performance of this without benchmarking it. Thank you @wantonsushi for doing all the legwork and testing that out with PBRT 🙏 If you do get those results back with taking the samples dynamically, it would be interesting to see the data. I could also try putting together a version of OpenQMC to A/B test this on a heavy production shot at Framestore. That would be another data point for CPU. Taking a step back. I agree that we are definitely squeezing the very last optimization out of the sobol function. The reverseAndShuffle() and scrambleAndReverse() are now where all the real cost is. It would be interesting to see if these could be optimized. Sounds like Chris has some ideas. PMJ sampler is still a lot faster, but only because it doesn't perform a scrambleAndReverse() on output of each dimension, and instead does a basic XOR to scramble (random-digit scrambling, Kollig and Keller). Both have the improved integration rate from Owen scrambling (random error cancellation) but PMJ gets this randomization from its initial stochastic construction, and so can rely on a cheap XOR at runtime. Although it is this same stochastic construction that makes it the approach here with the nibble tables infeasible, and the full table lookup necessary, which I suspect is not great for cache performance. The quality is also inferior, as the basic XOR still adds structure that is visible in the spectral transform. And soon it won't have the SZ features. All that to say, I'd really like to sunset PMJ if we can make the Sobol always a better tradeoff. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Hello :) I've been thinking about how we evaluate our generator matrices, and I want to share an idea on how we might optimise the sobol draw using tabulation. I've often wondered about the tradeoff between sobol and pmj, the pros vs cons of tabulating the sequence, and how this is likely problematic when considering cache-misses.
Tabulation
All ways of returning a sequence based on a generator matrix are a trade-off between memory and work per query. Laid out on one axis:
All three compute the same thing. They differ only in how much of the work is done ahead of time versus per draw. In this discussion I'd like to propose that we investigate the middle option.
Current implementations
OpenQMC already implements the extremes.
owen.hcomputes Sobol values live. The scalar path is the shift chains (diagonal factoring, Ahmed 2024), and the SIMD paths are column accumulation with AVX, SSE and ARM variants, plus a separate left-shift chain for CUDA. Five implementations of one 16x16 bit matrix multiply.pmj.hsits at the far right:stochasticPmjInitfills asamples[65536][4]table, 1 MB, and every draw is a lookup throughlookup.h.Idea
The reason why this middle option typically isn't feasible is that it only exists for linear transforms. Our Sobol draw is multiplication by a matrix over GF(2), which means it distributes over xor:
Split the input index into its four nibbles and the whole transform becomes four tiny table lookups xored together. Each nibble position has only 16 possible values, so each table has 16 entries. Better still, all four output dimensions can be packed into one 64-bit entry, since xor acts independently on each 16-bit lane. And since
reverseBits16is also linear, the bit reversal folds into the tables for free.Total: 4 tables x 16 entries x 8 bytes = 512 bytes, generated from the matrices at build time (the same
matrices.cppcli tool that emits the shift chains today could emit these instead).This is a known trick with many names: the Method of Four Russians in GF(2) linear algebra, slicing-by-N in the CRC world, tabulation hashing elsewhere.
Why is this optimal?
The exhaustive table's problem is the memory footprint. 1 MB is 16,384 cache lines, and a random index lands on a different one every draw, so in a real render almost every access misses L1 and competes with actual work for L2. The nibble tables are 8 cache lines total. Every draw touches the same 8 lines, so after warmup they are pinned in L1 permanently and every load is a 4-5 cycle hit (approximately). The randomness of the index only selects within a line, never which lines are resident.
And unlike the shift chains, there's no serial dependency: the four loads issue in parallel and the xor reduction is two deep. The chain's ~26 dependent rounds become ~13 cycles flat, for all four dimensions at once.
Sketch
That could replace the scalar chains and all three SIMD paths, and the standalone bit reversal in the draw.
Initial tests
I passed this onto Claude and asked it to test the idea against the current
owen.hscalar path exhaustively (all 65536 indices x 4 dimensions). It benchmarked 100M serially dependent draws, one index at a time, all four dimensions per draw. All three methods produced identical checksums. Xeon 2.8 GHz, gcc -O2:*note the exhaustive number is likely unrealistic and in reality much worse performance. The benchmark touches nothing but the table, so much of the 1 MB stays cached. In production that isn't the case and we would expect it to degrade; while the 512 B used to store the nibble tables would not.
What about pmj?
Should both samplers be tabulated using this type of table lookup? The key here is that the Sobol table compresses because the transform is linear; the pmj table cannot compress because it's random by construction. The pmj values contain genuine entropy from the PCG stream, so the 1 MB footprint is the smallest form I've managed to find. And even though pmj shows good results in benchmarks, I've always considered its poor cache efficiency to likely mean that in real world use the performance is unfavourable.
The other advantage of pmj was licensing, although given the licensing granted for the OpenQMC project, this is likely a moot point. It could be that a switch to this approach would make the pmj sampler redundant.
Open questions
If there's interest I'll put together a PR with the table generation added to the matrices cli tool, the new draw path behind the existing arch defines, and the verification test.
@wantonsushi @fpsunflower I'd be interested in getting your thoughts. It was your recent work @wantonsushi that got me thinking about this.
FYI @mr-matthew-jones this stuff is up your street, you might also find it interesting.
All reactions