<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
	<title>Posts</title>
	<subtitle>Posts by Kotov Semyon</subtitle>
	<link rel="self" type="application/atom+xml" href="https://kotow.dev/posts/feed.xml"/>
  <link rel="alternate" type="text/html" href="https://kotow.dev/posts/"/>
  
	<updated>2026-05-20T00:00:00+00:00</updated>
	
	<id>https://kotow.dev/posts/feed.xml</id>
	<entry xml:lang="en">
		<title>Parallel formula calculation in the spreadsheet</title>
		<published>2026-05-20T00:00:00+00:00</published>
		<updated>2026-05-20T00:00:00+00:00</updated>
		<link rel="alternate" type="text/html" href="https://kotow.dev/posts/parallel-formula-calculation-in-the-spreadsheet/"/>
		<id>https://kotow.dev/posts/parallel-formula-calculation-in-the-spreadsheet/</id>
    
		<content type="html" xml:base="https://kotow.dev/posts/parallel-formula-calculation-in-the-spreadsheet/">&lt;figure&gt;
    &lt;img
        src=&quot;&amp;#x2F;img&amp;#x2F;tonic-showcase-dark.png&quot;
         alt=&quot;Screenshot of Tonic&quot;
        
        
    &gt;
    
    &lt;figcaption&gt;&lt;p&gt;Screenshot of Tonic&lt;&#x2F;p&gt;
&lt;&#x2F;figcaption&gt;
    
