Thursday, August 13, 2026

Introducing Photometric ConditionsIntroducing Photometric Conditions A "1000 lm" LED lamp and a "1000 lm" low-pressure sodium lamp do not look equally bright at night. The number on both boxes is a photopic value: it weights the lamp's spectrum with $V(\lambda)$, the spectral sensitivity of the eye's cone cells, which drive vision in daylight. At night, rod cells take over, the sensitivity curve shifts towards blue ($V'(\lambda)$), and the same two lamps deliver roughly 2000 and 250 scotopic lumens respectively. Both figures are luminous fluxes. Both are expressed in lumens. And a program that adds or compares them computes a result that is wrong by up to a factor of eight: cpp auto led = 1000. * lm; // photopic value from the datasheet auto sodium = 250. * lm; // scotopic value from a night-visibility model auto total = led + sodium; // compiles everywhere, means nothing To the best of our knowledge, no units library catches this today, because every one of them (including mp-units until now) models a lumen as a lumen. This post introduces photometric conditions : a way to keep photopic, scotopic, mesopic, and custom quantities in separate quantity hierarchies, so that the mistake above breaks at compile time, while everything the physics does allow keeps working.๐Ÿ“mp-units

If this page is useful, please consider donating a coffee

Wednesday, August 12, 2026

Digitizing super 8 film yourselfIn our previous post we looked at fixing a super 8 film projector. While watching filme with a real projector has its own charm, it is inconvenient to say the least. First of all you make the entire room properly dark or you can't see anything. This is regardless of the fact that the projector bulb is consuming 100 watts of power to show the image. Even if you manage not to burn the film merely running it through the projector causes wear, scratches and tearing. While film typically ages very well, eventually it will turn into magenta goop or gets eaten by vinegar syndrome . Thus you'd really want to convert all these films into high quality digital files. There are several companies that offer this service. If you only have a few rolls, using those is the smart thing to do. I, on the other hand, have so much material that using a commercial service would cost thousands (possibly tens of thousands) of euros. Fortunately, this is a fairly common problem and there are dozens of existing projects on the Internet to be inspired by. The main technical problem with super 8 film is that it is very small. A sequence of 10 super 8 images is approximately as long as a matchstick. The fact that projectors can display 18 frames per second with sub millimeter registration is an astounding achievement of mechanical engineering. How do they do that? Very difficultly. Many of the DIY solutions start by taking an existing projector and modifying it to run slower. Then you remove the projection lens and aim a digital camera with a macro lens at the gate. This yields incredible results quality-wise but requires a fairly expensive macro lens and typically the modification on the projector is destructive. So that's out. Some more searching eventually lead me to this Github project . The basic idea is simple. Instead of using a projector or trying to replicate a film transport (which proper tension and all that) instead rely on the basic stiffness on film and drive it directly with stepper motor. Film is not aligned mechanically but instead by detecting the sprocket hole with some straightforward machine vision code. Time to fire up the ol' 3D printer and order components. This is what the end result looks like after assembly The thing at the top left that looks like a space cannon prop from a scifi movie is actually a microscope lens. Not only can it do > 1x optical magnification, it does it at a cost of about 25 euros. The downside is noticeable chromatic aberration. The small flat thing on the other end is the Raspberry Pi HQ camera module that can do 4k at 12 bits per channel. The whole thing is run via a single Raspberry Pi 3 with a stepper motor hat. The board at the bottom is used to distribute 12V DC power to the lamp and motors. Before going further, let's just spend some time appreciating just how awesome colors look in this film. Props to the chemical engineers at Kodak. And remember, the original image is about one third of the size of your smallest fingernail. The Github repo says that you probably need to adapt the code to your setup. I basically ended up rewriting all of it from scratch. In the process I learned that OpenCV has its own GUI toolkit which is both simple (one could even say simplistic) and perfect for this use case. The first attempt took nine hours to process one 3.5 minute reel of film. Then I realized that trying to do 4k on material that physically maxes out at approximately 2k with the processing power of a potato is not a recipe for success. Halving the capture resolution and a few other optimizations brought the runtime down to about one hour per reel. With this, some more custom software for image processing and stabilization coupled with FFmpeg scripts one can start to go through the archive of films. Doing so raises a fair bit of questions. For example: Is that a 3 year old child driving a jury-rigged go-kart on a frozen lake on his own without even wearing a helmet? Yes it is. A bit later an adult drives the car but he is too heavy so the ice cracks under him. No one seems particularly concerned. This may seem strange to us but you have to understand that this was the very early 70s. The concept of safety had not been invented yet.๐Ÿ“Nibble Stew

Tuesday, August 11, 2026

Monday, August 10, 2026

The fastest double-to-string algorithm youโ€™ve never heard ofvitaut.net https://vitaut.net/posts/2026/yy-dtoa/ - ลปmij , the binary-to-decimal conversion library I wrote about a few posts back , started as an optimized port of Schubfach. Later I switched its core to a different algorithm, defined in yy_double.c from yyjson by ibireme . It has no paper, no name beyond the file it lives in (I'll refer to it as yy), and almost no public profile outside the JSON performance crowd. It also happens to be one of the fastest dtoa implementations. This post is a tour of yy through a small visualization, with a close look at one boundary case. Where yy fits in yy is in the Schubfach family. The shared idea, which I covered in an earlier post , is to find the shortest decimal $\sigma \cdot 10^{e_{10}}$ that round-trips back to a binary float $v$ by intersecting $v$'s rounding interval with decimal grids of various spacings, and picking the coarsest grid that still has a tick in the interval. yy's trick is doing this very cheaply. The whole algorithm runs on fixed-width integer arithmetic and uses only one multiplication by a precomputed power of 10, where classic Schubfach needs two or three. Four candidates For each binary float $v = c \cdot 2^{e_2}$, yy picks a decimal exponent $e_{10}$ via a fixed-point approximation of $\log_{10} 2$, then re-expresses $v$ at the decimal scale as $$ \bar v \approx v \cdot 10^{-e_{10}} $$ using a precomputed power-of-10 table $p_{10}$, a fixed-point value with $p_{10} \cdot 2^{e_p} \approx 10^{-e_{10}}$ for some binary exponent $e_p$. $\bar v$ then sits between four candidate decimal values: $d_1 = \lfloor \bar v \rfloor$ and $u_1 = d_1 + 1$, the integers immediately below and above $\bar v$. $d_0 = 10 \cdot \lfloor \bar v / 10 \rfloor$ and $u_0 = d_0 + 10$, the multiples of 10 below and above. Outputting $d_0$ or $u_0$ gives a decimal one digit shorter than $d_1$ or $u_1$, because the trailing zero folds into the exponent. Like classic Schubfach, yy prefers $d_0$ or $u_0$ when they round-trip and falls back to $d_1$ or $u_1$ otherwise. Three predicates do the work, evaluated against $\bar v$ and a half-ulp band $\delta$ around it. The first checks whether $\bar v - \delta$ reaches $d_0$: $$ \delta \ge \bar v_{10} + \varepsilon_c $$ with $\bar v_{10} = \bar v \bmod 10$. The second checks whether $\bar v + \delta$ reaches $u_0$: $$ \bar v_{10} + \delta \ge 10 + \eta_c $$ The third decides between the longer candidates $d_1$ and $u_1$ by checking whether $\bar v$ is past the midpoint: $$ \bar v \bmod 1 \ge \tfrac12 + \varepsilon_u $$ The biases $\varepsilon_c$, $\eta_c = 2\varepsilon_c - 1$, $\varepsilon_u$ are small parity adjustments ($0$ or $\pm 1$) that implement round-half-to-even at exact ties. The first predicate that fires picks the candidate; if none does, the answer is $d_1$. The $\varepsilon$ and $\eta$ terms are my bookkeeping, not yy's: they let me write the three predicates as simple, uniform formulas. The code doesn't adjust the thresholds at all. It runs the plain comparison and, only when it lands exactly on a tie, branches off and rounds to even by testing a low bit of the significand. This is where the one-multiplication claim from earlier comes in: $\delta$ doesn't need its own multiplication. The half-ulp of $v$ is $\tfrac12 \cdot \mathrm{ulp}(v) = 2^{e_2 - 1}$, which in $\bar v$'s scale gives $$ \delta = 2^{e_2 - 1} \cdot p_{10} \cdot 2^{e_p} = p_{10} \cdot 2^{e_2 + e_p - 1} $$ so $\delta$ is just $p_{10}$ shifted by an integer (no rounding, no second multiplication). The bounds of the rounding interval are then $\bar v \pm \delta$ via add and subtract. Schubfach instead multiplies $v$, $v_l$, and $v_r$ by $p_{10}$ separately, which is two extra 192-bit multiplications. The actual algorithm is a bit more involved than this, with extra paths for irregular intervals, subnormals, and the digit-emission loop. The sketch above is the core idea the rest hangs off of, and all you need to follow the visualization. A step-by-step at E4M3 scale E4M3 is an 8-bit floating-point format (1 sign bit, 4 exponent bits, 3 significand bits, bias 7) used for low-precision AI inference on recent GPUs. With only 256 encodings it fits on one page, which makes it a good target for visualizing things you'd otherwise have to take on faith at f64 scale. I went into more detail on the format in the previous post . The walk-through is one HTML page, e4m3-yy.html ; open it in a new tab. The page walks a value through yy's pipeline top to bottom. The main grid at the top plots every E4M3 value, with the rounding interval of the selected value highlighted: The middle panel is yy itself, step by step: $e_{10}$, the $p_{10}$ table with the active row highlighted, the scaling chain $\bar v = c \cdot 2^{e_2} \cdot p_{10} \cdot 2^{e_p}$, and the four candidates derived from $\bar v$: The bottom of that panel is the predicate table. Each row shows โœ“ when the predicate fires, โœ— when it evaluates to false, and is greyed out when an earlier row already fired, alongside the actual comparison at 8-bit working-word precision. Below it, a small diagram puts the four candidates on a number line with $\bar v$ in the middle and a band of width $\pm \delta$: Hover any underlined hex literal for the exact infinite-precision tail; hover a ? over a comparison for a note on why that cell is on a tipping point. A boundary case that looks like a bug Set the encoding to 116 in the explorer, or work out $v = 12 \cdot 2^{4} = 192$ by hand. The bits are 0 1110 100 , so $c = 12$, $e_2 = 4$, and yy picks $e_{10} = \lfloor 4 \log_{10} 2 \rfloor = 1$, so $\bar v = 192 \cdot 10^{-1} = 19.2$ and the fine-grid fallback is $d_1 = \lfloor \bar v \rfloor = 19$, printed as 19e1 . The shorter grid is multiples of $10^{2} = 100$, with $d_0 = 100$ and $u_0 = 200$. If $v$'s rounding interval reaches $u_0 = 200$, yy can emit the shorter 2e2 instead of the longer 19e1 . The decision comes down to the second predicate, $\bar v_{10} + \delta \ge 10$. yy evaluates it in a Q4.4 fixed-point working word (4 integer bits, 4 fractional bits, packed in 8 bits), and the left-hand side comes out to 0x9.F ($= 9.9375$), one LSB short of $10$. The predicate is false, so yy emits 19e1 , a digit longer than it needs to be. That looks wrong, and the reason it isn't comes down to one term. The comparison yy actually runs is $$ \bar v_{10} + \delta \ge 10 + \eta_c $$ with $\eta_c = -1$ LSB here. Lowering the threshold by a unit in the last place looks like an off-by-one, but it is correcting for one. In exact arithmetic the interval reaches $u_0$ exactly: $$ \bar v + \delta = 19.2 + \tfrac12 \cdot 2^{4} \cdot 10^{-1} = 19.2 + 0.8 = 20.0, $$ so $u_0 = 200 = 10 \cdot 10^{1}$ sits at the edge of the interval. yy has no exact arithmetic. Its $p_{10}$ table is stored wider than the Q4.4 working word, 16 bits at this scale, and the $10^{-1}$ row floors to 0xCCCC , dropping a 0.8 LSB tail, the largest truncation any row carries. Multiplying by that rounded-down $p_{10}$ and packing the product back into Q4.4 is what turns a true 10.0 into 0x9.F , and $\eta_c$ subtracts the same LSB from the threshold to match: $$ \bar v_{10} + \delta \ge 10 + \eta_c \iff \mathtt{0x9.F} \ge \mathtt{0xA.0 - 0x0.1} $$ Both sides are 0x9.F . The predicate ties, fires, and yy emits 2e2 . The visualization flags this cell with a ? because it's bias-sensitive: flip $\eta_c$ from $-1$ back to $0$ and the verdict flips, and yy emits 19e1 . Both decimals round-trip: 200 parses to the halfway point between $192$ and $208$, and round-half-to-even picks $192$ because $c = 12$ is even. Try it The explorer is a single HTML file with no build step ( source ). Click through the encodings to see where $p_{10}$ truncation and round-half-to-even actually change yy's output. Algorithms that live in JSON libraries don't get the citation count of the ones that ship with papers. yy is worth knowing about anyway. Fun fact The smallest normal double , $2^{-1022}$, is regular: its predecessor sits exactly one ULP below. Schubfach-family algorithms (yy, Dragonbox, ลปmij) flag the "irregular" case by checking whether the significand has all fraction bits zero, which is exactly the powers of two, this one included. Harmless, but as far as I know nobody special-cases it. - https://vitaut.net/posts/2026/yy-dtoa/ -๐Ÿ“vitaut.net
From Prototype to Production: The EngFlow Build Analytics JourneyFrom Prototype to Production: The EngFlow Build Analytics Journey Every build tells a story. Which targets took the longest? Where did cache misses cost you minutes? Who triggered the invocation, and what source changes drove it? At EngFlow, we believe that surfacing these answers โ€” automatically and at scale โ€” transforms build optimization from reactive firefighting into proactive engineering. Today, we're sharing how our build analytics platform evolved from early prototypes to a production-grade system providing tangible value to our customers.๐Ÿ“EngFlow Blog

Sunday, August 9, 2026

Saturday, August 8, 2026

Understanding std::counting_semaphore and std::binary_semaphore from C++20This article explains the two semaphore types introduced in C++20: std::counting_semaphore and std::binary_semaphore . Weโ€™ll first use a counting semaphore to limit how many threads can operate at the same time. Then weโ€™ll use a binary semaphore to send a signal between threads. Weโ€™ll also look at timed waiting, a small RAII helper, and a few more details. Note: The synchronization features discussed here are available in C++20. The examples use C++23 std::println for cleaner output. Letโ€™s go. Basics A mutex works well when only one thread should enter a protected section at a time. But sometimes that limit is too strict. Imagine an application with three database connections. Running only one database operation at a time would waste two of them. On the other hand, allowing any number of threads to start an operation could overload the database. What we need is a limit: three threads may continue, while the others wait. There is another common case. One thread prepares some data, and another thread waits until the data is ready. Semaphores work well for both problems. A lot of multi-threading libraries have semaphores, but itโ€™s pretty cool that the C++20 Standard Library now includes them right out of the box. API A counting semaphore is declared as: std::counting_semaphore The main operations are: Function Description counting_semaphore(desired) Creates a semaphore with the counter set to desired acquire() Decreases the counter, or waits if it is zero release(update) Increases the counter by update ; the default is 1 try_acquire() Tries once without waiting try_acquire_for() Waits for a limited duration try_acquire_until() Waits until a given time point max() Returns the largest counter value supported by the implementation std::binary_semaphore is an alias for: std::counting_semaphore It is useful when one outstanding signal is enough. Letโ€™s start with the counting version. Limiting concurrency with std::counting_semaphore Suppose we have eight jobs, but only three should perform an expensive operation at the same time: #include #include #include #include // #include #include int main () { constexpr int workerCount = 8 ; constexpr int slotCount = 3 ; std :: counting_semaphore slotCount > slots { slotCount }; std :: mutex outputMutex ; int active = 0 ; auto worker = [ & ]( int id ) { slots . acquire (); { std :: lock_guard lock ( outputMutex ); ++ active ; std :: println ( "worker {} entered; active={}" , id , active ); } std :: this_thread :: sleep_for ( std :: chrono :: milliseconds ( 250 ) ); { std :: lock_guard lock ( outputMutex ); -- active ; std :: println ( "worker {} leaving; active={}" , id , active ); } slots . release (); }; std :: vector std :: jthread > threads ; threads . reserve ( workerCount ); for ( int i = 0 ; i workerCount ; ++ i ) threads . emplace_back ( worker , i ); } Run @Compiler Explorer The semaphore starts with three available slots. The first three workers call acquire() and continue. The internal counter then reaches zero. When a fourth worker calls acquire() , it has to wait. Once one of the active workers finishes and calls release() , a slot becomes available again. One waiting worker can then continue. One possible part of the output is: worker 0 entered; active=1 worker 2 entered; active=2 worker 1 entered; active=3 worker 2 leaving; active=2 worker 4 entered; active=3 The order may change between runs, but active should never be greater than three. The mutex in this example does not limit the number of workers. It only protects the diagnostic counter and keeps the output readable. The semaphore is the part that enforces the three-worker limit. Logging can slightly change thread scheduling, but it does not change the rule enforced by the semaphore. The mutex is held only for a short print operation, not for the simulated work. This is also why the code is not a traditional critical section. Three workers are allowed to run the operation together. We are limiting concurrency, not forcing complete mutual exclusion. There is one more detail: the semaphore knows how many slots are available, but it does not know what those slots represent. If they stood for three real database connections, we would still need a separate container holding those connections. Returning a slot with RAII The example above calls release() by hand. That works, but it is easy to make a mistake. An early return or an exception between acquire() and release() could prevent the slot from being returned. Other threads might then wait forever, even though the underlying work has already stopped. This is similar to calling lock() and forgetting to call unlock() . A small RAII guard can help: template std :: ptrdiff_t LeastMaxValue > class SemaphoreGuard { public : explicit SemaphoreGuard ( std :: counting_semaphore LeastMaxValue >& sem ) : sem_ ( sem ) { sem_ . acquire (); } ~ SemaphoreGuard () { sem_ . release (); } SemaphoreGuard ( const SemaphoreGuard & ) = delete ; SemaphoreGuard & operator = ( const SemaphoreGuard & ) = delete ; private : std :: counting_semaphore LeastMaxValue >& sem_ ; }; It can be used at the start of a scope: SemaphoreGuard guard { slots }; The constructor waits for a slot, and the destructor returns it. This works well when the slot represents reusable capacity: a database connection, an upload slot, a buffer, or access to limited hardware. On the other hand, it does not fit every signaling example. A one-way signal is often meant to be consumed rather than returned. Signaling with std::binary_semaphore A mutex has ownership. The thread that locks it must also unlock it. A semaphore does not have this rule. One thread can wait in acquire() , while another thread calls release() . That makes std::binary_semaphore useful for simple communication between threads. In the next example, the worker waits until the main thread tells it to start. It calculates a result and then sends a signal back: #include #include #include int main () { std :: binary_semaphore startSignal { 0 }; std :: binary_semaphore doneSignal { 0 }; int result = 0 ; std :: jthread worker ([ & ] { startSignal . acquire (); result = 42 ; doneSignal . release (); }); std :: println ( "[main] starting worker" ); startSignal . release (); doneSignal . acquire (); std :: println ( "[main] result = {}" , result ); } Run @Compiler Explorer Both semaphore counters start at zero. The worker stops at startSignal.acquire() . The main thread calls startSignal.release() , which sends the start signal and lets the worker continue. After writing result , the worker calls doneSignal.release() . The main thread waits for this signal before reading the result. The semaphore also handles memory synchronization. The main thread sees the writes made by the worker before the release() that allowed doneSignal.acquire() to finish. That means the main thread can safely read result after doneSignal.acquire() returns. The signal is not returned afterward, and that is fine. It represented a one-time notification rather than a reusable slot. Waiting with a timeout acquire() can wait forever. Some code cannot accept that. For example, an application may want to report an error, retry the operation, or switch to a fallback path after waiting for too long. try_acquire_for() waits for a relative duration: #include #include #include #include int main () { std :: counting_semaphore 1 > sem { 0 }; std :: jthread notifier ([ & ] { std :: this_thread :: sleep_for ( std :: chrono :: milliseconds ( 200 ) ); sem . release (); }); if ( sem . try_acquire_for ( std :: chrono :: milliseconds ( 100 ))) { std :: println ( "First wait succeeded" ); } else { std :: println ( "First wait timed out" ); } if ( sem . try_acquire_for ( std :: chrono :: milliseconds ( 300 ))) { std :: println ( "Second wait succeeded" ); } else { std :: println ( "Second wait timed out" ); } } See @Compiler Explorer A typical result is: First wait timed out Second wait succeeded The first call stops waiting before the notifier calls release() . It does not decrease the counter. Later, the notifier increases the counter, and the second call succeeds. A timeout is not an exact scheduling deadline. Waiting for 100 milliseconds means the function will not report a timeout before that duration has passed. The operating system may resume the thread a little later. Semaphore, mutex, or condition variable? These tools solve different problems. Use a mutex when one thread should own a protected section. The thread that locks the mutex must unlock it, and std::lock_guard makes that ownership easy to manage. Use a counting semaphore when up to N operations may run at the same time. Use a binary semaphore when one thread needs to send a simple signal to another thread. A condition variable is a better fit when threads wait for shared state to satisfy a condition. It normally works together with a mutex and a predicate. Tool Common use std::mutex One thread owns a protected section std::counting_semaphore Up to N operations run together std::binary_semaphore One thread signals another std::condition_variable Threads wait for a shared-state condition A semaphore also remembers an unused counter value. If release() runs before another thread starts waiting, a later acquire() can still use that value. With a condition variable, code normally checks shared state rather than relying on a stored notification. Details worth knowing LeastMaxValue is a lower bound In: std :: counting_semaphore 3 > the value 3 means that the implementation must support a counter of at least three. Its real maximum may be larger. max() returns the value supported by the implementation. Application code should normally use the number it actually needs and not depend on any extra range offered by one standard library. Do not increase the counter past max() release(update) requires that the new counter value does not exceed max() . This matters with std::binary_semaphore . It is not an event flag that can be set many times without being cleared. Calling release() twice without an acquire() between those calls may break the functionโ€™s precondition. try_acquire() may fail spuriously try_acquire() performs one non-blocking attempt. It is allowed to return false even when the counter is greater than zero. Use acquire() when the thread must wait. Use try_acquire_for() or try_acquire_until() when it should wait only for a limited time. Waiting order is not guaranteed If several threads are waiting, the standard does not say that the oldest waiter must run first. Code that needs strict first-in, first-out behavior requires an extra queue or scheduling layer. Watch the lifetime Do not destroy a semaphore while another thread may still be using it. All calls to acquire() , release() , and the timed functions must finish before the semaphoreโ€™s lifetime ends. Summary std::counting_semaphore is useful when several threads may run an operation together, but their number must stay below a fixed limit. std::binary_semaphore works well for simple signals between threads. Use a mutex for normal exclusive access and a condition variable when threads wait for a shared-state condition. The main thing is to be clear about what the counter means. It may represent free worker slots, connections, buffers, or a signal waiting to be received. References std::counting_semaphore and std::binary_semaphore - cppreference C++ working draft: semaphores Implementing C++20 semaphores - Red Hat Developer C++ Concurrency in Action, Second Edition by Anthony Williams Concurrency with Modern C++ by Rainer Grimm Back to you Do you use semaphores mainly for limiting concurrent work or for signaling? Have you tried C++20 or some third-party libraries?๐Ÿ“C++ Stories

Friday, August 7, 2026