&lt;&#x2F;figure&gt;
&lt;p&gt;I&#x27;m about to graduate, and this semester I was working on my thesis. I wanted it to be technically challenging and to have some production potential, so finding a topic was not easy. After thinking about it for a while, I decided to write a new spreadsheet application.&lt;&#x2F;p&gt;
&lt;p&gt;This idea came from a friend, who uses spreadsheets more than I do and suggested that existing solutions can still be improved in many ways. People across different professions, use these tools daily because they bring a lot of productivity. Then also my friend uses it, for anything from calculating a GPA to grading his students, which made me wonder why I was still reluctant to use spreadsheets myself.&lt;&#x2F;p&gt;
&lt;p&gt;I think I do not use spreadsheets as much partly because I do not have the habit, and partly because many existing solutions do not fit well with my workflow. I run Linux and prefer open-source where possible. It is interesting to note that you do not need a spreadsheet in the same way you need Photoshop if you want to edit an image. Anything done in a spreadsheet can, in theory, be done manually too. And so, because of all the friction, I often end up using something else. When my friend used a spreadsheet to calculate the GPA, I used a simple calculator to get the same result. But when I needed to calculate GPA again for my new grades, I just asked my friend for his spreadsheet instead of bothering to type in the formula again.&lt;&#x2F;p&gt;
&lt;p&gt;The result is &lt;a rel=&quot;nofollow noreferrer external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;andude10&#x2F;tonic&quot;&gt;tonic&lt;&#x2F;a&gt;, which I&#x27;m still working on in my free time. It uses Tauri, which lets me write performance-sensitive parts in Rust and use Svelte for the UI.&lt;&#x2F;p&gt;
&lt;p&gt;It turns out that creating a spreadsheet is also a fairly complex technical task, I learned a lot while developing it. There are a lot of interconnected parts under the hood. I ended up writing a formula parser (using &lt;a rel=&quot;nofollow noreferrer external&quot; href=&quot;https:&#x2F;&#x2F;docs.rs&#x2F;chumsky&#x2F;latest&#x2F;chumsky&#x2F;&quot;&gt;chumsky&lt;&#x2F;a&gt;), a cache-efficient sparse grid representation, and a parallel formula calculation engine. A fast, pretty frontend is also almost as complex as the backend. And there is still a lot of work to do before &lt;code&gt;tonic&lt;&#x2F;code&gt; becomes production-ready. So I thought my experience might be useful for someone who wants to build a better tool, or for someone who is just curious.&lt;&#x2F;p&gt;
&lt;p&gt;In this blog post, I want to talk about the algorithm that I used to calculate formulas in a spreadsheet. It&#x27;s important to note that I&#x27;m still learning. If you find any mistakes, or just have some general advice, please &lt;a href=&quot;https:&#x2F;&#x2F;kotow.dev&#x2F;contact&#x2F;&quot;&gt;contact me&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h1 id=&quot;spreadsheet-calculation-algorithm&quot;&gt;Spreadsheet calculation algorithm&lt;a class=&quot;zola-anchor&quot; href=&quot;#spreadsheet-calculation-algorithm&quot; aria-label=&quot;Anchor link for: spreadsheet-calculation-algorithm&quot; style=&quot;visibility: hidden;&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h1&gt;
&lt;p&gt;A spreadsheet can contain formulas: mathematical expressions that reference values of other cells. When a user changes a cell, all formulas that reference it need to be recomputed. If their results change, the next formulas need to be recomputed too, and this chain continues until the whole spreadsheet is updated. This is pretty much the most important feature of the spreadsheet.&lt;&#x2F;p&gt;
&lt;div class=&quot;calc-demo sp-calc-demo&quot;&gt;
    &lt;div class=&quot;calc-demo__controls sp-calc-demo__controls&quot;&gt;
        &lt;label
            &gt;&lt;input type=&quot;checkbox&quot; name=&quot;calc-demo&quot; value=&quot;a30&quot; &#x2F;&gt;A1 ←
            67&lt;&#x2F;label
        &gt;&lt;label
            &gt;&lt;input type=&quot;checkbox&quot; name=&quot;calc-demo&quot; value=&quot;b2&quot; &#x2F;&gt;B2 ←
            =A2*1000&lt;&#x2F;label
        &gt;
    &lt;&#x2F;div&gt;
    &lt;div class=&quot;calc-demo__sheet sp-calc-demo__sheet&quot;&gt;
        &lt;table aria-label=&quot;Spreadsheet recalculation example&quot;&gt;
            &lt;colgroup&gt;
                &lt;col &#x2F;&gt;
                &lt;col &#x2F;&gt;
                &lt;col &#x2F;&gt;
                &lt;col &#x2F;&gt;
            &lt;&#x2F;colgroup&gt;
            &lt;thead&gt;
                &lt;tr&gt;
                    &lt;th&gt;&lt;&#x2F;th&gt;
                    &lt;th&gt;A&lt;&#x2F;th&gt;
                    &lt;th&gt;B&lt;&#x2F;th&gt;
                    &lt;th&gt;C&lt;&#x2F;th&gt;
                &lt;&#x2F;tr&gt;
            &lt;&#x2F;thead&gt;
            &lt;tbody&gt;
                &lt;tr&gt;
                    &lt;th&gt;1&lt;&#x2F;th&gt;
                    &lt;td data-cell=&quot;A1&quot;&gt;&lt;span data-value=&quot;A1&quot;&gt;10&lt;&#x2F;span&gt;&lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;B1&quot;&gt;
                        &lt;span data-value=&quot;B1&quot;&gt;20&lt;&#x2F;span&gt;
                        &lt;span class=&quot;formula&quot;&gt;(=A1*2)&lt;&#x2F;span&gt;
                    &lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;C1&quot;&gt;
                        &lt;span data-value=&quot;C1&quot;&gt;120&lt;&#x2F;span&gt;
                        &lt;span class=&quot;formula&quot;&gt;(=SUM(B1:B3))&lt;&#x2F;span&gt;
                    &lt;&#x2F;td&gt;
                &lt;&#x2F;tr&gt;
                &lt;tr&gt;
                    &lt;th&gt;2&lt;&#x2F;th&gt;
                    &lt;td data-cell=&quot;A2&quot;&gt;&lt;span data-value=&quot;A2&quot;&gt;20&lt;&#x2F;span&gt;&lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;B2&quot;&gt;
                        &lt;span data-value=&quot;B2&quot;&gt;40&lt;&#x2F;span&gt;
                        &lt;span class=&quot;formula&quot; data-formula=&quot;B2&quot;&gt;(=A2*2)&lt;&#x2F;span&gt;
                    &lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;C2&quot;&gt;&lt;&#x2F;td&gt;
                &lt;&#x2F;tr&gt;
                &lt;tr&gt;
                    &lt;th&gt;3&lt;&#x2F;th&gt;
                    &lt;td data-cell=&quot;A3&quot;&gt;&lt;span data-value=&quot;A3&quot;&gt;30&lt;&#x2F;span&gt;&lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;B3&quot;&gt;
                        &lt;span data-value=&quot;B3&quot;&gt;60&lt;&#x2F;span&gt;
                        &lt;span class=&quot;formula&quot;&gt;(=A3*2)&lt;&#x2F;span&gt;
                    &lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;C3&quot;&gt;&lt;&#x2F;td&gt;
                &lt;&#x2F;tr&gt;
            &lt;&#x2F;tbody&gt;
        &lt;&#x2F;table&gt;
    &lt;&#x2F;div&gt;
    &lt;div class=&quot;calc-demo__text sp-calc-demo__panel is-hidden&quot;&gt;
        &lt;p data-order&gt;&lt;&#x2F;p&gt;
    &lt;&#x2F;div&gt;
    &lt;script&gt;
        {
            const helpers = window.spCalcDemo;
            const demo = document.currentScript.closest(&quot;.calc-demo&quot;);
            const inputs = demo.querySelectorAll(&#x27;input[name=&quot;calc-demo&quot;]&#x27;);
            const text = demo.querySelector(&quot;.calc-demo__text&quot;);
            const order = demo.querySelector(&quot;[data-order]&quot;);
            const cells = helpers.nodeMap(demo, &quot;[data-cell]&quot;, &quot;cell&quot;);
            const valueNodes = helpers.nodeMap(demo, &quot;[data-value]&quot;, &quot;value&quot;);
            const formulaNodes = helpers.nodeMap(
                demo,
                &quot;[data-formula]&quot;,
                &quot;formula&quot;,
            );
            const baseValues = {
                A1: 10,
                A2: 20,
                A3: 30,
                B1: 20,
                B2: 40,
                B3: 60,
                C1: 120,
            };
            const baseFormulaText = {
                B2: &quot;(=A2*2)&quot;,
            };
            const scenarios = {
                a30: {
                    focus: &quot;A1&quot;,
                    values: { A1: 67, B1: 134, C1: 234 },
                    order: &quot;Order: A1, B1, C1&quot;,
                },
                b2: {
                    focus: &quot;B2&quot;,
                    values: { B2: 20000, C1: 20080 },
                    formulas: { B2: &quot;(=A2*1000)&quot; },
                    order: &quot;Order: B2, C1&quot;,
                },
            };

            function render(edit) {
                const scenario = scenarios[edit] || {};
                const values = { ...baseValues, ...(scenario.values || {}) };
                const formulas = {
                    ...baseFormulaText,
                    ...(scenario.formulas || {}),
                };

                for (const [cell, node] of valueNodes) {
                    &#x2F;&#x2F; Spreadsheet values switch only when the rendered number changes.
                    helpers.switchText(node, values[cell]);
                }

                for (const [cell, node] of formulaNodes) {
                    &#x2F;&#x2F; Formula text uses the same switch animation as cell values.
                    helpers.switchText(node, formulas[cell]);
                }

                helpers.focusCells(cells, scenario.focus);
                text.classList.toggle(&quot;is-hidden&quot;, !edit);
                order.textContent = scenario.order || &quot;&quot;;
            }

            helpers.bindExclusiveInputs(inputs, {
                reset: function () {
                    render(&quot;&quot;);
                },
                select: function (input) {
                    render(input.value);
                },
            });

            render(&quot;&quot;);
        }
    &lt;&#x2F;script&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;The visualisation above shows the order in which formulas should be recomputed after different inputs. For example, if we change the value of A1 to 67, then B1 should be computed before C1. Otherwise, C1 will use the old value of B1, and the spreadsheet will display incorrect values. So we need a calculation algorithm that respects this order. We also want it to be as fast as possible, because almost all user actions trigger recalculation.&lt;&#x2F;p&gt;
&lt;p&gt;The first spreadsheet program, VisiCalc, used a straightforward algorithm. After any cell change, the whole spreadsheet is recalculated from top to bottom. But what about the order? The algorithm runs recalculations until the previous iteration produces the same values as the next one. In other words, until &lt;code&gt;changed&lt;&#x2F;code&gt; becomes false:&lt;&#x2F;p&gt;
&lt;div class=&quot;visicalc-demo sp-calc-demo&quot;&gt;
    &lt;div class=&quot;visicalc-demo__controls sp-calc-demo__controls&quot;&gt;
        &lt;label
            &gt;&lt;input type=&quot;checkbox&quot; name=&quot;visicalc-demo&quot; value=&quot;a2&quot; &#x2F;&gt;A2 ←
            40&lt;&#x2F;label
        &gt;&lt;label
            &gt;&lt;input type=&quot;checkbox&quot; name=&quot;visicalc-demo&quot; value=&quot;c1&quot; &#x2F;&gt;C1 ←
            =SUM(B1:B3) * 1000&lt;&#x2F;label
        &gt;
    &lt;&#x2F;div&gt;
    &lt;div class=&quot;visicalc-demo__timeline sp-calc-demo__timeline&quot; hidden&gt;
        &lt;div class=&quot;visicalc-demo__scrubber sp-calc-demo__scrubber&quot;&gt;
            &lt;button type=&quot;button&quot; data-step=&quot;-1&quot; aria-label=&quot;Previous step&quot;&gt;
                &lt;span data-icon=&quot;arrow&quot; aria-hidden=&quot;true&quot;&gt;&lt;&#x2F;span&gt;
            &lt;&#x2F;button&gt;
            &lt;input
                type=&quot;range&quot;
                min=&quot;0&quot;
                max=&quot;0&quot;
                value=&quot;0&quot;
                aria-label=&quot;Visualization step&quot;
                data-slider
            &#x2F;&gt;
            &lt;button type=&quot;button&quot; data-step=&quot;1&quot; aria-label=&quot;Next step&quot;&gt;
                &lt;span data-icon=&quot;arrow&quot; aria-hidden=&quot;true&quot;&gt;&lt;&#x2F;span&gt;
            &lt;&#x2F;button&gt;
            &lt;span class=&quot;visicalc-demo__step sp-calc-demo__step&quot; data-step-label&gt;0&#x2F;0&lt;&#x2F;span&gt;
        &lt;&#x2F;div&gt;
        &lt;button
            class=&quot;visicalc-demo__play sp-calc-demo__play&quot;
            type=&quot;button&quot;
            data-play
            aria-label=&quot;Play&quot;
        &gt;
            &lt;span
                data-icon=&quot;play&quot;
                data-play-icon=&quot;play&quot;
                aria-hidden=&quot;true&quot;
            &gt;&lt;&#x2F;span&gt;
            &lt;span
                data-icon=&quot;pause&quot;
                data-play-icon=&quot;pause&quot;
                aria-hidden=&quot;true&quot;
                hidden
            &gt;&lt;&#x2F;span&gt;
        &lt;&#x2F;button&gt;
    &lt;&#x2F;div&gt;
    &lt;div class=&quot;visicalc-demo__sheet sp-calc-demo__sheet&quot;&gt;
        &lt;table aria-label=&quot;VisiCalc recalculation algorithm example&quot;&gt;
            &lt;colgroup&gt;
                &lt;col &#x2F;&gt;
                &lt;col &#x2F;&gt;
                &lt;col &#x2F;&gt;
                &lt;col &#x2F;&gt;
            &lt;&#x2F;colgroup&gt;
            &lt;thead&gt;
                &lt;tr&gt;
                    &lt;th&gt;&lt;&#x2F;th&gt;
                    &lt;th&gt;A&lt;&#x2F;th&gt;
                    &lt;th&gt;B&lt;&#x2F;th&gt;
                    &lt;th&gt;C&lt;&#x2F;th&gt;
                &lt;&#x2F;tr&gt;
            &lt;&#x2F;thead&gt;
            &lt;tbody&gt;
                &lt;tr&gt;
                    &lt;th&gt;1&lt;&#x2F;th&gt;
                    &lt;td data-cell=&quot;A1&quot;&gt;&lt;span data-value=&quot;A1&quot;&gt;10&lt;&#x2F;span&gt;&lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;B1&quot;&gt;
                        &lt;span data-value=&quot;B1&quot;&gt;20&lt;&#x2F;span&gt;
                        &lt;span class=&quot;formula&quot;&gt;(=A1*2)&lt;&#x2F;span&gt;
                    &lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;C1&quot;&gt;
                        &lt;span data-value=&quot;C1&quot;&gt;120&lt;&#x2F;span&gt;
                        &lt;span class=&quot;formula&quot; data-formula=&quot;C1&quot;
                            &gt;(=SUM(B1:B3))&lt;&#x2F;span
                        &gt;
                    &lt;&#x2F;td&gt;
                &lt;&#x2F;tr&gt;
                &lt;tr&gt;
                    &lt;th&gt;2&lt;&#x2F;th&gt;
                    &lt;td data-cell=&quot;A2&quot;&gt;&lt;span data-value=&quot;A2&quot;&gt;20&lt;&#x2F;span&gt;&lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;B2&quot;&gt;
                        &lt;span data-value=&quot;B2&quot;&gt;40&lt;&#x2F;span&gt;
                        &lt;span class=&quot;formula&quot;&gt;(=A2*2)&lt;&#x2F;span&gt;
                    &lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;C2&quot;&gt;&lt;&#x2F;td&gt;
                &lt;&#x2F;tr&gt;
                &lt;tr&gt;
                    &lt;th&gt;3&lt;&#x2F;th&gt;
                    &lt;td data-cell=&quot;A3&quot;&gt;&lt;span data-value=&quot;A3&quot;&gt;30&lt;&#x2F;span&gt;&lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;B3&quot;&gt;
                        &lt;span data-value=&quot;B3&quot;&gt;60&lt;&#x2F;span&gt;
                        &lt;span class=&quot;formula&quot;&gt;(=A3*2)&lt;&#x2F;span&gt;
                    &lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;C3&quot;&gt;&lt;&#x2F;td&gt;
                &lt;&#x2F;tr&gt;
            &lt;&#x2F;tbody&gt;
        &lt;&#x2F;table&gt;
    &lt;&#x2F;div&gt;
    &lt;div class=&quot;visicalc-demo__info sp-calc-demo__info&quot;&gt;
        &lt;div class=&quot;visicalc-demo__text sp-calc-demo__panel is-hidden&quot;&gt;
            &lt;p data-status&gt;&lt;&#x2F;p&gt;
        &lt;&#x2F;div&gt;
        &lt;div class=&quot;visicalc-demo__changed sp-calc-demo__panel is-hidden&quot;&gt;
            &lt;p&gt;
                &lt;code
                    &gt;changed:
                    &lt;strong class=&quot;visicalc-demo__bool--false&quot; data-changed
                        &gt;false&lt;&#x2F;strong
                    &gt;
                &lt;&#x2F;code&gt;
            &lt;&#x2F;p&gt;
        &lt;&#x2F;div&gt;
    &lt;&#x2F;div&gt;
    &lt;script&gt;
        {
            const helpers = window.spCalcDemo;
            const demo = document.currentScript.closest(&quot;.visicalc-demo&quot;);
            const inputs = demo.querySelectorAll(&#x27;input[name=&quot;visicalc-demo&quot;]&#x27;);
            const timeline = demo.querySelector(&quot;.visicalc-demo__timeline&quot;);
            const slider = demo.querySelector(&quot;[data-slider]&quot;);
            const stepLabel = demo.querySelector(&quot;[data-step-label]&quot;);
            const stepButtons = demo.querySelectorAll(&quot;[data-step]&quot;);
            const playButton = demo.querySelector(&quot;[data-play]&quot;);
            const playIcon = demo.querySelector(&#x27;[data-play-icon=&quot;play&quot;]&#x27;);
            const pauseIcon = demo.querySelector(&#x27;[data-play-icon=&quot;pause&quot;]&#x27;);
            const sheet = demo.querySelector(&quot;.visicalc-demo__sheet&quot;);
            const status = demo.querySelector(&quot;[data-status]&quot;);
            const statusBox = demo.querySelector(&quot;.visicalc-demo__text&quot;);
            const changedBox = demo.querySelector(&quot;.visicalc-demo__changed&quot;);
            const changedText = demo.querySelector(&quot;[data-changed]&quot;);
            const cells = helpers.nodeMap(demo, &quot;[data-cell]&quot;, &quot;cell&quot;);
            const valueNodes = helpers.nodeMap(demo, &quot;[data-value]&quot;, &quot;value&quot;);
            const formulaNodes = helpers.nodeMap(
                demo,
                &quot;[data-formula]&quot;,
                &quot;formula&quot;,
            );
            const baseValues = {
                A1: 10,
                A2: 20,
                A3: 30,
                B1: 20,
                B2: 40,
                B3: 60,
                C1: 120,
            };
            const baseFormulaText = {
                C1: &quot;(=SUM(B1:B3))&quot;,
            };
            const scenarios = {
                a2: { focus: &quot;A2&quot;, values: { A2: 40 } },
                c1: {
                    focus: &quot;C1&quot;,
                    formulas: {
                        C1: (values) =&gt;
                            (values.B1 + values.B2 + values.B3) * 1000,
                    },
                    formulaText: { C1: &quot;(=SUM(B1:B3)*1000)&quot; },
                },
            };
            const scanOrder = [
                &quot;A1&quot;,
                &quot;B1&quot;,
                &quot;C1&quot;,
                &quot;A2&quot;,
                &quot;B2&quot;,
                &quot;C2&quot;,
                &quot;A3&quot;,
                &quot;B3&quot;,
                &quot;C3&quot;,
            ];
            const baseFormulas = {
                B1: (values) =&gt; values.A1 * 2,
                C1: (values) =&gt; values.B1 + values.B2 + values.B3,
                B2: (values) =&gt; values.A2 * 2,
                B3: (values) =&gt; values.A3 * 2,
            };
            let steps = [];
            let stepIndex = 0;
            const timelineController = helpers.createTimelineController({
                slider,
                stepLabel,
                stepButtons,
                playButton,
                playIcon,
                pauseIcon,
                playDelay: 1000,
                getStepCount: function () {
                    return steps.length;
                },
                getStepIndex: function () {
                    return stepIndex;
                },
                setStepIndex: function (nextStep) {
                    stepIndex = nextStep;
                },
                render,
            });

            function calculatedText(calculated) {
                return calculated.length
                    ? `Calculated: ${calculated.join(&quot;, &quot;)}`
                    : &quot;Calculated: None&quot;;
            }

            function setChanged(changed) {
                const nextText = changed ? &quot;true&quot; : &quot;false&quot;;

                &#x2F;&#x2F; The changed flag space is reserved, then revealed during playback.
                changedBox.classList.remove(&quot;is-hidden&quot;);

                &#x2F;&#x2F; The label only animates when the boolean text switches.
                helpers.switchText(
                    changedText,
                    nextText,
                    changed
                        ? &quot;visicalc-demo__bool--true&quot;
                        : &quot;visicalc-demo__bool--false&quot;,
                );
            }

            function renderFormulaText(edit) {
                const scenario = scenarios[edit] || {};
                const text = {
                    ...baseFormulaText,
                    ...(scenario.formulaText || {}),
                };

                for (const [cell, node] of formulaNodes) {
                    &#x2F;&#x2F; Formula labels follow formula edits without duplicating the table.
                    helpers.switchText(node, text[cell], &quot;formula&quot;);
                }
            }

            function buildSteps(edit) {
                const scenario = scenarios[edit];
                const values = { ...baseValues, ...(scenario.values || {}) };
                const formulas = {
                    ...baseFormulas,
                    ...(scenario.formulas || {}),
                };
                const result = [];

                for (let pass = 1; pass &lt;= 100; pass++) {
                    let changed = false;
                    const done = [];

                    function pushStep(extra) {
                        &#x2F;&#x2F; Every timeline step freezes values before later formulas mutate them.
                        result.push({
                            values: { ...values },
                            changed,
                            ...extra,
                        });
                    }

                    for (const cell of scanOrder) {
                        &#x2F;&#x2F; Yellow means the VisiCalc scan reached this grid cell.
                        pushStep({ current: cell, done: [...done] });

                        &#x2F;&#x2F; Only formula cells can change a stored spreadsheet value.
                        if (formulas[cell]) {
                            const nextValue = formulas[cell](values);

                            &#x2F;&#x2F; A changed result keeps the outer recalculation loop running.
                            if (nextValue !== values[cell]) {
                                changed = true;
                            }

                            values[cell] = nextValue;
                        }

                        done.push(cell);

                        &#x2F;&#x2F; Green means the grid cell has been visited in the scan.
                        pushStep({ done: [...done] });
                    }

                    &#x2F;&#x2F; The pass ends by checking whether any formula changed.
                    pushStep({ done: [...done], check: true });

                    &#x2F;&#x2F; A changed pass may leave later formulas stale, so the scan repeats.
                    if (changed) {
                        pushStep({ repeat: true });
                        continue;
                    }

                    pushStep({ complete: true });
                    break;
                }

                return result;
            }

            function clearCellStates() {
                for (const cell of cells.values()) {
                    &#x2F;&#x2F; Each render starts from white cells, then applies scan colors.
                    cell.classList.remove(&quot;is-current&quot;, &quot;is-done&quot;);
                }
                sheet.classList.remove(&quot;is-repeat&quot;, &quot;is-complete&quot;);
            }

            function renderValues(values) {
                for (const [cell, node] of valueNodes) {
                    &#x2F;&#x2F; Values are rendered separately from formula text to keep formulas visible.
                    helpers.switchText(node, values[cell]);
                }
            }

            function renderStatus(step) {
                &#x2F;&#x2F; The status space is reserved, then revealed during playback.
                statusBox.classList.remove(&quot;is-hidden&quot;);

                &#x2F;&#x2F; The check step replaces the calculated list with the pass-end question.
                if (step.check) {
                    status.textContent =
                        &quot;Spreadsheet changed during recalculation?&quot;;
                    return;
                }

                &#x2F;&#x2F; A repeated pass explains why the changed flag keeps the loop running.
                if (step.repeat) {
                    status.textContent =
                        &quot;Yes. Spreadsheet might be still inconsistent, continue&quot;;
                    return;
                }

                &#x2F;&#x2F; A complete pass explains why the recalculation loop stops.
                if (step.complete) {
                    status.textContent = &quot;No. Calculation is completed&quot;;
                    return;
                }

                status.textContent = calculatedText(step.done || []);
            }

            function render() {
                const step = steps[stepIndex];

                &#x2F;&#x2F; Without an active edit, the grid shows the initial spreadsheet values.
                if (!step) {
                    clearCellStates();
                    renderValues(baseValues);
                    statusBox.classList.add(&quot;is-hidden&quot;);
                    changedBox.classList.add(&quot;is-hidden&quot;);
                    timelineController.updatePlayButton();
                    return;
                }

                clearCellStates();
                renderValues(step.values);
                renderStatus(step);
                setChanged(step.changed);

                &#x2F;&#x2F; Red marks the pass result where another recalculation is needed.
                if (step.repeat) {
                    sheet.classList.add(&quot;is-repeat&quot;);
                }

                &#x2F;&#x2F; A green grid border marks the same break condition as the C code.
                if (step.complete) {
                    sheet.classList.add(&quot;is-complete&quot;);
                }

                for (const cell of step.done || []) {
                    &#x2F;&#x2F; Already visited cells stay green for the rest of the pass.
                    cells.get(cell).classList.add(&quot;is-done&quot;);
                }

                &#x2F;&#x2F; The current cell is yellow until this scan step finishes.
                if (step.current) {
                    cells.get(step.current).classList.add(&quot;is-current&quot;);
                }

                timelineController.renderControls();
            }

            function showEdit(edit) {
                timelineController.setPlaying(false);
                steps = buildSteps(edit);
                stepIndex = 0;
                slider.max = steps.length - 1;
                slider.value = 0;
                timeline.style.setProperty(
                    &quot;--visicalc-slider-size&quot;,
                    `${Math.min(42, Math.max(18, steps.length * 0.6))}rem`,
                );
                renderFormulaText(edit);
                timeline.hidden = false;
                render();
                helpers.focusCells(cells, scenarios[edit].focus);
            }

            function reset() {
                timelineController.setPlaying(false);
                steps = [];
                stepIndex = 0;
                timeline.style.removeProperty(&quot;--visicalc-slider-size&quot;);
                renderFormulaText(&quot;&quot;);
                timeline.hidden = true;
                render();
                helpers.focusCells(cells, &quot;&quot;);
            }

            helpers.bindExclusiveInputs(inputs, {
                reset,
                select: function (input) {
                    showEdit(input.value);
                },
            });
            timelineController.bind();

            reset();
        }
    &lt;&#x2F;script&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;This algorithm gives us an average time complexity of $O(n \cdot k)$, where $n$ is the size of the whole spreadsheet and $k$ is the number of times we had to run the recalculation. The worst case is quadratic, so the performance is not ideal. But the good side of this algorithm is its space complexity, which is just $O(n)$: we do not store any extra information besides the data itself. At that time, a program couldn&#x27;t waste any RAM. The less memory a program used, the bigger spreadsheets it could create, which is why this algorithm was used in VisiCalc.&lt;&#x2F;p&gt;
&lt;p&gt;Today this algorithm may look inefficient, but users understood these limitations and structured their spreadsheets to avoid worst cases. If I had to guess, this habit is why we still create spreadsheets where formulas reference cells on the left-hand side of the grid, and summaries (like &lt;code&gt;SUM&lt;&#x2F;code&gt; or &lt;code&gt;AVG&lt;&#x2F;code&gt;) are placed at the bottom.&lt;&#x2F;p&gt;
&lt;p&gt;However, modern spreadsheets use more advanced algorithms, which preserve the relationships between cells, for example in the form of a DAG (directed acyclic graph). Each time a formula changes, we update the graph. During recalculation, we traverse it. In spreadsheets, this is usually called the &quot;dependency graph&quot;, because it stores how formulas depend on other cells.&lt;&#x2F;p&gt;
&lt;p&gt;Consider some cells X and Y in the dependency graph.&lt;&#x2F;p&gt;
&lt;div class=&quot;dependency-graph-demo sp-calc-demo&quot;&gt;
    &lt;div class=&quot;sp-calc-demo__graph&quot;&gt;
        &lt;svg
            viewBox=&quot;0 0 420 170&quot;
            role=&quot;img&quot;
            aria-label=&quot;Dependency graph example: cell Y points to cell X because X depends on Y.&quot;
        &gt;
            &lt;g class=&quot;sp-calc-demo__graph-edge&quot; data-edge=&quot;Y:X&quot;&gt;
                &lt;path d=&quot;M 158 70 H 260&quot; &#x2F;&gt;
                &lt;path d=&quot;M 260 70 L 248 62 M 260 70 L 248 78&quot; &#x2F;&gt;
            &lt;&#x2F;g&gt;
            &lt;g class=&quot;sp-calc-demo__graph-node&quot; data-node=&quot;Y&quot;&gt;
                &lt;circle
                    class=&quot;sp-calc-demo__graph-node-shape&quot;
                    cx=&quot;110&quot;
                    cy=&quot;70&quot;
                    r=&quot;38&quot;
                &#x2F;&gt;
                &lt;text class=&quot;sp-calc-demo__graph-name&quot; x=&quot;110&quot; y=&quot;78&quot;&gt;Y&lt;&#x2F;text&gt;
                &lt;text class=&quot;sp-calc-demo__graph-label&quot; x=&quot;110&quot; y=&quot;135&quot;&gt;
                    dependency
                &lt;&#x2F;text&gt;
            &lt;&#x2F;g&gt;
            &lt;g class=&quot;sp-calc-demo__graph-node&quot; data-node=&quot;X&quot;&gt;
                &lt;circle
                    class=&quot;sp-calc-demo__graph-node-shape&quot;
                    cx=&quot;310&quot;
                    cy=&quot;70&quot;
                    r=&quot;38&quot;
                &#x2F;&gt;
                &lt;text class=&quot;sp-calc-demo__graph-name&quot; x=&quot;310&quot; y=&quot;78&quot;&gt;X&lt;&#x2F;text&gt;
                &lt;text class=&quot;sp-calc-demo__graph-label&quot; x=&quot;310&quot; y=&quot;135&quot;&gt;
                    dependent
                &lt;&#x2F;text&gt;
            &lt;&#x2F;g&gt;
        &lt;&#x2F;svg&gt;
        &lt;p class=&quot;sp-calc-demo__graph-caption&quot;&gt;Dependency graph&lt;&#x2F;p&gt;
    &lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;If cell X has a formula that references cell Y, then:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Y is called the &quot;dependency&quot; of X&lt;&#x2F;li&gt;
&lt;li&gt;X is called the &quot;dependent&quot; of Y&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Likewise:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;All cells referenced by X are called &quot;dependencies&quot; of X&lt;&#x2F;li&gt;
&lt;li&gt;All cells that reference Y are called the &quot;dependents&quot; of Y&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The direction in the dependency graph is from a &quot;dependency&quot; to a &quot;dependent&quot;. Remembering this will help you understand the visualisation of the algorithm. Maybe it&#x27;s just me, but I often confuse which cell is which. You can see if you&#x27;re me or not by guessing which cell is X in this case:&lt;&#x2F;p&gt;
&lt;div class=&quot;dependency-quiz-demo sp-calc-demo&quot;&gt;
    &lt;div class=&quot;dependency-quiz-demo__controls sp-calc-demo__controls&quot;&gt;
        &lt;label
            &gt;&lt;input type=&quot;checkbox&quot; name=&quot;dependency-quiz-demo&quot; value=&quot;a1&quot; &#x2F;&gt;A1
            is X&lt;&#x2F;label
        &gt;&lt;label
            &gt;&lt;input type=&quot;checkbox&quot; name=&quot;dependency-quiz-demo&quot; value=&quot;b1&quot; &#x2F;&gt;B1
            is X&lt;&#x2F;label
        &gt;
    &lt;&#x2F;div&gt;
    &lt;div class=&quot;dependency-quiz-demo__sheet sp-calc-demo__sheet&quot;&gt;
        &lt;table aria-label=&quot;Spreadsheet dependency quiz&quot;&gt;
            &lt;colgroup&gt;
                &lt;col &#x2F;&gt;
                &lt;col &#x2F;&gt;
                &lt;col &#x2F;&gt;
                &lt;col &#x2F;&gt;
            &lt;&#x2F;colgroup&gt;
            &lt;thead&gt;
                &lt;tr&gt;
                    &lt;th&gt;&lt;&#x2F;th&gt;
                    &lt;th&gt;A&lt;&#x2F;th&gt;
                    &lt;th&gt;B&lt;&#x2F;th&gt;
                    &lt;th&gt;C&lt;&#x2F;th&gt;
                &lt;&#x2F;tr&gt;
            &lt;&#x2F;thead&gt;
            &lt;tbody&gt;
                &lt;tr&gt;
                    &lt;th&gt;1&lt;&#x2F;th&gt;
                    &lt;td data-cell=&quot;A1&quot;&gt;10&lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;B1&quot;&gt;
                        20 &lt;span class=&quot;formula&quot;&gt;(=A1*2)&lt;&#x2F;span&gt;
                    &lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;C1&quot;&gt;&lt;&#x2F;td&gt;
                &lt;&#x2F;tr&gt;
                &lt;tr&gt;
                    &lt;th&gt;2&lt;&#x2F;th&gt;
                    &lt;td data-cell=&quot;A2&quot;&gt;&lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;B2&quot;&gt;&lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;C2&quot;&gt;&lt;&#x2F;td&gt;
                &lt;&#x2F;tr&gt;
            &lt;&#x2F;tbody&gt;
        &lt;&#x2F;table&gt;
    &lt;&#x2F;div&gt;
    &lt;div class=&quot;dependency-quiz-demo__feedbacks&quot;&gt;
        &lt;div
            class=&quot;dependency-quiz-demo__feedback dependency-quiz-demo__feedback--correct&quot;
        &gt;
            &lt;span data-icon=&quot;check&quot; aria-hidden=&quot;true&quot;&gt;&lt;&#x2F;span&gt;
            &lt;p&gt;
                Correct, B1 is the dependent of A1, and A1 is the dependency of
                B1
            &lt;&#x2F;p&gt;
        &lt;&#x2F;div&gt;
        &lt;div
            class=&quot;dependency-quiz-demo__feedback dependency-quiz-demo__feedback--wrong&quot;
        &gt;
            &lt;span data-icon=&quot;cross&quot; aria-hidden=&quot;true&quot;&gt;&lt;&#x2F;span&gt;
            &lt;p&gt;Hello me!&lt;&#x2F;p&gt;
        &lt;&#x2F;div&gt;
    &lt;&#x2F;div&gt;
    &lt;script&gt;
        {
            const helpers = window.spCalcDemo;
            const demo = document.currentScript.closest(
                &quot;.dependency-quiz-demo&quot;,
            );
            const inputs = demo.querySelectorAll(
                &#x27;input[name=&quot;dependency-quiz-demo&quot;]&#x27;,
            );

            helpers.bindExclusiveInputs(inputs, {});
        }
    &lt;&#x2F;script&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;The dependency graph lets us quickly find the dependencies and dependents of any cell. But there are two common implementations of a graph: adjacency list and adjacency matrix. Which one should we choose? A rough rule is to choose an adjacency list if the dependency graph is sparse (each node has a small number of edges), and an adjacency matrix if it is dense (each node has a lot of edges).&lt;&#x2F;p&gt;
&lt;p&gt;But the dependency graph in a spreadsheet is actually both. Some cells (like &lt;code&gt;=A1*2&lt;&#x2F;code&gt;) have only a single edge, while others (like &lt;code&gt;=AVG(A1:A10000)&lt;&#x2F;code&gt;) can have thousands of edges. This is why spreadsheets use different compression techniques (for example, instead of &lt;code&gt;A1 -&amp;gt; B1, A2 -&amp;gt; B1, A3 -&amp;gt; B1, ..., A10000 -&amp;gt; B1&lt;&#x2F;code&gt;, use just a single edge &lt;code&gt;A1:A10000 -&amp;gt; B1&lt;&#x2F;code&gt;). In my implementation, I decided to use the &lt;a href=&quot;file:&#x2F;&#x2F;&#x2F;home&#x2F;andude&#x2F;Downloads&#x2F;TACO_algorithm.pdf&quot;&gt;TACO algorithm&lt;&#x2F;a&gt; to compress dependencies. In this post I want to focus more on the formula calculation algorithm, so I won&#x27;t go into further details. But I think the link will give you mcuh more details than I can, in case you are interested.&lt;&#x2F;p&gt;
&lt;p&gt;With the dependency graph, the new calculation algorithm recalculates only the cells affected by the user change. If the user changed &lt;code&gt;A1&lt;&#x2F;code&gt;, then we find the dependents of &lt;code&gt;A1&lt;&#x2F;code&gt; and recalculate them, then recalculate their dependents, and so on. However, this is not enough, because it will not always respect formula order. For example, consider &lt;code&gt;A1=5, B1=A1*2, C1=A1+B1&lt;&#x2F;code&gt;. If we change &lt;code&gt;A1&lt;&#x2F;code&gt;, then we get its dependents: &lt;code&gt;B1&lt;&#x2F;code&gt; and &lt;code&gt;C1&lt;&#x2F;code&gt;. We should first calculate &lt;code&gt;B1&lt;&#x2F;code&gt; and only then &lt;code&gt;C1&lt;&#x2F;code&gt;, but a simple traversal (like BFS) does not guarantee this order.&lt;&#x2F;p&gt;
&lt;p&gt;One solution is to use &lt;a rel=&quot;nofollow noreferrer external&quot; href=&quot;https:&#x2F;&#x2F;www.geeksforgeeks.org&#x2F;dsa&#x2F;topological-sorting-indegree-based-solution&#x2F;&quot;&gt;Kahn&#x27;s topological sort&lt;&#x2F;a&gt;. Given a DAG, it returns a topological sort of the DAG such that for every edge &lt;code&gt;u -&amp;gt; v&lt;&#x2F;code&gt;, node &lt;code&gt;u&lt;&#x2F;code&gt; comes before &lt;code&gt;v&lt;&#x2F;code&gt;. It works by assigning an &quot;in-degree&quot; counter to each node in the graph. This counter represents how many edges point toward the node. For example, for the graph &lt;code&gt;X -&amp;gt; Y, Z -&amp;gt; Y, Y -&amp;gt; A&lt;&#x2F;code&gt;, the counters are as follows: &lt;code&gt;in_degree(X) = 0, in_degree(Z) = 0, in_degree(Y) = 2, in_degree(A) = 1&lt;&#x2F;code&gt;. Then, all nodes with an in-degree of 0 are added to a queue. We repeatedly remove a node from the queue, add it to our result list, and reduce the in-degree of all its adjacent nodes. If any of those nodes now have an in-degree of 0, they are added to the queue. This process continues until the queue is empty, and the result is a topological sort of the nodes. For us, this means that if we calculate the cells in this order, then the result should be correct.&lt;&#x2F;p&gt;
&lt;p&gt;In our case, the nodes are the cells in the dependency graph. The in-degree counter for cell X tells us how many dependencies we still need to calculate before calculating X. So, if the counter of X is 0, then all the dependencies of X are calculated and we can calculate X itself.&lt;&#x2F;p&gt;
&lt;div class=&quot;kahn-demo sp-calc-demo&quot;&gt;
    &lt;div class=&quot;kahn-demo__controls sp-calc-demo__controls&quot;&gt;
        &lt;label
            &gt;&lt;input type=&quot;checkbox&quot; name=&quot;kahn-demo&quot; value=&quot;a2&quot; &#x2F;&gt;A2 ← 40&lt;&#x2F;label
        &gt;
        &lt;label
            &gt;&lt;input type=&quot;checkbox&quot; name=&quot;kahn-demo&quot; value=&quot;c1&quot; &#x2F;&gt;C1 ←
            =SUM(B1:B3) * 1000&lt;&#x2F;label
        &gt;
        &lt;label
            &gt;&lt;input type=&quot;checkbox&quot; name=&quot;kahn-demo&quot; value=&quot;a1a2&quot; &#x2F;&gt;A1 ← =A2+1,
            A2 ← 99&lt;&#x2F;label
        &gt;
    &lt;&#x2F;div&gt;
    &lt;div class=&quot;kahn-demo__timeline sp-calc-demo__timeline&quot; hidden&gt;
        &lt;div class=&quot;kahn-demo__scrubber sp-calc-demo__scrubber&quot;&gt;
            &lt;button type=&quot;button&quot; data-step=&quot;-1&quot; aria-label=&quot;Previous step&quot;&gt;
                &lt;span data-icon=&quot;arrow&quot; aria-hidden=&quot;true&quot;&gt;&lt;&#x2F;span&gt;
            &lt;&#x2F;button&gt;
            &lt;input
                type=&quot;range&quot;
                min=&quot;0&quot;
                max=&quot;0&quot;
                value=&quot;0&quot;
                aria-label=&quot;Visualization step&quot;
                data-slider
            &#x2F;&gt;
            &lt;button type=&quot;button&quot; data-step=&quot;1&quot; aria-label=&quot;Next step&quot;&gt;
                &lt;span data-icon=&quot;arrow&quot; aria-hidden=&quot;true&quot;&gt;&lt;&#x2F;span&gt;
            &lt;&#x2F;button&gt;
            &lt;span class=&quot;kahn-demo__step sp-calc-demo__step&quot; data-step-label&gt;0&#x2F;0&lt;&#x2F;span&gt;
        &lt;&#x2F;div&gt;
        &lt;button
            class=&quot;kahn-demo__play sp-calc-demo__play&quot;
            type=&quot;button&quot;
            data-play
            aria-label=&quot;Play&quot;
        &gt;
            &lt;span
                data-icon=&quot;play&quot;
                data-play-icon=&quot;play&quot;
                aria-hidden=&quot;true&quot;
            &gt;&lt;&#x2F;span&gt;
            &lt;span
                data-icon=&quot;pause&quot;
                data-play-icon=&quot;pause&quot;
                aria-hidden=&quot;true&quot;
                hidden
            &gt;&lt;&#x2F;span&gt;
        &lt;&#x2F;button&gt;
    &lt;&#x2F;div&gt;
    &lt;div class=&quot;kahn-demo__sheet sp-calc-demo__sheet&quot;&gt;
        &lt;table aria-label=&quot;Kahn topological sort formula calculation example&quot;&gt;
            &lt;colgroup&gt;
                &lt;col &#x2F;&gt;
                &lt;col &#x2F;&gt;
                &lt;col &#x2F;&gt;
                &lt;col &#x2F;&gt;
            &lt;&#x2F;colgroup&gt;
            &lt;thead&gt;
                &lt;tr&gt;
                    &lt;th&gt;&lt;&#x2F;th&gt;
                    &lt;th&gt;A&lt;&#x2F;th&gt;
                    &lt;th&gt;B&lt;&#x2F;th&gt;
                    &lt;th&gt;C&lt;&#x2F;th&gt;
                &lt;&#x2F;tr&gt;
            &lt;&#x2F;thead&gt;
            &lt;tbody&gt;
                &lt;tr&gt;
                    &lt;th&gt;1&lt;&#x2F;th&gt;
                    &lt;td data-cell=&quot;A1&quot;&gt;
                        &lt;span data-value=&quot;A1&quot;&gt;10&lt;&#x2F;span&gt;
                        &lt;span class=&quot;formula&quot; data-formula=&quot;A1&quot;&gt;&lt;&#x2F;span&gt;
                    &lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;B1&quot;&gt;
                        &lt;span data-value=&quot;B1&quot;&gt;20&lt;&#x2F;span&gt;
                        &lt;span class=&quot;formula&quot;&gt;(=A1*2)&lt;&#x2F;span&gt;
                    &lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;C1&quot;&gt;
                        &lt;span data-value=&quot;C1&quot;&gt;60&lt;&#x2F;span&gt;
                        &lt;span class=&quot;formula&quot; data-formula=&quot;C1&quot;
                            &gt;(=SUM(B1:B3))&lt;&#x2F;span
                        &gt;
                    &lt;&#x2F;td&gt;
                &lt;&#x2F;tr&gt;
                &lt;tr&gt;
                    &lt;th&gt;2&lt;&#x2F;th&gt;
                    &lt;td data-cell=&quot;A2&quot;&gt;&lt;span data-value=&quot;A2&quot;&gt;20&lt;&#x2F;span&gt;&lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;B2&quot;&gt;
                        &lt;span data-value=&quot;B2&quot;&gt;40&lt;&#x2F;span&gt;
                        &lt;span class=&quot;formula&quot;&gt;(=A2*2)&lt;&#x2F;span&gt;
                    &lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;C2&quot;&gt;
                        &lt;span data-value=&quot;C2&quot;&gt;60&lt;&#x2F;span&gt;
                        &lt;span class=&quot;formula&quot; data-formula=&quot;C2&quot;&gt;(=A2+B2)&lt;&#x2F;span&gt;
                    &lt;&#x2F;td&gt;
                &lt;&#x2F;tr&gt;
                &lt;tr&gt;
                    &lt;th&gt;3&lt;&#x2F;th&gt;
                    &lt;td data-cell=&quot;A3&quot;&gt;&lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;B3&quot;&gt;&lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;C3&quot;&gt;&lt;&#x2F;td&gt;
                &lt;&#x2F;tr&gt;
            &lt;&#x2F;tbody&gt;
        &lt;&#x2F;table&gt;
    &lt;&#x2F;div&gt;
    &lt;div class=&quot;sp-calc-demo__graph kahn-demo__graph&quot;&gt;
        &lt;svg
            data-graph
            viewBox=&quot;0 0 640 235&quot;
            role=&quot;img&quot;
            aria-label=&quot;Dependency graph for the spreadsheet table. Each node shows the cell name and its current in-degree counter.&quot;
        &gt;&lt;&#x2F;svg&gt;
    &lt;&#x2F;div&gt;
    &lt;div class=&quot;kahn-demo__info sp-calc-demo__info&quot;&gt;
        &lt;div class=&quot;kahn-demo__text sp-calc-demo__panel is-hidden&quot;&gt;&lt;p data-status&gt;&lt;&#x2F;p&gt;&lt;&#x2F;div&gt;
        &lt;div class=&quot;kahn-demo__queue sp-calc-demo__panel is-hidden&quot;&gt;&lt;p data-queue&gt;&lt;&#x2F;p&gt;&lt;&#x2F;div&gt;
    &lt;&#x2F;div&gt;
    &lt;script&gt;
        {
            const helpers = window.spCalcDemo;
            const demo = document.currentScript.closest(&quot;.kahn-demo&quot;);
            const inputs = demo.querySelectorAll(&#x27;input[name=&quot;kahn-demo&quot;]&#x27;);
            const timeline = demo.querySelector(&quot;.kahn-demo__timeline&quot;);
            const slider = demo.querySelector(&quot;[data-slider]&quot;);
            const stepLabel = demo.querySelector(&quot;[data-step-label]&quot;);
            const stepButtons = demo.querySelectorAll(&quot;[data-step]&quot;);
            const playButton = demo.querySelector(&quot;[data-play]&quot;);
            const playIcon = demo.querySelector(&#x27;[data-play-icon=&quot;play&quot;]&#x27;);
            const pauseIcon = demo.querySelector(&#x27;[data-play-icon=&quot;pause&quot;]&#x27;);
            const sheet = demo.querySelector(&quot;.kahn-demo__sheet&quot;);
            const status = demo.querySelector(&quot;[data-status]&quot;);
            const queueStatus = demo.querySelector(&quot;[data-queue]&quot;);
            const statusBox = demo.querySelector(&quot;.kahn-demo__text&quot;);
            const queueBox = demo.querySelector(&quot;.kahn-demo__queue&quot;);
            const graph = demo.querySelector(&quot;.kahn-demo__graph&quot;);
            const graphSvg = demo.querySelector(&quot;[data-graph]&quot;);
            const mobileQuery = window.matchMedia(&quot;(max-width: 32rem)&quot;);
            const cells = helpers.nodeMap(demo, &quot;[data-cell]&quot;, &quot;cell&quot;);
            const valueNodes = helpers.nodeMap(demo, &quot;[data-value]&quot;, &quot;value&quot;);
            const formulaNodes = helpers.nodeMap(
                demo,
                &quot;[data-formula]&quot;,
                &quot;formula&quot;,
            );
            const graphNodes = new Map();
            const degreeNodes = new Map();
            const baseValues = {
                A1: 10,
                A2: 20,
                B1: 20,
                B2: 40,
                C1: 60,
                C2: 60,
            };
            const baseFormulaText = {
                A1: &quot;&quot;,
                C1: &quot;(=SUM(B1:B3))&quot;,
                C2: &quot;(=A2+B2)&quot;,
            };
            const baseFormulas = {
                B1: function (values) {
                    return values.A1 * 2;
                },
                B2: function (values) {
                    return values.A2 * 2;
                },
                C1: function (values) {
                    return values.B1 + values.B2;
                },
                C2: function (values) {
                    return values.A2 + values.B2;
                },
            };
            const scenarios = {
                a2: {
                    focus: &quot;A2&quot;,
                    values: { A2: 40 },
                    affected: [&quot;A2&quot;, &quot;B2&quot;, &quot;C1&quot;, &quot;C2&quot;],
                },
                c1: {
                    focus: &quot;C1&quot;,
                    affected: [&quot;C1&quot;],
                    formulas: {
                        C1: function (values) {
                            return (values.B1 + values.B2) * 1000;
                        },
                    },
                    formulaText: { C1: &quot;(=SUM(B1:B3)*1000)&quot; },
                },
                a1a2: {
                    focus: [&quot;A1&quot;, &quot;A2&quot;],
                    values: { A2: 99 },
                    affected: [&quot;A1&quot;, &quot;A2&quot;, &quot;B1&quot;, &quot;B2&quot;, &quot;C1&quot;, &quot;C2&quot;],
                    dependencies: { A1: [&quot;A2&quot;] },
                    formulas: {
                        A1: function (values) {
                            return values.A2 + 1;
                        },
                    },
                    formulaText: { A1: &quot;(=A2+1)&quot; },
                    desktopGraphEdges: [[&quot;A2&quot;, &quot;A1&quot;, &quot;M 90 124 V 86&quot;]],
                    mobileGraphEdges: [[&quot;A2&quot;, &quot;A1&quot;, &quot;M 90 126 V 94&quot;]],
                },
            };
            const allCells = [&quot;A1&quot;, &quot;A2&quot;, &quot;B1&quot;, &quot;B2&quot;, &quot;C1&quot;, &quot;C2&quot;];
            const baseDependencies = {
                B1: [&quot;A1&quot;],
                B2: [&quot;A2&quot;],
                C1: [&quot;B1&quot;, &quot;B2&quot;],
                C2: [&quot;A2&quot;, &quot;B2&quot;],
            };
            const desktopGraphPoints = {
                A1: [90, 50],
                A2: [90, 160],
                B1: [320, 50],
                B2: [320, 160],
                C1: [550, 50],
                C2: [550, 160],
            };
            const mobileGraphPoints = {
                A1: [90, 42],
                A2: [90, 178],
                B1: [320, 42],
                B2: [320, 178],
                C1: [550, 42],
                C2: [550, 178],
            };
            const desktopGraphEdges = [
                [&quot;A1&quot;, &quot;B1&quot;, &quot;M 132 50 H 278&quot;],
                [&quot;A2&quot;, &quot;B2&quot;, &quot;M 132 160 H 278&quot;],
                [&quot;B1&quot;, &quot;C1&quot;, &quot;M 362 50 H 508&quot;],
                [&quot;B2&quot;, &quot;C1&quot;, &quot;M 360 138 C 425 120 465 90 513 72&quot;],
                [
                    &quot;A2&quot;,
                    &quot;C2&quot;,
                    &quot;M 120 188 C 185 222 260 222 320 222 S 455 222 520 188&quot;,
                ],
                [&quot;B2&quot;, &quot;C2&quot;, &quot;M 362 160 H 508&quot;],
            ];
            const mobileGraphEdges = [
                [&quot;A1&quot;, &quot;B1&quot;, &quot;M 142 42 H 268&quot;],
                [&quot;A2&quot;, &quot;B2&quot;, &quot;M 142 178 H 268&quot;],
                [&quot;B1&quot;, &quot;C1&quot;, &quot;M 372 42 H 498&quot;],
                [&quot;B2&quot;, &quot;C1&quot;, &quot;M 368 152 C 425 132 465 92 502 70&quot;],
                [
                    &quot;A2&quot;,
                    &quot;C2&quot;,
                    &quot;M 128 214 C 190 233 260 233 320 233 S 450 233 512 214&quot;,
                ],
                [&quot;B2&quot;, &quot;C2&quot;, &quot;M 372 178 H 498&quot;],
            ];
            let activeEdit = &quot;&quot;;
            let steps = [];
            let stepIndex = 0;
            const timelineController = helpers.createTimelineController({
                slider,
                stepLabel,
                stepButtons,
                playButton,
                playIcon,
                pauseIcon,
                playDelay: 1000,
                getStepCount: function () {
                    return steps.length;
                },
                getStepIndex: function () {
                    return stepIndex;
                },
                setStepIndex: function (nextStep) {
                    stepIndex = nextStep;
                },
                render,
            });

            function emptyDegrees() {
                const degrees = {};

                for (const cell of allCells) {
                    &#x2F;&#x2F; Unaffected graph nodes keep the default counter value.
                    degrees[cell] = 0;
                }

                return degrees;
            }

            function calculatedText(calculated) {
                return calculated.length
                    ? &quot;Calculated: &quot; + calculated.join(&quot;, &quot;)
                    : &quot;Calculated: None&quot;;
            }

            function queueText(queue) {
                return queue.length
                    ? &quot;Queue: &quot; + queue.join(&quot;, &quot;)
                    : &quot;Queue: Empty&quot;;
            }

            function dependenciesFor(edit) {
                &#x2F;&#x2F; Formula edit scenarios can add or replace dependency edges.
                return Object.assign(
                    {},
                    baseDependencies,
                    (scenarios[edit] || {}).dependencies || {},
                );
            }

            function graphLayout() {
                const scenario = scenarios[activeEdit] || {};

                &#x2F;&#x2F; Mobile keeps wider vertical gaps because nodes are scaled up there.
                if (mobileQuery.matches) {
                    return {
                        points: mobileGraphPoints,
                        edges: mobileGraphEdges.concat(
                            scenario.mobileGraphEdges || [],
                        ),
                    };
                }

                return {
                    points: desktopGraphPoints,
                    edges: desktopGraphEdges.concat(
                        scenario.desktopGraphEdges || [],
                    ),
                };
            }

            function createGraph() {
                const markerId =
                    &quot;kahn-demo-arrow-&quot; + Math.random().toString(36).slice(2);
                const layout = graphLayout();
                const defs = helpers.svgEl(&quot;defs&quot;, {});
                const marker = helpers.svgEl(&quot;marker&quot;, {
                    id: markerId,
                    markerWidth: &quot;15&quot;,
                    markerHeight: &quot;18&quot;,
                    refX: &quot;13&quot;,
                    refY: &quot;9&quot;,
                    orient: &quot;auto&quot;,
                    markerUnits: &quot;userSpaceOnUse&quot;,
                });

                marker.append(
                    helpers.svgEl(&quot;path&quot;, {
                        class: &quot;kahn-demo__graph-arrow&quot;,
                        d: &quot;M 13 9 L 1 1 M 13 9 L 1 17&quot;,
                    }),
                );
                defs.append(marker);
                graphNodes.clear();
                degreeNodes.clear();
                graphSvg.replaceChildren(defs);

                for (const edge of layout.edges) {
                    const group = helpers.svgEl(&quot;g&quot;, {
                        class: &quot;sp-calc-demo__graph-edge&quot;,
                        &quot;data-edge&quot;: edge[0] + &quot;:&quot; + edge[1],
                    });

                    &#x2F;&#x2F; Open marker arrows match the static dependency graph style.
                    group.append(
                        helpers.svgEl(&quot;path&quot;, {
                            d: edge[2],
                            &quot;marker-end&quot;: &quot;url(#&quot; + markerId + &quot;)&quot;,
                        }),
                    );
                    graphSvg.append(group);
                }

                for (const cell of allCells) {
                    const point = layout.points[cell];
                    const group = helpers.svgEl(&quot;g&quot;, {
                        class: &quot;sp-calc-demo__graph-node kahn-demo__graph-node&quot;,
                        &quot;data-node&quot;: cell,
                    });
                    const name = helpers.svgEl(&quot;text&quot;, {
                        class: &quot;sp-calc-demo__graph-name&quot;,
                        x: point[0],
                        y: point[1] - 8,
                    });
                    const divider = helpers.svgEl(&quot;text&quot;, {
                        class: &quot;kahn-demo__graph-divider&quot;,
                        x: point[0],
                        y: point[1] + 7,
                    });
                    const degreeBox = helpers.svgEl(&quot;foreignObject&quot;, {
                        x: point[0] - 30,
                        y: point[1],
                        width: &quot;60&quot;,
                        height: &quot;42&quot;,
                    });
                    const degreeWrap = document.createElement(&quot;div&quot;);
                    const degree = document.createElement(&quot;span&quot;);

                    name.textContent = cell;
                    divider.textContent = &quot;--&quot;;
                    degreeWrap.className = &quot;kahn-demo__graph-degree-wrap&quot;;
                    degree.className = &quot;kahn-demo__graph-degree&quot;;
                    degree.dataset.degree = cell;
                    degree.textContent = &quot;0&quot;;
                    degreeBox.append(degreeWrap);
                    degreeWrap.append(degree);
                    group.append(
                        helpers.svgEl(&quot;circle&quot;, {
                            class: &quot;sp-calc-demo__graph-node-shape&quot;,
                            cx: point[0],
                            cy: point[1],
                            r: &quot;32&quot;,
                        }),
                        name,
                        divider,
                        degreeBox,
                    );
                    graphSvg.append(group);
                    graphNodes.set(cell, group);
                    degreeNodes.set(cell, degree);
                }
            }

            function snapshot(values, degrees, extra) {
                const step = Object.assign(
                    {
                        values: Object.assign({}, values),
                        degrees: Object.assign({}, degrees),
                        done: [],
                        queue: [],
                    },
                    extra,
                );

                &#x2F;&#x2F; Arrays are copied so later queue mutations cannot change old steps.
                step.done = Array.from(step.done || []);
                step.queue = Array.from(step.queue || []);

                return step;
            }

            function increasedText(cells) {
                return &quot;Increased the in-degree counter of &quot; + cells.join(&quot;, &quot;);
            }

            function buildSteps(edit) {
                const scenario = scenarios[edit];
                const values = Object.assign(
                    {},
                    baseValues,
                    scenario.values || {},
                );
                const activeFormulas = Object.assign(
                    {},
                    baseFormulas,
                    scenario.formulas || {},
                );
                const affected = scenario.affected;
                const affectedSet = new Set(affected);
                const dependencies = dependenciesFor(edit);
                const dependents = helpers.buildDependents(
                    allCells,
                    dependencies,
                );
                const affectedOrder = new Map(
                    affected.map(function (cell, index) {
                        return [cell, index];
                    }),
                );
                const degrees = emptyDegrees();

                for (const cell of allCells) {
                    &#x2F;&#x2F; Queue order follows the visible scenario order when choices are equal.
                    dependents[cell].sort(function (left, right) {
                        return (
                            affectedOrder.get(left) - affectedOrder.get(right)
                        );
                    });
                }
                const result = [
                    snapshot(values, degrees, {
                        hideQueue: true,
                        status: &quot;Calculating in-degree of cells in affected subgraph&quot;,
                    }),
                ];

                for (const cell of affected) {
                    result.push(
                        snapshot(values, degrees, {
                            degreeCell: cell,
                            hideQueue: true,
                            hideStatus: true,
                        }),
                    );

                    const increased = [];

                    for (const dependent of dependents[cell]) {
                        if (!affectedSet.has(dependent)) {
                            &#x2F;&#x2F; Only edges inside the affected subgraph contribute to counters.
                            continue;
                        }

                        degrees[dependent]++;
                        increased.push(dependent);
                    }

                    if (increased.length) {
                        &#x2F;&#x2F; After visiting a cell, all direct affected dependents gain one edge.
                        result.push(
                            snapshot(values, degrees, {
                                degreeCell: cell,
                                hideQueue: true,
                                status: increasedText(increased),
                            }),
                        );
                    }
                }

                const queue = [];

                for (const cell of affected) {
                    if (degrees[cell] === 0) {
                        &#x2F;&#x2F; Zero in-degree cells are ready to calculate first.
                        queue.push(cell);
                    }
                }

                const done = [];

                while (queue.length) {
                    const current = queue[0];

                    result.push(
                        snapshot(values, degrees, {
                            current: current,
                            done: done,
                            queue: queue,
                        }),
                    );
                    queue.shift();

                    if (activeFormulas[current]) {
                        &#x2F;&#x2F; Formula value changes when Kahn pops the cell from the queue.
                        values[current] = activeFormulas[current](values);
                    }

                    done.push(current);

                    for (const dependent of dependents[current]) {
                        if (!affectedSet.has(dependent)) {
                            &#x2F;&#x2F; Unaffected cells stay visible in the graph, but are not decremented.
                            continue;
                        }

                        degrees[dependent]--;

                        if (degrees[dependent] === 0) {
                            &#x2F;&#x2F; A node enters the queue only after all affected dependencies are done.
                            queue.push(dependent);
                        }
                    }

                    result.push(
                        snapshot(values, degrees, {
                            done: done,
                            queue: queue,
                        }),
                    );
                }

                if (result.length) {
                    &#x2F;&#x2F; The last state is the completed topological order.
                    result[result.length - 1].complete = true;
                }

                return result;
            }

            function renderFormulaText(edit) {
                const scenario = scenarios[edit] || {};
                const text = Object.assign(
                    {},
                    baseFormulaText,
                    scenario.formulaText || {},
                );

                for (const entry of formulaNodes) {
                    &#x2F;&#x2F; Formula labels follow formula edits without duplicating the table.
                    helpers.switchText(entry[1], text[entry[0]], &quot;formula&quot;);
                }
            }

            function clearCellStates() {
                for (const cell of cells.values()) {
                    &#x2F;&#x2F; Each render starts from white cells, then applies current state colors.
                    cell.classList.remove(&quot;is-current&quot;, &quot;is-degree&quot;, &quot;is-done&quot;);
                }

                for (const node of graphNodes.values()) {
                    &#x2F;&#x2F; The graph mirrors the table colors for the same cells.
                    node.classList.remove(&quot;is-current&quot;, &quot;is-degree&quot;, &quot;is-done&quot;);
                }

                sheet.classList.remove(&quot;is-complete&quot;);
                graph.classList.remove(&quot;is-complete&quot;);
            }

            function markCell(cellName, className) {
                const cell = cells.get(cellName);
                const graphNode = graphNodes.get(cellName);

                if (cell) {
                    &#x2F;&#x2F; Table and graph should describe the same algorithm step.
                    cell.classList.add(className);
                }

                if (graphNode) {
                    &#x2F;&#x2F; Only cells with dependency edges have graph nodes.
                    graphNode.classList.add(className);
                }
            }

            function renderValues(values) {
                for (const entry of valueNodes) {
                    &#x2F;&#x2F; Values are rendered separately from formula text to keep formulas visible.
                    helpers.switchText(entry[1], values[entry[0]]);
                }
            }

            function renderDegrees(degrees) {
                for (const cell of allCells) {
                    &#x2F;&#x2F; Counter nodes use the same HTML switch animation as grid values.
                    helpers.switchText(
                        degreeNodes.get(cell),
                        degrees[cell] || 0,
                        &quot;kahn-demo__graph-degree&quot;,
                    );
                }
            }

            function renderStatus(step) {
                statusBox.classList.toggle(
                    &quot;is-hidden&quot;,
                    Boolean(step.hideStatus),
                );
                queueBox.classList.toggle(&quot;is-hidden&quot;, Boolean(step.hideQueue));

                if (!step.hideStatus) {
                    &#x2F;&#x2F; During graph traversal this box explains what just changed.
                    status.textContent =
                        step.status || calculatedText(step.done || []);
                }

                if (!step.hideQueue) {
                    &#x2F;&#x2F; The queue is shown only for the actual Kahn calculation phase.
                    queueStatus.textContent = queueText(step.queue || []);
                }
            }

            function renderTimeline() {
                timelineController.renderControls();
            }

            function render() {
                const step = steps[stepIndex];

                if (!step) {
                    &#x2F;&#x2F; Without an active edit, the grid shows the initial spreadsheet values.
                    clearCellStates();
                    renderValues(baseValues);
                    renderDegrees(emptyDegrees());
                    statusBox.classList.add(&quot;is-hidden&quot;);
                    queueBox.classList.add(&quot;is-hidden&quot;);
                    timelineController.updatePlayButton();
                    return;
                }

                clearCellStates();
                renderValues(step.values);
                renderDegrees(step.degrees);
                renderStatus(step);

                if (step.complete) {
                    &#x2F;&#x2F; Green borders mark the finished topological order.
                    sheet.classList.add(&quot;is-complete&quot;);
                    graph.classList.add(&quot;is-complete&quot;);
                }

                for (const cell of step.done || []) {
                    &#x2F;&#x2F; Already calculated cells stay green for the rest of the run.
                    markCell(cell, &quot;is-done&quot;);
                }

                if (step.current) {
                    &#x2F;&#x2F; Yellow means this cell has been popped from the queue.
                    markCell(step.current, &quot;is-current&quot;);
                } else if (step.degreeCell) {
                    &#x2F;&#x2F; In-degree setup uses a separate color from cell calculation.
                    markCell(step.degreeCell, &quot;is-degree&quot;);
                }

                renderTimeline();
            }

            function showEdit(edit) {
                timelineController.setPlaying(false);
                activeEdit = edit;
                createGraph();
                steps = buildSteps(edit);
                stepIndex = 0;
                slider.max = steps.length - 1;
                slider.value = 0;
                timeline.style.setProperty(
                    &quot;--kahn-slider-size&quot;,
                    Math.min(42, Math.max(18, steps.length * 0.8)) + &quot;rem&quot;,
                );
                renderFormulaText(edit);
                timeline.hidden = false;
                render();
                helpers.focusCells(cells, scenarios[edit].focus);
            }

            function reset() {
                timelineController.setPlaying(false);
                activeEdit = &quot;&quot;;
                createGraph();
                steps = [];
                stepIndex = 0;
                timeline.style.removeProperty(&quot;--kahn-slider-size&quot;);
                renderFormulaText(&quot;&quot;);
                timeline.hidden = true;
                render();
                helpers.focusCells(cells, &quot;&quot;);
            }

            createGraph();

            mobileQuery.addEventListener(&quot;change&quot;, function () {
                &#x2F;&#x2F; Rebuild edges and nodes when the responsive graph layout changes.
                createGraph();
                render();
            });

            helpers.bindExclusiveInputs(inputs, {
                reset,
                select: function (input) {
                    showEdit(input.value);
                },
            });
            timelineController.bind();

            reset();
        }
    &lt;&#x2F;script&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;First, we perform a regular BFS over the dependency graph to calculate the in-degree counters of each affected cell. When we visit a node, we increase the in-degree counter of each dependent.&lt;&#x2F;p&gt;
&lt;p&gt;Then we start the calculation. We add the updated cells that have an in-degree of 0 to the queue. Notice that updated cells can also have an in-degree greater than 0. We pop a cell from the queue, calculate it, and decrease the in-degree of each dependent by 1. If a dependent&#x27;s in-degree becomes 0, we add it to the queue. We continue until the queue is empty.&lt;&#x2F;p&gt;
&lt;p&gt;The algorithm first performs BFS to update the in-degree counters, and then uses topological sort to calculate the formulas. Both steps have linear time complexity. So, the total time complexity is $O(V + E)$, where $V$ is the number of affected nodes and $E$ is the number of affected edges in the dependency graph. In the worst case, the affected subgraph is the whole dependency graph. However, most of the time, only a subgraph is affected. The cells are recalculated only when their inputs change, and each affected cell is calculated only once. This is much more efficient than the previous algorithm, where cells were repeatedly and redundantly recalculated.&lt;&#x2F;p&gt;
&lt;details &gt;
  &lt;summary&gt;&lt;span&gt;Cycle detection&lt;&#x2F;span&gt;&lt;&#x2F;summary&gt;
  &lt;p&gt;So far, I have not mentioned how cycles are detected in spreadsheets. A cycle happens when two formulas reference each other, for example &lt;code&gt;A1=B1*2, B1=A1+100&lt;&#x2F;code&gt;. Formulas with cycles will produce errors (unless &lt;a rel=&quot;nofollow noreferrer external&quot; href=&quot;https:&#x2F;&#x2F;www.onlyoffice.com&#x2F;blog&#x2F;2024&#x2F;11&#x2F;excel-circular-references-and-iterative-calculation&quot;&gt;iterative calculation&lt;&#x2F;a&gt; is implemented). This is an important step in spreadsheet recalculation. It can sometimes take almost as much time as calculating formulas. I wasn&#x27;t sure whether I should include it in the algorithm or not, and in the end decided not to. In my implementation, this is a separate step that happens afterward.&lt;&#x2F;p&gt;

&lt;&#x2F;details&gt;
&lt;details &gt;
  &lt;summary&gt;&lt;span&gt;Lazy recalculation&lt;&#x2F;span&gt;&lt;&#x2F;summary&gt;
  &lt;p&gt;In the next section, I will talk about making the algorithm faster by using more cores. There is also another optimization technique that can improve performance. I think I can call it &quot;lazy recalculation&quot;: the idea is to calculate only the cells that are displayed on the screen. It is partially used in Google Sheets, which prioritizes visible formulas. But, as far as I know, Excel does not use it because of data integrity concerns.&lt;&#x2F;p&gt;

&lt;&#x2F;details&gt;
&lt;h1 id=&quot;making-this-parallel&quot;&gt;Making this parallel&lt;a class=&quot;zola-anchor&quot; href=&quot;#making-this-parallel&quot; aria-label=&quot;Anchor link for: making-this-parallel&quot; style=&quot;visibility: hidden;&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h1&gt;
&lt;p&gt;I wanted to go further and make this algorithm parallel, mostly for fun. It was also a good opportunity to practice concurrency in Rust.&lt;&#x2F;p&gt;
&lt;p&gt;I think a good parallel strategy is one that needs little to no synchronization between threads. For this Kahn-based algorithm, the main change is to use an atomic integer, instead of a regular integer, for the in-degree counter. When a thread starts calculating cell X with an in-degree of zero, all dependencies of X are already calculated, so the thread can read them safely (no other thread will write to them). When a thread finishes calculating a cell, it decreases the in-degree of its dependents (safe because it is an atomic integer). If a dependent&#x27;s in-degree becomes 0 after this, then the cell that was just calculated was its last dependency, so only that thread schedules it.&lt;&#x2F;p&gt;
&lt;p&gt;The last observation is the main reason a thread can safely write to the cell it started calculating. No other thread calculates it, and no other thread will read from it before the topological order allows it.&lt;&#x2F;p&gt;
&lt;p&gt;A simple first idea is to start X threads and give each one its own portion of cells, instead of putting updated cells into one queue as before. But we do not know how much time each formula will take. One thread may calculate &lt;code&gt;B1&lt;&#x2F;code&gt; quickly and then stop because &lt;code&gt;B1&lt;&#x2F;code&gt; does not have any dependents, while another thread calculates &lt;code&gt;C1&lt;&#x2F;code&gt; and then starts calculating thousands of its dependents. A good parallel strategy should use all cores as much as possible, so we need to introduce some load balancing.&lt;&#x2F;p&gt;
&lt;p&gt;There&#x27;s another problem that requires load balancing as well. So far, we have talked about calculating multiple formulas in parallel, but we might also want to improve performance by calculating a single formula in parallel. For example, &lt;code&gt;=AVG(A1:A1000000)&lt;&#x2F;code&gt; and similar formulas can benefit from this. Not every spreadsheet would need this, but I decided to include it just for fun.&lt;&#x2F;p&gt;
&lt;p&gt;I will describe my attempt to implement the algorithm while keeping the load-balancing problem in mind. I&#x27;m sure there exist better approaches, writing good concurrent code requires much more experience than I have.&lt;&#x2F;p&gt;
&lt;p&gt;Async in Rust is a useful abstraction for simplifying concurrent code. It gives us a &quot;task&quot;: a piece of code that can be paused and continued. This is very useful in a high-throughput web server, for example. When a user request (&lt;code&gt;task A&lt;&#x2F;code&gt;) causes a web server to send a database request, the web server can pause &lt;code&gt;task A&lt;&#x2F;code&gt; instead of waiting, and continue working on other tasks (serving other users). When the response arrives, &lt;code&gt;task A&lt;&#x2F;code&gt; continues. As you can see, this is useful even on a single thread.&lt;&#x2F;p&gt;
&lt;p&gt;However, in Rust, async is only an abstraction. To get the actual implementation, you need an external library: a &quot;runtime&quot;. Its job is to manage tasks. Runtimes try to process many tasks quickly, and many of them use parallelism. In that case, they also need load-balancing strategies, because tasks can take a long or short time to finish, just like formulas in our case. The most popular runtime, &lt;a rel=&quot;nofollow noreferrer external&quot; href=&quot;https:&#x2F;&#x2F;docs.rs&#x2F;tokio&#x2F;latest&#x2F;tokio&#x2F;runtime&#x2F;index.html&quot;&gt;tokio&lt;&#x2F;a&gt;, uses an advanced work-stealing scheduler (when a thread has nothing to do, it &quot;steals&quot; work from other threads).&lt;&#x2F;p&gt;
&lt;p&gt;I think there is a neat similarity between formulas and tasks. So I thought: why not create a task for each formula, and let the runtime handle the load balancing?&lt;&#x2F;p&gt;
&lt;details &gt;
  &lt;summary&gt;&lt;span&gt;Note on the runtime selection and overhead&lt;&#x2F;span&gt;&lt;&#x2F;summary&gt;
  &lt;p&gt;One task per formula calculation has a lot of overhead. Each task is usually allocated on the heap so it can be sent to another thread (if balancing is needed). I started with the tokio runtime, but then switched to &lt;a rel=&quot;nofollow noreferrer external&quot; href=&quot;https:&#x2F;&#x2F;docs.rs&#x2F;forte&#x2F;1.0.0-alpha.4&#x2F;forte&#x2F;index.html&quot;&gt;forte&lt;&#x2F;a&gt;, which performed better in my experiments. Even then, I ended up creating tasks for multiple formulas (one task per 1000 formulas) to reduce overhead, which made the code a bit more complex. See the &quot;Benchmarking&quot; note for exact numbers.&lt;&#x2F;p&gt;

&lt;&#x2F;details&gt;
&lt;p&gt;Conceptually, the algorithm becomes as follows: spawn a new task for each formula. When task &lt;code&gt;A&lt;&#x2F;code&gt; finishes calculating its formula, it spawns new tasks for its dependents. Inside a formula, if some function can be parallelised (like &lt;code&gt;SUM&lt;&#x2F;code&gt;), we spawn helper tasks and then combine their results (fork &amp;amp; join). After that, task &lt;code&gt;A&lt;&#x2F;code&gt; resumes as usual.&lt;&#x2F;p&gt;
&lt;div class=&quot;parallel-kahn-demo kahn-demo sp-calc-demo&quot;&gt;
    &lt;div class=&quot;kahn-demo__controls sp-calc-demo__controls&quot;&gt;
        &lt;label
            &gt;&lt;input type=&quot;checkbox&quot; name=&quot;kahn-demo&quot; value=&quot;a2&quot; &#x2F;&gt;A2 ← 40&lt;&#x2F;label
        &gt;
        &lt;label
            &gt;&lt;input type=&quot;checkbox&quot; name=&quot;kahn-demo&quot; value=&quot;a1a2values&quot; &#x2F;&gt;A1 ←
            25, A2 ← 45&lt;&#x2F;label
        &gt;
        &lt;label
            &gt;&lt;input type=&quot;checkbox&quot; name=&quot;kahn-demo&quot; value=&quot;a1a2&quot; &#x2F;&gt;A1 ← =A2+1,
            A2 ← 99&lt;&#x2F;label
        &gt;
    &lt;&#x2F;div&gt;
    &lt;div class=&quot;kahn-demo__timeline sp-calc-demo__timeline&quot; hidden&gt;
        &lt;div class=&quot;kahn-demo__scrubber sp-calc-demo__scrubber&quot;&gt;
            &lt;button type=&quot;button&quot; data-step=&quot;-1&quot; aria-label=&quot;Previous step&quot;&gt;
                &lt;span data-icon=&quot;arrow&quot; aria-hidden=&quot;true&quot;&gt;&lt;&#x2F;span&gt;
            &lt;&#x2F;button&gt;
            &lt;input
                type=&quot;range&quot;
                min=&quot;0&quot;
                max=&quot;0&quot;
                value=&quot;0&quot;
                aria-label=&quot;Visualization step&quot;
                data-slider
            &#x2F;&gt;
            &lt;button type=&quot;button&quot; data-step=&quot;1&quot; aria-label=&quot;Next step&quot;&gt;
                &lt;span data-icon=&quot;arrow&quot; aria-hidden=&quot;true&quot;&gt;&lt;&#x2F;span&gt;
            &lt;&#x2F;button&gt;
            &lt;span class=&quot;kahn-demo__step sp-calc-demo__step&quot; data-step-label&gt;0&#x2F;0&lt;&#x2F;span&gt;
        &lt;&#x2F;div&gt;
        &lt;button
            class=&quot;kahn-demo__play sp-calc-demo__play&quot;
            type=&quot;button&quot;
            data-play
            aria-label=&quot;Play&quot;
        &gt;
            &lt;span
                data-icon=&quot;play&quot;
                data-play-icon=&quot;play&quot;
                aria-hidden=&quot;true&quot;
            &gt;&lt;&#x2F;span&gt;
            &lt;span
                data-icon=&quot;pause&quot;
                data-play-icon=&quot;pause&quot;
                aria-hidden=&quot;true&quot;
                hidden
            &gt;&lt;&#x2F;span&gt;
        &lt;&#x2F;button&gt;
    &lt;&#x2F;div&gt;
    &lt;div class=&quot;kahn-demo__sheet sp-calc-demo__sheet&quot;&gt;
        &lt;table aria-label=&quot;Parallel Kahn formula calculation example&quot;&gt;
            &lt;colgroup&gt;
                &lt;col &#x2F;&gt;
                &lt;col &#x2F;&gt;
                &lt;col &#x2F;&gt;
                &lt;col &#x2F;&gt;
            &lt;&#x2F;colgroup&gt;
            &lt;thead&gt;
                &lt;tr&gt;
                    &lt;th&gt;&lt;&#x2F;th&gt;
                    &lt;th&gt;A&lt;&#x2F;th&gt;
                    &lt;th&gt;B&lt;&#x2F;th&gt;
                    &lt;th&gt;C&lt;&#x2F;th&gt;
                &lt;&#x2F;tr&gt;
            &lt;&#x2F;thead&gt;
            &lt;tbody&gt;
                &lt;tr&gt;
                    &lt;th&gt;1&lt;&#x2F;th&gt;
                    &lt;td data-cell=&quot;A1&quot;&gt;
                        &lt;span data-value=&quot;A1&quot;&gt;10&lt;&#x2F;span&gt;
                        &lt;span class=&quot;formula&quot; data-formula=&quot;A1&quot;&gt;&lt;&#x2F;span&gt;
                    &lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;B1&quot;&gt;
                        &lt;span data-value=&quot;B1&quot;&gt;20&lt;&#x2F;span&gt;
                        &lt;span class=&quot;formula&quot;&gt;(=A1*2)&lt;&#x2F;span&gt;
                    &lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;C1&quot;&gt;
                        &lt;span data-value=&quot;C1&quot;&gt;60&lt;&#x2F;span&gt;
                        &lt;span class=&quot;formula&quot; data-formula=&quot;C1&quot;
                            &gt;(=SUM(B1:B3))&lt;&#x2F;span
                        &gt;
                    &lt;&#x2F;td&gt;
                &lt;&#x2F;tr&gt;
                &lt;tr&gt;
                    &lt;th&gt;2&lt;&#x2F;th&gt;
                    &lt;td data-cell=&quot;A2&quot;&gt;&lt;span data-value=&quot;A2&quot;&gt;20&lt;&#x2F;span&gt;&lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;B2&quot;&gt;
                        &lt;span data-value=&quot;B2&quot;&gt;40&lt;&#x2F;span&gt;
                        &lt;span class=&quot;formula&quot;&gt;(=A2*2)&lt;&#x2F;span&gt;
                    &lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;C2&quot;&gt;
                        &lt;span data-value=&quot;C2&quot;&gt;60&lt;&#x2F;span&gt;
                        &lt;span class=&quot;formula&quot; data-formula=&quot;C2&quot;&gt;(=A2+B2)&lt;&#x2F;span&gt;
                    &lt;&#x2F;td&gt;
                &lt;&#x2F;tr&gt;
                &lt;tr&gt;
                    &lt;th&gt;3&lt;&#x2F;th&gt;
                    &lt;td data-cell=&quot;A3&quot;&gt;&lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;B3&quot;&gt;&lt;&#x2F;td&gt;
                    &lt;td data-cell=&quot;C3&quot;&gt;&lt;&#x2F;td&gt;
                &lt;&#x2F;tr&gt;
            &lt;&#x2F;tbody&gt;
        &lt;&#x2F;table&gt;
    &lt;&#x2F;div&gt;
    &lt;div class=&quot;sp-calc-demo__graph kahn-demo__graph&quot;&gt;
        &lt;svg
            data-graph
            viewBox=&quot;0 0 640 235&quot;
            role=&quot;img&quot;
            aria-label=&quot;Dependency graph for the spreadsheet table. Each node shows the cell name and its current in-degree counter.&quot;
        &gt;&lt;&#x2F;svg&gt;
    &lt;&#x2F;div&gt;
    &lt;div
        class=&quot;kahn-demo__threads&quot;
        data-threads
        aria-label=&quot;Thread task timeline&quot;
    &gt;
        &lt;div class=&quot;kahn-demo__thread-row&quot;&gt;
            &lt;span class=&quot;kahn-demo__thread-label&quot;&gt;Thread A:&lt;&#x2F;span&gt;
            &lt;div class=&quot;kahn-demo__thread-track&quot; data-thread=&quot;A&quot;&gt;&lt;&#x2F;div&gt;
        &lt;&#x2F;div&gt;
        &lt;div class=&quot;kahn-demo__thread-row&quot;&gt;
            &lt;span class=&quot;kahn-demo__thread-label&quot;&gt;Thread B:&lt;&#x2F;span&gt;
            &lt;div class=&quot;kahn-demo__thread-track&quot; data-thread=&quot;B&quot;&gt;&lt;&#x2F;div&gt;
        &lt;&#x2F;div&gt;
        &lt;p class=&quot;kahn-demo__thread-note&quot;&gt;
            *async runtime is assigning the tasks to threads
        &lt;&#x2F;p&gt;
    &lt;&#x2F;div&gt;
    &lt;div class=&quot;kahn-demo__info sp-calc-demo__info&quot;&gt;
        &lt;div class=&quot;kahn-demo__text sp-calc-demo__panel is-hidden&quot;&gt;&lt;p data-status&gt;&lt;&#x2F;p&gt;&lt;&#x2F;div&gt;
    &lt;&#x2F;div&gt;
    &lt;script&gt;
        {
            const helpers = window.spCalcDemo;
            const demo = document.currentScript.closest(&quot;.kahn-demo&quot;);
            const inputs = demo.querySelectorAll(&#x27;input[name=&quot;kahn-demo&quot;]&#x27;);
            const timeline = demo.querySelector(&quot;.kahn-demo__timeline&quot;);
            const slider = demo.querySelector(&quot;[data-slider]&quot;);
            const stepLabel = demo.querySelector(&quot;[data-step-label]&quot;);
            const stepButtons = demo.querySelectorAll(&quot;[data-step]&quot;);
            const playButton = demo.querySelector(&quot;[data-play]&quot;);
            const playIcon = demo.querySelector(&#x27;[data-play-icon=&quot;play&quot;]&#x27;);
            const pauseIcon = demo.querySelector(&#x27;[data-play-icon=&quot;pause&quot;]&#x27;);
            const sheet = demo.querySelector(&quot;.kahn-demo__sheet&quot;);
            const status = demo.querySelector(&quot;[data-status]&quot;);
            const statusBox = demo.querySelector(&quot;.kahn-demo__text&quot;);
            const graph = demo.querySelector(&quot;.kahn-demo__graph&quot;);
            const graphSvg = demo.querySelector(&quot;[data-graph]&quot;);
            const threadsDiagram = demo.querySelector(&quot;[data-threads]&quot;);
            const mobileQuery = window.matchMedia(&quot;(max-width: 32rem)&quot;);
            const cells = helpers.nodeMap(demo, &quot;[data-cell]&quot;, &quot;cell&quot;);
            const valueNodes = helpers.nodeMap(demo, &quot;[data-value]&quot;, &quot;value&quot;);
            const formulaNodes = helpers.nodeMap(
                demo,
                &quot;[data-formula]&quot;,
                &quot;formula&quot;,
            );
            const graphNodes = new Map();
            const degreeNodes = new Map();
            const threadTracks = helpers.nodeMap(
                demo,
                &quot;[data-thread]&quot;,
                &quot;thread&quot;,
            );
            const baseValues = {
                A1: 10,
                A2: 20,
                B1: 20,
                B2: 40,
                C1: 60,
                C2: 60,
            };
            const baseFormulaText = {
                A1: &quot;&quot;,
                C1: &quot;(=SUM(B1:B3))&quot;,
                C2: &quot;(=A2+B2)&quot;,
            };
            const baseFormulas = {
                B1: function (values) {
                    return values.A1 * 2;
                },
                B2: function (values) {
                    return values.A2 * 2;
                },
                C1: function (values) {
                    return values.B1 + values.B2;
                },
                C2: function (values) {
                    return values.A2 + values.B2;
                },
            };
            const scenarios = {
                a2: {
                    focus: &quot;A2&quot;,
                    values: { A2: 40 },
                    affected: [&quot;A2&quot;, &quot;B2&quot;, &quot;C1&quot;, &quot;C2&quot;],
                },
                a1a2values: {
                    focus: [&quot;A1&quot;, &quot;A2&quot;],
                    values: { A1: 25, A2: 45 },
                    affected: [&quot;A1&quot;, &quot;A2&quot;, &quot;B1&quot;, &quot;B2&quot;, &quot;C1&quot;, &quot;C2&quot;],
                },
                a1a2: {
                    focus: [&quot;A1&quot;, &quot;A2&quot;],
                    values: { A2: 99 },
                    affected: [&quot;A1&quot;, &quot;A2&quot;, &quot;B1&quot;, &quot;B2&quot;, &quot;C1&quot;, &quot;C2&quot;],
                    dependencies: { A1: [&quot;A2&quot;] },
                    formulas: {
                        A1: function (values) {
                            return values.A2 + 1;
                        },
                    },
                    formulaText: { A1: &quot;(=A2+1)&quot; },
                    desktopGraphEdges: [[&quot;A2&quot;, &quot;A1&quot;, &quot;M 90 124 V 86&quot;]],
                    mobileGraphEdges: [[&quot;A2&quot;, &quot;A1&quot;, &quot;M 90 126 V 94&quot;]],
                },
            };
            const allCells = [&quot;A1&quot;, &quot;A2&quot;, &quot;B1&quot;, &quot;B2&quot;, &quot;C1&quot;, &quot;C2&quot;];
            const threadNames = [&quot;A&quot;, &quot;B&quot;];
            const baseDependencies = {
                B1: [&quot;A1&quot;],
                B2: [&quot;A2&quot;],
                C1: [&quot;B1&quot;, &quot;B2&quot;],
                C2: [&quot;A2&quot;, &quot;B2&quot;],
            };
            const desktopGraphPoints = {
                A1: [90, 50],
                A2: [90, 160],
                B1: [320, 50],
                B2: [320, 160],
                C1: [550, 50],
                C2: [550, 160],
            };
            const mobileGraphPoints = {
                A1: [90, 42],
                A2: [90, 178],
                B1: [320, 42],
                B2: [320, 178],
                C1: [550, 42],
                C2: [550, 178],
            };
            const desktopGraphEdges = [
                [&quot;A1&quot;, &quot;B1&quot;, &quot;M 132 50 H 278&quot;],
                [&quot;A2&quot;, &quot;B2&quot;, &quot;M 132 160 H 278&quot;],
                [&quot;B1&quot;, &quot;C1&quot;, &quot;M 362 50 H 508&quot;],
                [&quot;B2&quot;, &quot;C1&quot;, &quot;M 360 138 C 425 120 465 90 513 72&quot;],
                [
                    &quot;A2&quot;,
                    &quot;C2&quot;,
                    &quot;M 120 188 C 185 222 260 222 320 222 S 455 222 520 188&quot;,
                ],
                [&quot;B2&quot;, &quot;C2&quot;, &quot;M 362 160 H 508&quot;],
            ];
            const mobileGraphEdges = [
                [&quot;A1&quot;, &quot;B1&quot;, &quot;M 142 42 H 268&quot;],
                [&quot;A2&quot;, &quot;B2&quot;, &quot;M 142 178 H 268&quot;],
                [&quot;B1&quot;, &quot;C1&quot;, &quot;M 372 42 H 498&quot;],
                [&quot;B2&quot;, &quot;C1&quot;, &quot;M 368 152 C 425 132 465 92 502 70&quot;],
                [
                    &quot;A2&quot;,
                    &quot;C2&quot;,
                    &quot;M 128 214 C 190 233 260 233 320 233 S 450 233 512 214&quot;,
                ],
                [&quot;B2&quot;, &quot;C2&quot;, &quot;M 372 178 H 498&quot;],
            ];
            let activeEdit = &quot;&quot;;
            let steps = [];
            let stepIndex = 0;
            const timelineController = helpers.createTimelineController({
                slider,
                stepLabel,
                stepButtons,
                playButton,
                playIcon,
                pauseIcon,
                playDelay: 1000,
                getStepCount: function () {
                    return steps.length;
                },
                getStepIndex: function () {
                    return stepIndex;
                },
                setStepIndex: function (nextStep) {
                    stepIndex = nextStep;
                },
                render,
            });

            function emptyDegrees() {
                const degrees = {};

                for (const cell of allCells) {
                    &#x2F;&#x2F; Unaffected graph nodes keep the default counter value.
                    degrees[cell] = 0;
                }

                return degrees;
            }

            function calculatedText(calculated) {
                return calculated.length
                    ? &quot;Calculated: &quot; + calculated.join(&quot;, &quot;)
                    : &quot;Calculated: None&quot;;
            }

            function emptyThreads() {
                const rows = {};

                for (const name of threadNames) {
                    &#x2F;&#x2F; Every render keeps both thread rows visible, even with no tasks.
                    rows[name] = [];
                }

                return rows;
            }

            function copyThreads(source) {
                const rows = emptyThreads();

                for (const name of threadNames) {
                    for (const task of (source &amp;&amp; source[name]) || []) {
                        &#x2F;&#x2F; Steps own task objects so later state changes cannot leak backward.
                        rows[name].push({
                            label: task.label,
                            state: task.state,
                        });
                    }
                }

                return rows;
            }

            function dependenciesFor(edit) {
                &#x2F;&#x2F; Formula edit scenarios can add or replace dependency edges.
                return Object.assign(
                    {},
                    baseDependencies,
                    (scenarios[edit] || {}).dependencies || {},
                );
            }

            function graphLayout() {
                const scenario = scenarios[activeEdit] || {};

                &#x2F;&#x2F; Mobile keeps wider vertical gaps because nodes are scaled up there.
                if (mobileQuery.matches) {
                    return {
                        points: mobileGraphPoints,
                        edges: mobileGraphEdges.concat(
                            scenario.mobileGraphEdges || [],
                        ),
                    };
                }

                return {
                    points: desktopGraphPoints,
                    edges: desktopGraphEdges.concat(
                        scenario.desktopGraphEdges || [],
                    ),
                };
            }

            function createGraph() {
                const markerId =
                    &quot;kahn-demo-arrow-&quot; + Math.random().toString(36).slice(2);
                const layout = graphLayout();
                const defs = helpers.svgEl(&quot;defs&quot;, {});
                const marker = helpers.svgEl(&quot;marker&quot;, {
                    id: markerId,
                    markerWidth: &quot;15&quot;,
                    markerHeight: &quot;18&quot;,
                    refX: &quot;13&quot;,
                    refY: &quot;9&quot;,
                    orient: &quot;auto&quot;,
                    markerUnits: &quot;userSpaceOnUse&quot;,
                });

                marker.append(
                    helpers.svgEl(&quot;path&quot;, {
                        class: &quot;kahn-demo__graph-arrow&quot;,
                        d: &quot;M 13 9 L 1 1 M 13 9 L 1 17&quot;,
                    }),
                );
                defs.append(marker);
                graphNodes.clear();
                degreeNodes.clear();
                graphSvg.replaceChildren(defs);

                for (const edge of layout.edges) {
                    const group = helpers.svgEl(&quot;g&quot;, {
                        class: &quot;sp-calc-demo__graph-edge&quot;,
                        &quot;data-edge&quot;: edge[0] + &quot;:&quot; + edge[1],
                    });

                    &#x2F;&#x2F; Open marker arrows match the static dependency graph style.
                    group.append(
                        helpers.svgEl(&quot;path&quot;, {
                            d: edge[2],
                            &quot;marker-end&quot;: &quot;url(#&quot; + markerId + &quot;)&quot;,
                        }),
                    );
                    graphSvg.append(group);
                }

                for (const cell of allCells) {
                    const point = layout.points[cell];
                    const group = helpers.svgEl(&quot;g&quot;, {
                        class: &quot;sp-calc-demo__graph-node kahn-demo__graph-node&quot;,
                        &quot;data-node&quot;: cell,
                    });
                    const name = helpers.svgEl(&quot;text&quot;, {
                        class: &quot;sp-calc-demo__graph-name&quot;,
                        x: point[0],
                        y: point[1] - 8,
                    });
                    const divider = helpers.svgEl(&quot;text&quot;, {
                        class: &quot;kahn-demo__graph-divider&quot;,
                        x: point[0],
                        y: point[1] + 7,
                    });
                    const degreeBox = helpers.svgEl(&quot;foreignObject&quot;, {
                        x: point[0] - 30,
                        y: point[1],
                        width: &quot;60&quot;,
                        height: &quot;42&quot;,
                    });
                    const degreeWrap = document.createElement(&quot;div&quot;);
                    const degree = document.createElement(&quot;span&quot;);

                    name.textContent = cell;
                    divider.textContent = &quot;--&quot;;
                    degreeWrap.className = &quot;kahn-demo__graph-degree-wrap&quot;;
                    degree.className = &quot;kahn-demo__graph-degree&quot;;
                    degree.dataset.degree = cell;
                    degree.textContent = &quot;0&quot;;
                    degreeBox.append(degreeWrap);
                    degreeWrap.append(degree);
                    group.append(
                        helpers.svgEl(&quot;circle&quot;, {
                            class: &quot;sp-calc-demo__graph-node-shape&quot;,
                            cx: point[0],
                            cy: point[1],
                            r: &quot;32&quot;,
                        }),
                        name,
                        divider,
                        degreeBox,
                    );
                    graphSvg.append(group);
                    graphNodes.set(cell, group);
                    degreeNodes.set(cell, degree);
                }
            }

            function snapshot(values, degrees, extra) {
                const step = Object.assign(
                    {
                        values: Object.assign({}, values),
                        degrees: Object.assign({}, degrees),
                        current: [],
                        done: [],
                        threads: emptyThreads(),
                    },
                    extra,
                );

                &#x2F;&#x2F; Arrays are copied so later mutations cannot change old steps.
                step.current = Array.from(step.current || []);
                step.done = Array.from(step.done || []);
                step.threads = copyThreads(step.threads);

                return step;
            }

            function increasedText(cells) {
                return (
                    &quot;Increased the atomic in-degree counter of &quot; +
                    cells.join(&quot;, &quot;)
                );
            }

            function codeText(text) {
                const node = document.createElement(&quot;code&quot;);

                &#x2F;&#x2F; Code nodes match article formatting inside multi-line captions.
                node.textContent = text;

                return node;
            }

            function renderSumStatus(cell) {
                status.replaceChildren(
                    document.createTextNode(&quot;Calculating &quot;),
                    codeText(&quot;SUM&quot;),
                    document.createTextNode(&quot; from &quot; + cell + &quot; in parallel: &quot;),
                    document.createElement(&quot;br&quot;),
                    document.createTextNode(&quot;- Spawn tasks &quot;),
                    codeText(&quot;Task &quot; + cell + &quot; (1)&quot;),
                    document.createTextNode(&quot; and &quot;),
                    codeText(&quot;Task &quot; + cell + &quot; (2)&quot;),
                    document.createElement(&quot;br&quot;),
                    document.createTextNode(
                        &quot;- After they complete, combine them to calculate the sum&quot;,
                    ),
                );
            }

            function startCurrentTasks(threadRows, current) {
                let sumCell = &quot;&quot;;

                for (let index = 0; index &lt; current.length; index++) {
                    const cell = current[index];
                    const thread = threadNames[index % threadNames.length];

                    &#x2F;&#x2F; Main formula tasks follow the same slots as the yellow cells.
                    threadRows[thread].push({
                        label: &quot;Task &quot; + cell,
                        state: &quot;current&quot;,
                    });
                }

                for (const cell of current) {
                    if (cell !== &quot;C1&quot;) {
                        &#x2F;&#x2F; Only the SUM cell gets extra fork-and-join helper tasks.
                        continue;
                    }

                    sumCell = cell;

                    for (let index = 0; index &lt; threadNames.length; index++) {
                        const thread = threadNames[index];

                        &#x2F;&#x2F; Helper tasks are split across threads to show SUM parallelism.
                        threadRows[thread].push({
                            label: &quot;Task &quot; + cell + &quot; (&quot; + (index + 1) + &quot;)&quot;,
                            state: &quot;current&quot;,
                        });
                    }
                }

                return sumCell;
            }

            function finishCurrentTaskLayer(threadRows) {
                for (const thread of threadNames) {
                    for (const task of threadRows[thread]) {
                        if (task.state !== &quot;current&quot;) {
                            &#x2F;&#x2F; Finished tasks stay green and should not be touched again.
                            continue;
                        }

                        &#x2F;&#x2F; Each thread completes one visible task per calculation step.
                        task.state = &quot;done&quot;;
                        break;
                    }
                }
            }

            function hasCurrentTasks(threadRows) {
                for (const thread of threadNames) {
                    for (const task of threadRows[thread]) {
                        if (task.state === &quot;current&quot;) {
                            &#x2F;&#x2F; Any yellow task means the current batch is still running.
                            return true;
                        }
                    }
                }

                return false;
            }

            function buildSteps(edit) {
                const scenario = scenarios[edit];
                const values = Object.assign(
                    {},
                    baseValues,
                    scenario.values || {},
                );
                const activeFormulas = Object.assign(
                    {},
                    baseFormulas,
                    scenario.formulas || {},
                );
                const affected = scenario.affected;
                const affectedSet = new Set(affected);
                const dependencies = dependenciesFor(edit);
                const dependents = helpers.buildDependents(
                    allCells,
                    dependencies,
                );
                const affectedOrder = new Map(
                    affected.map(function (cell, index) {
                        return [cell, index];
                    }),
                );
                const degrees = emptyDegrees();

                for (const cell of allCells) {
                    &#x2F;&#x2F; Queue order follows the visible scenario order when choices are equal.
                    dependents[cell].sort(function (left, right) {
                        return (
                            affectedOrder.get(left) - affectedOrder.get(right)
                        );
                    });
                }
                const result = [
                    snapshot(values, degrees, {
                        status: &quot;Calculating atomic in-degree counters in affected subgraph&quot;,
                    }),
                ];

                for (const cell of affected) {
                    result.push(
                        snapshot(values, degrees, {
                            degreeCell: cell,
                            hideStatus: true,
                        }),
                    );

                    const increased = [];

                    for (const dependent of dependents[cell]) {
                        if (!affectedSet.has(dependent)) {
                            &#x2F;&#x2F; Only edges inside the affected subgraph contribute to counters.
                            continue;
                        }

                        degrees[dependent]++;
                        increased.push(dependent);
                    }

                    if (increased.length) {
                        &#x2F;&#x2F; After visiting a cell, all direct affected dependents gain one edge.
                        result.push(
                            snapshot(values, degrees, {
                                degreeCell: cell,
                                status: increasedText(increased),
                            }),
                        );
                    }
                }

                const queue = [];

                for (const cell of affected) {
                    if (degrees[cell] === 0) {
                        &#x2F;&#x2F; Zero in-degree cells are ready to calculate first.
                        queue.push(cell);
                    }
                }

                const done = [];
                const taskRows = emptyThreads();
                const workerCount = 2;

                while (queue.length) {
                    const current = queue.slice(0, workerCount);
                    const sumCell = startCurrentTasks(taskRows, current);

                    result.push(
                        snapshot(values, degrees, {
                            current: current,
                            done: done,
                            threads: taskRows,
                            hideStatus: !sumCell,
                            sumCell: sumCell,
                        }),
                    );
                    queue.splice(0, current.length);

                    while (hasCurrentTasks(taskRows)) {
                        &#x2F;&#x2F; Threads complete one task layer in parallel.
                        finishCurrentTaskLayer(taskRows);

                        if (hasCurrentTasks(taskRows)) {
                            &#x2F;&#x2F; The cell stays yellow while helper tasks are still running.
                            result.push(
                                snapshot(values, degrees, {
                                    current: current,
                                    done: done,
                                    threads: taskRows,
                                    hideStatus: true,
                                }),
                            );
                        }
                    }

                    for (const cell of current) {
                        if (activeFormulas[cell]) {
                            &#x2F;&#x2F; A worker writes the cell only after all dependencies are done.
                            values[cell] = activeFormulas[cell](values);
                        }
                    }

                    for (const cell of current) {
                        &#x2F;&#x2F; Finished cells are marked done before they unlock dependents.
                        done.push(cell);
                    }

                    for (const cell of current) {
                        for (const dependent of dependents[cell]) {
                            if (!affectedSet.has(dependent)) {
                                &#x2F;&#x2F; Unaffected cells stay visible in the graph, but are not decremented.
                                continue;
                            }

                            degrees[dependent]--;

                            if (degrees[dependent] === 0) {
                                &#x2F;&#x2F; Atomic decrement to zero makes the cell available to any worker.
                                queue.push(dependent);
                            }
                        }
                    }

                    result.push(
                        snapshot(values, degrees, {
                            done: done,
                            threads: taskRows,
                            status:
                                &quot;Atomic in-degree updated after: &quot; +
                                current.join(&quot;, &quot;),
                        }),
                    );
                }

                if (result.length) {
                    &#x2F;&#x2F; The last state is the completed topological order.
                    result[result.length - 1].complete = true;
                }

                return result;
            }

            function renderFormulaText(edit) {
                const scenario = scenarios[edit] || {};
                const text = Object.assign(
                    {},
                    baseFormulaText,
                    scenario.formulaText || {},
                );

                for (const entry of formulaNodes) {
                    &#x2F;&#x2F; Formula labels follow formula edits without duplicating the table.
                    helpers.switchText(entry[1], text[entry[0]], &quot;formula&quot;);
                }
            }

            function clearCellStates() {
                for (const cell of cells.values()) {
                    &#x2F;&#x2F; Each render starts from white cells, then applies current state colors.
                    cell.classList.remove(&quot;is-current&quot;, &quot;is-degree&quot;, &quot;is-done&quot;);
                }

                for (const node of graphNodes.values()) {
                    &#x2F;&#x2F; The graph mirrors the table colors for the same cells.
                    node.classList.remove(&quot;is-current&quot;, &quot;is-degree&quot;, &quot;is-done&quot;);
                }

                sheet.classList.remove(&quot;is-complete&quot;);
                graph.classList.remove(&quot;is-complete&quot;);
                threadsDiagram.classList.remove(&quot;is-complete&quot;);
            }

            function markCell(cellName, className) {
                const cell = cells.get(cellName);
                const graphNode = graphNodes.get(cellName);

                if (cell) {
                    &#x2F;&#x2F; Table and graph should describe the same algorithm step.
                    cell.classList.add(className);
                }

                if (graphNode) {
                    &#x2F;&#x2F; Only cells with dependency edges have graph nodes.
                    graphNode.classList.add(className);
                }
            }

            function renderValues(values) {
                for (const entry of valueNodes) {
                    &#x2F;&#x2F; Values are rendered separately from formula text to keep formulas visible.
                    helpers.switchText(entry[1], values[entry[0]]);
                }
            }

            function renderDegrees(degrees) {
                for (const cell of allCells) {
                    &#x2F;&#x2F; Counter nodes use the same HTML switch animation as grid values.
                    helpers.switchText(
                        degreeNodes.get(cell),
                        degrees[cell] || 0,
                        &quot;kahn-demo__graph-degree&quot;,
                    );
                }
            }

            function renderStatus(step) {
                statusBox.classList.toggle(
                    &quot;is-hidden&quot;,
                    Boolean(step.hideStatus),
                );

                if (step.hideStatus) {
                    &#x2F;&#x2F; Hidden captions still keep their layout space.
                    return;
                }

                if (step.sumCell) {
                    &#x2F;&#x2F; SUM uses code-styled task names and explicit line breaks.
                    renderSumStatus(step.sumCell);
                    return;
                }

                &#x2F;&#x2F; During graph traversal this box explains what just changed.
                status.textContent =
                    step.status || calculatedText(step.done || []);
            }

            function renderThreads(step) {
                const rows = step ? step.threads : emptyThreads();

                &#x2F;&#x2F; The task chart stays visible even before a scenario is selected.
                threadsDiagram.hidden = false;

                for (const name of threadNames) {
                    const track = threadTracks.get(name);

                    &#x2F;&#x2F; Rebuilding keeps the small task chart in sync with each step.
                    track.replaceChildren();

                    for (const task of rows[name] || []) {
                        const node = document.createElement(&quot;span&quot;);

                        &#x2F;&#x2F; Task colors mirror the yellow&#x2F;green cell and graph states.
                        node.className = helpers.classes(
                            &quot;kahn-demo__thread-task&quot;,
                            task.state === &quot;current&quot; &amp;&amp; &quot;is-current&quot;,
                            task.state === &quot;done&quot; &amp;&amp; &quot;is-done&quot;,
                        );
                        node.textContent = task.label;
                        track.append(node);
                    }
                }
            }

            function renderTimeline() {
                timelineController.renderControls();
            }

            function render() {
                const step = steps[stepIndex];

                if (!step) {
                    &#x2F;&#x2F; Without an active edit, the grid shows the initial spreadsheet values.
                    clearCellStates();
                    renderValues(baseValues);
                    renderDegrees(emptyDegrees());
                    renderThreads();
                    statusBox.classList.add(&quot;is-hidden&quot;);
                    timelineController.updatePlayButton();
                    return;
                }

                clearCellStates();
                renderValues(step.values);
                renderDegrees(step.degrees);
                renderStatus(step);
                renderThreads(step);

                if (step.complete) {
                    &#x2F;&#x2F; Green borders mark the finished topological order.
                    sheet.classList.add(&quot;is-complete&quot;);
                    graph.classList.add(&quot;is-complete&quot;);
                    threadsDiagram.classList.add(&quot;is-complete&quot;);
                }

                for (const cell of step.done || []) {
                    &#x2F;&#x2F; Already calculated cells stay green for the rest of the run.
                    markCell(cell, &quot;is-done&quot;);
                }

                if (step.current.length) {
                    for (const cell of step.current) {
                        &#x2F;&#x2F; Yellow means this cell is currently handled by a worker.
                        markCell(cell, &quot;is-current&quot;);
                    }
                } else if (step.degreeCell) {
                    &#x2F;&#x2F; In-degree setup uses a separate color from cell calculation.
                    markCell(step.degreeCell, &quot;is-degree&quot;);
                }

                renderTimeline();
            }

            function showEdit(edit) {
                timelineController.setPlaying(false);
                activeEdit = edit;
                createGraph();
                steps = buildSteps(edit);
                stepIndex = 0;
                slider.max = steps.length - 1;
                slider.value = 0;
                timeline.style.setProperty(
                    &quot;--kahn-slider-size&quot;,
                    Math.min(42, Math.max(18, steps.length * 0.8)) + &quot;rem&quot;,
                );
                renderFormulaText(edit);
                timeline.hidden = false;
                render();
                helpers.focusCells(cells, scenarios[edit].focus);
            }

            function reset() {
                timelineController.setPlaying(false);
                activeEdit = &quot;&quot;;
                createGraph();
                steps = [];
                stepIndex = 0;
                timeline.style.removeProperty(&quot;--kahn-slider-size&quot;);
                renderFormulaText(&quot;&quot;);
                timeline.hidden = true;
                render();
                helpers.focusCells(cells, &quot;&quot;);
            }

            createGraph();

            mobileQuery.addEventListener(&quot;change&quot;, function () {
                &#x2F;&#x2F; Rebuild edges and nodes when the responsive graph layout changes.
                createGraph();
                render();
            });

            helpers.bindExclusiveInputs(inputs, {
                reset,
                select: function (input) {
                    showEdit(input.value);
                },
            });
            timelineController.bind();

            reset();
        }
    &lt;&#x2F;script&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;The result is around &lt;code&gt;4x&lt;&#x2F;code&gt; faster than the synchronous algorithm. I hope the visualisation does not oversimplify what happens in the code.&lt;&#x2F;p&gt;
&lt;details &gt;
  &lt;summary&gt;&lt;span&gt;Benchmarking&lt;&#x2F;span&gt;&lt;&#x2F;summary&gt;
  &lt;p&gt;To get the &lt;code&gt;4x&lt;&#x2F;code&gt; number, I created a test spreadsheet file. It contains 10M values, 1M formulas (which reference values on the left), and a single &lt;code&gt;=SUM(A1:J1000000)&lt;&#x2F;code&gt; formula. The test file is stored inside the repository: &lt;a rel=&quot;nofollow noreferrer external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;andude10&#x2F;tonic&#x2F;tree&#x2F;master&#x2F;tauri-backend&#x2F;test-files&quot;&gt;tonic&#x2F;tauri-backend&#x2F;test-files&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;You can reproduce this in release mode by comparing the sync version (&lt;code&gt;git checkout 82c7874e20ea0c4fa24d62b021daaace854989df&lt;&#x2F;code&gt;) against the parallel version (&lt;code&gt;git checkout 11dd9ec68dbe6e2d75a11eac7fad7133195006a6&lt;&#x2F;code&gt;). Or, alternatively, run &lt;code&gt;cargo bench&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;On my machine:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;CPU: AMD Ryzen 9 3900X 12-Core Processor&lt;&#x2F;li&gt;
&lt;li&gt;Cores: 12 physical cores &#x2F; 24 threads&lt;&#x2F;li&gt;
&lt;li&gt;RAM: 64 GB&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;I get these results (excluding cycle detection and counter init):&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;calculation&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: right&quot;&gt;sync&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: right&quot;&gt;parallel&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: right&quot;&gt;improvement&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;calculate L3 (&lt;code&gt;=SUM(A1:J1000000)&lt;&#x2F;code&gt;)&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;179.47ms&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;43.75ms&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;4.1x&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;calculate K1:K1000000 (&lt;code&gt;=H1+I1+J1&lt;&#x2F;code&gt;)&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;287.90ms&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;56.78ms&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;5.1x&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;calculate L3 and K1:K1000000&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;460.20ms&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;114.15ms&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;4.0x&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;

&lt;&#x2F;details&gt;
&lt;p&gt;Thank you for reading! I hoped you enjoyed and learned something new :)&lt;&#x2F;p&gt;
</content>
	</entry>
</feed>
