Posts in Tutorial (20 found)
Unsung Yesterday

Max one weird thing

If you want to record the screen from your iPhone on your Mac, open the QuickTime Player app but ignore New Screen Recording, and click on New Movie Recording instead. = 2x) and (width >= 700px)" srcset="https://unsung.aresluna.org/_media/max-one-weird-thing/1.2096w.avif" type="image/avif"> = 3x) or (width >= 700px)" srcset="https://unsung.aresluna.org/_media/max-one-weird-thing/1.1600w.avif" type="image/avif"> This instruction is a fever dream of three weird things in sequence: It’s interesting to me to think how we got here: = 2x) and (width >= 700px)" srcset="https://unsung.aresluna.org/_media/max-one-weird-thing/2.2096w.avif" type="image/avif"> = 3x) or (width >= 700px)" srcset="https://unsung.aresluna.org/_media/max-one-weird-thing/2.1600w.avif" type="image/avif"> = 2x) and (width >= 700px)" srcset="https://unsung.aresluna.org/_media/max-one-weird-thing/3.2096w.avif" type="image/avif"> = 3x) or (width >= 700px)" srcset="https://unsung.aresluna.org/_media/max-one-weird-thing/3.1600w.avif" type="image/avif"> Long ago, the Player was the only free, consumer-facing part of QuickTime, so it needed special branding. You could purchase QuickTime Pro – you would even get aggressive ad banners for it inside Mac OS! – and its encoding and saving capabilities would then be sprinkled across the entire system. = 2x) and (width >= 700px)" srcset="https://unsung.aresluna.org/_media/max-one-weird-thing/4.2096w.avif" type="image/avif"> = 3x) or (width >= 700px)" srcset="https://unsung.aresluna.org/_media/max-one-weird-thing/4.1600w.avif" type="image/avif"> “New Movie Recording” originally offered recording from external video cameras (like iSight , another cute name). “New Screen Recording” was added later, for recording from internal screens. My guess is that technically, architecturally, or both, it was easier to treat external screens (like iPhone or Apple TV) as external video cameras since the UI and affordances matched them more closely. So that’s why screen recording from external devices ended up under “New Movie Recording.” As a UX historian, this is fun and fascinating! I love tracing back that kind of stuff and learning how certain strange things came to be. As a user… not so much. “If you want to record the screen from your iPhone on your Mac, open the QuickTime Player app but ignore New Screen Recording, and click on New Movie Recording instead.” This feels thrice arbitrary, closer to a magical incantation than a computer command, requiring you to hold a bunch of counterintuitive things in your head, or look them up every time. “Wait, what was the strange name?“ “Yeah, it’s called a player, but that’s ok.” “Hmm, I remember something about not choosing the obvious command.“ I have this internal rule that a flow or a space in the UI should have at most one weird thing. I can’t prove it to you mathematically, and I would be the first to find exceptions to my own rule. But one weird thing makes me nervous, and two or more weird things in concert raise the hair at the back of my neck. Two weird things is when the “launch blocking” bulb lights up in my head. Work needs to happen to bring the weirdness count back to 1 or 0. This is one example of what I dragged Apple earlier for : it’s not just speed that matters. It’s noticing this kind of complexity, places where an easy way was chosen, design debt accumulated, and things got simply too weird. Apple allowed three weird things to accumulate here. (By the way, delightful weird doesn’t count! But it’s hard for me to imagine anyone defending these three things above as delightful or positive in any way.) “If you want to record the screen from your iPhone on your Mac, open the QuickTime Player app but ignore New Screen Recording, and click on New Movie Recording instead.” “If you want to record the screen from your iPhone on your Mac, open the Recorder app and click on New Screen Recording.” It’s not trivial to get to this or something similar, but it’s also not really hard . You can get rid of weird things, but you need to want it. #apple #change management #complexity What on earth is “QuickTime”? I am recording with a player ? Why can’t I choose the option that describes exactly what I want to do? QuickTime is a 1990s brand, an offshoot of QuickDraw. Instead of QuickAnimate or QuickPlay, Apple called it QuickTime because it felt cute: time is what separates static images from video. The branding was much more prominent in the 1990s and 2000s, but mostly fell out of use – searching for “quicktime” in system settings today, for example, yields zero results. Long ago, the Player was the only free, consumer-facing part of QuickTime, so it needed special branding. You could purchase QuickTime Pro – you would even get aggressive ad banners for it inside Mac OS! – and its encoding and saving capabilities would then be sprinkled across the entire system. “New Movie Recording” originally offered recording from external video cameras (like iSight , another cute name). “New Screen Recording” was added later, for recording from internal screens. My guess is that technically, architecturally, or both, it was easier to treat external screens (like iPhone or Apple TV) as external video cameras since the UI and affordances matched them more closely. So that’s why screen recording from external devices ended up under “New Movie Recording.”

0 views

Notes on the Fourier Transform

The Fourier series is a great tool for analyzing periodic functions. But what about functions that don’t repeat? We’ve seen that we can compute Fourier series for a non-periodic function defined on a finite interval, as long as we don’t care about its behavior beyond that interval. Let’s extend this idea to functions that never repeat; that is, non-periodic functions defined on the interval (-\infty,\infty) . To motivate the subject ahead, let’s look back at the example used in the earlier post about Fourier series : With an odd extension into [-2,0] . In that post, to make the Fourier series work, we assumed t(x) keeps repeating with a period 2L=4 on the entire x axis. Here, let’s face the reality that it does not - in fact - repeat, and observe how our Fourier series work out. Recall that the Fourier series approximating t(x) are the sine series (since it’s an odd function): The following visualization is interactive. By default, it shows t(x) (with its odd extension) and no Fourier series approximation. We’ll proceed by a series of steps and observe the outcome: Step 1 : set to some non-zero number; already at 3, the approximation is very good. The frequency spacing is \frac{\pi}{L} (this is the coefficient of x in the sines). Note that the Fourier series repeats every 2L , as expected. Step 2 : increase L to 6. This means our series are constructed assuming t(x) has a period of 12, not 4. Note how the Fourier series look now - they repeat every 12, and they don’t match t(x) as well as before. We can increase to a higher number to make the match better. As L grows, the spacing between adjacent frequencies decreases. Step 3 : increase L to 10. We no longer see the repetitions, so feel free to increase the values of x min and x max until you do. Note again that we need to add more and more coefficients to match t(x) better with this larger L , and the spacing adjacent frequencies grows smaller. Increasing L means our function repeats at larger and larger intervals. The logical conclusion of this progression is to ask - what happens if the function never repeats, meaning L\rightarrow\infty ? While not mathematically rigorous, the visual experiment here lets us make some conjectures: we’ll likely need an infinite number of coefficients for a good approximation, and moreover, the spacing between these coefficients will tend to zero. In other words, instead of a discrete set of coefficients, we’ll end up with a continuous line, or function . The function produced by this process is the Fourier transform of t(x) , and the next section shows its mathematical derivation. In these notes, we’ll be using the complex exponential formulation of Fourier series: We’re interested in a non-periodic defined on the interval (-\infty,\infty) . So we’ll be exploring the above equations for L\rightarrow\infty . First, let’s make a slight change of notation. Instead of writing formulae in terms of the period ( 2L ), we’ll be using the n-th harmonic angular frequency w_n : So we can slightly rewrite our series as: Using \Delta w as the difference between two consecutive frequencies: Using this notation, C_n is expressed as: So far there are no new insights here, just some new notation. Now we’re going to use it to facilitate the next step. Since L\rightarrow \infty , then \Delta w\rightarrow 0 . Let’s calculate the limit of the Fourier series representation of when \Delta w\rightarrow 0 : And substitute the latest C_n into this equation, changing its dummy integration variable from x to t to avoid confusion [1] Reordering slightly, and also replacing n\Delta w by w_n in the complex exponents: Looking at the limit with the sum carefully, this is a Riemann sum (see Appendix A)! w_n is the "sampled" version of , and \Delta w\rightarrow 0 . We can therefore replace it by an integral, changing w_n to and \Delta w to dw [2] : The inner integral is called the Fourier transform of and denoted [3] : And the full equation for is then the inverse Fourier transform: Let’s take our favorite odd triangular pulse example and calculate its Fourier transform. The function’s mathematical definition and plot are shown earlier in this post. Note that we’re not extending this function periodically - it’s zero beyond the range [-2,2] ; this is exactly why we need the Fourier transform here - as we’ve seen, Fourier series won’t do because the function they reconstruct eventually starts repeating. We’re looking to find: To calculate the integral, let’s decompose the complex exponent using Euler’s formula: Since our t(x) is odd, the first integral is zero . Also t(x)sin(wx) is even, so we can write: We’ve already calculated a very similar integral in the post on Fourier series , so let’s just skip to the result: The only remaining difficulty is its value at 0, which seems undefined at first (division by zero). However, note that as w\rightarrow 0 , the numerator also tends to 0, so we can use L’Hopital’s rule (twice!) to find that: This function is complex-valued; in fact, it’s purely imaginary. How do we visualize it? A common way to visualize complex-valued functions is by plotting their magnitude and phase separately. The magnitude of \hat{t}(w) is: Since \hat{t}(w) is purely imaginary, there are only two options for the phase: When the numerator is positive, we get a negative imaginary number with phase -\pi/2 , and when the numerator is negative, we get a positive imaginary number with phase \pi/2 . Finally, when \hat{t}(w)=0 (which happens at w=0 , by our earlier analysis, but also whenever is a whole multiple of \pi ), the phase is undefined. Here’s the magnitude and phase of \hat{t}(w) plotted against : It is common to talk about \hat{t}(w) as the frequency domain representation of t(x) . When the functions we’re working with have time as their domain (e.g. the x in t(x) represents time), which is often the case in the study of signals and systems, the Fourier transform can be seen as computing the frequency domain representation of the function. Here’s the Fourier transform formula again: It takes - the time domain representation of a function, and converts it to \hat{f}(w) - a frequency domain representation. For well-behaved functions, these two representations are dual - each one describes the function completely, just in a different way. To convert back from a frequency domain representation to the time domain, we use the inverse Fourier transform: While a time-domain plot ( t(x) ) shows how a signal changes over time, a frequency-domain plot ( \hat{t}(w) ) shows how the signal is distributed across all possible frequencies. Moreover, as we’ve seen, \hat{t}(w) is complex valued. Each frequency therefore has both a magnitude and a phase: the magnitude tells us how strongly that frequency contributes, while the phase tells us how that component is shifted. The frequency domain is extremely useful in signal analysis; for example, when designing filters. The Fourier transform also has a number of properties that are very useful in signal analysis and processing. But first, let’s discuss what a "well-behaved function" means for the purpose of applying Fourier transforms. The simplest existence condition for Fourier transforms is absolute integrability (also known as Lebesgue integrable): With this condition, \hat{f}(w) exists on the entire domain, is continuous and vanishes (tends to 0) as |w|\rightarrow\infty [4] . While this condition is sufficient, it’s not necessary; there are less well-behaved functions that also have Fourier transforms defined with some limitations. In these notes, we’re mostly interested in well-behaved functions that are used in real-world engineering, so we won’t discuss the other cases. Another assumption commonly made for real-world functions is that they vanish (tend to 0) as |x|\rightarrow\infty . While this is not a direct outcome of absolute integrability [5] , it’s a reasonable assumption in engineering. After all, real-world signals have finite energies. Intuitively, when we also assume is uniformly continuous , the assumption of vanishing at |x|\rightarrow\infty is a logical conclusion, because otherwise how can the total area for |f(x)| be finite? An important outcome of this discussion is that the Fourier transform is unsuitable for periodic functions. Functions that repeat at intervals are not absolute integrable . For periodic functions, we use Fourier series. The Fourier transform is a linear operator, because the integral is linear: So is the inverse Fourier transform; it’s similarly easy to show that: If we scale the domain of a function by a constant, its transform changes only slightly: Let’s do the variable substitution u=ax : This is the Fourier transform evaluated at \frac{w}{a} , so: There’s one small caveat here; when a is negative, the integral bounds should be flipped, causing a minus sign in front of the transform. So we can write: Which works for any a\ne 0 . This property is intuitive when thinking about signals: suppose a>0 , then f(ax) means the signal is compressed in the time domain by a factor a . The scaling property says that the frequency domain is expanded using the same factor; in other words, the higher frequencies become more prominent because we need sharper transitions to represent the compressed signal. Time shifting What happens to the Fourier transform if we time-shift the input signal by some constant: f(x-x_0) . By definition: Substituting u=x-x_0 , we get du=dx , so: Transform of a derivative An extremely useful property that’s often employed in the solution of partial differential equations; let’s calculate the Fourier transform of the derivative of : We’ll use integration by parts, where dv=f'(x) and u=e^{-i\cdot wx} . Therefore, v=f(x) and du=-iw\cdot e^{-i\cdot wx} : Recall the assumption made in the "Existence condition..." section about vanishing at infinities. So the first part of the equation above is zero, and we’re left with: Transform of convolution The convolution between two continuous functions and g(x) is defined as: Let’s calculate the Fourier transform of this function: This step of combining the integrals into a double integral, as well as the next step (changing the order of integration) is possible due to Fubini’s theorem and our assumption that and g(x) are Lebesgue integrable. Switch order of integration: Now, f(\xi) in the inner integral doesn’t depend on x , so we can pull it out: The inner integral is just the Fourier transform of a time-shifted g(x-\xi) , so we can write: And the remaining integral is the Fourier transform of , so: Convolution in the time domain translates to multiplication in the frequency domain! This result is so important in signal processing that it’s called the convolution theorem . Suppose we have some function and we want to know the area bounded between this function’s graph and the x axis in a certain interval [a,b] . One way to do this is to take a partition of the interval: And calculate the area under for every element of the partition. We can then approximate such sub-areas by rectangles, as follows: We’ll denote the area of each rectangle as f(x^*_i)\cdot\Delta x : There are many ways to choose which point of the interval [x_{i-1},x_i] to denote as x^*_i : left point ( x_{i-1} ), right point ( ), mid-point between the two (which is what our plot shows) or anything in between. The distinction doesn’t really matter for our purpose, as we will soon see. We can approximate the area under the curve of in the interval [a,b] with the Riemann sum , using a uniform partition: If is continuous on [a,b] , then as n\rightarrow \infty : This is known as the Riemann integral , or just the definite integral. The limit is why the exact choice of x^*_i doesn’t matter: as n\rightarrow\infty we have \Delta x\rightarrow 0 , and all points within [x_{i-1}, x_i] are equally good. The Fourier series is a great tool for analyzing periodic functions. But what about functions that don’t repeat? We’ve seen that we can compute Fourier series for a non-periodic function defined on a finite interval, as long as we don’t care about its behavior beyond that interval. Let’s extend this idea to functions that never repeat; that is, non-periodic functions defined on the interval (-\infty,\infty) . Visualizing Fourier series for non-repeating functions To motivate the subject ahead, let’s look back at the example used in the earlier post about Fourier series : \[t(x)= \begin{cases} x & 0 \leq x \leq 1 \\ 2-x & 1 < x \leq 2 \\ \end{cases}\] With an odd extension into [-2,0] . In that post, to make the Fourier series work, we assumed t(x) keeps repeating with a period 2L=4 on the entire x axis. Here, let’s face the reality that it does not - in fact - repeat, and observe how our Fourier series work out. Recall that the Fourier series approximating t(x) are the sine series (since it’s an odd function): \[t(x)=\frac{8}{\pi^2}\bigg[ sin\frac{\pi x}{2}-\frac{1}{3^2} sin\frac{3\pi x}{2}+\frac{1}{5^2}sin\frac{5\pi x}{2}-\cdots\bigg]\] The following visualization is interactive. By default, it shows t(x) (with its odd extension) and no Fourier series approximation. We’ll proceed by a series of steps and observe the outcome: n (terms in the Fourier series) L x min x max Step 1 : set to some non-zero number; already at 3, the approximation is very good. The frequency spacing is \frac{\pi}{L} (this is the coefficient of x in the sines). Note that the Fourier series repeats every 2L , as expected. Step 2 : increase L to 6. This means our series are constructed assuming t(x) has a period of 12, not 4. Note how the Fourier series look now - they repeat every 12, and they don’t match t(x) as well as before. We can increase to a higher number to make the match better. As L grows, the spacing between adjacent frequencies decreases. Step 3 : increase L to 10. We no longer see the repetitions, so feel free to increase the values of x min and x max until you do. Note again that we need to add more and more coefficients to match t(x) better with this larger L , and the spacing adjacent frequencies grows smaller. Increasing L means our function repeats at larger and larger intervals. The logical conclusion of this progression is to ask - what happens if the function never repeats, meaning L\rightarrow\infty ? While not mathematically rigorous, the visual experiment here lets us make some conjectures: we’ll likely need an infinite number of coefficients for a good approximation, and moreover, the spacing between these coefficients will tend to zero. In other words, instead of a discrete set of coefficients, we’ll end up with a continuous line, or function . The function produced by this process is the Fourier transform of t(x) , and the next section shows its mathematical derivation. Fourier series with L\rightarrow\infty leading to Fourier transform In these notes, we’ll be using the complex exponential formulation of Fourier series: \[f(x)=\sum_{n=-\infty}^{\infty}C_n\cdot e^{in\pi x/L}\] With: \[C_n=\frac{1}{2L}\int_{-L}^{L}f(x)e^{-in\pi x/L}dx\] We’re interested in a non-periodic defined on the interval (-\infty,\infty) . So we’ll be exploring the above equations for L\rightarrow\infty . First, let’s make a slight change of notation. Instead of writing formulae in terms of the period ( 2L ), we’ll be using the n-th harmonic angular frequency w_n : \[w_n=\frac{n\pi}{L}\] So we can slightly rewrite our series as: \[f(x)=\sum_{n=-\infty}^{\infty}C_n\cdot e^{i w_n x}=\sum_{n=-\infty}^{\infty}C_n\cdot e^{i\cdot n \Delta w x}\] Using \Delta w as the difference between two consecutive frequencies: \[\Delta w=w_n-w_{n-1}=\frac{n\pi}{L}-\frac{(n-1)\pi}{L}=\frac{\pi}{L}\] Using this notation, C_n is expressed as: \[C_n=\frac{\Delta w}{2\pi}\int_{-\pi/\Delta w}^{\pi/\Delta w}f(x)e^{-i\cdot n \Delta w x}dx\] So far there are no new insights here, just some new notation. Now we’re going to use it to facilitate the next step. Since L\rightarrow \infty , then \Delta w\rightarrow 0 . Let’s calculate the limit of the Fourier series representation of when \Delta w\rightarrow 0 : \[f(x)=\lim_{\Delta w\rightarrow 0}\sum_{n=-\infty}^{\infty}C_n\cdot e^{i\cdot n \Delta w x}\] And substitute the latest C_n into this equation, changing its dummy integration variable from x to t to avoid confusion [1] \[f(x)=\lim_{\Delta w\rightarrow 0}\sum_{n=-\infty}^{\infty}\left[\frac{\Delta w}{2\pi}\int_{-\pi/\Delta w}^{\pi/\Delta w}f(t)e^{-i\cdot n \Delta w t}dt\right]\cdot e^{i\cdot n \Delta w x}\] Reordering slightly, and also replacing n\Delta w by w_n in the complex exponents: \[f(x)=\frac{1}{2\pi}\lim_{\Delta w\rightarrow 0}\sum_{n=-\infty}^{\infty}\left[\int_{-\pi/\Delta w}^{\pi/\Delta w}f(t)e^{-i\cdot w_n t}dt\right]\cdot e^{i\cdot w_n x}\Delta w\] Looking at the limit with the sum carefully, this is a Riemann sum (see Appendix A)! w_n is the "sampled" version of , and \Delta w\rightarrow 0 . We can therefore replace it by an integral, changing w_n to and \Delta w to dw [2] : \[f(x)=\frac{1}{2\pi}\int_{-\infty}^{\infty}\left[\int_{-\infty}^{\infty}f(t)e^{-i\cdot wt}dt\right]\cdot e^{i\cdot w x}dw\] The inner integral is called the Fourier transform of and denoted [3] : \[\boxed{\hat{f}(w)=\mathcal{F}\left[f(x)\right]=\int_{-\infty}^{\infty}f(x)e^{-i\cdot wx}dx}\] And the full equation for is then the inverse Fourier transform: \[\boxed{f(x)=\mathcal{F}^{-1}\left[\hat{f}(w)\right]=\frac{1}{2\pi}\int_{-\infty}^{\infty}\hat{f}(w)e^{i\cdot w x}dw}\] Example calculation of Fourier transform Let’s take our favorite odd triangular pulse example and calculate its Fourier transform. The function’s mathematical definition and plot are shown earlier in this post. Note that we’re not extending this function periodically - it’s zero beyond the range [-2,2] ; this is exactly why we need the Fourier transform here - as we’ve seen, Fourier series won’t do because the function they reconstruct eventually starts repeating. We’re looking to find: \[\hat{t}(w)=\int_{-\infty}^{\infty}t(x)e^{-iwx}dx\] To calculate the integral, let’s decompose the complex exponent using Euler’s formula: \[\hat{t}(w)=\int_{-\infty}^{\infty}t(x)cos(wx)dx-i\int_{-\infty}^{\infty}t(x)sin(wx)dx\] Since our t(x) is odd, the first integral is zero . Also t(x)sin(wx) is even, so we can write: \[\hat{t}(w)=-2i\int_{0}^{\infty}t(x)sin(wx)dx\] We’ve already calculated a very similar integral in the post on Fourier series , so let’s just skip to the result: \[\hat{t}(w)=-2i\cdot\frac{2\cdot sin(w)-sin(2w)}{w^2}\] The only remaining difficulty is its value at 0, which seems undefined at first (division by zero). However, note that as w\rightarrow 0 , the numerator also tends to 0, so we can use L’Hopital’s rule (twice!) to find that: \[\lim_{w\rightarrow 0} \hat{t}(w)=0\] Therefore: \[\hat{t}(w)= \begin{cases} -2i\cdot\frac{2\cdot sin(w)-sin(2w)}{w^2} & w\neq 0 \\ 0 & w=0 \\ \end{cases}\] This function is complex-valued; in fact, it’s purely imaginary. How do we visualize it? A common way to visualize complex-valued functions is by plotting their magnitude and phase separately. The magnitude of \hat{t}(w) is: \[|\hat{t}(w)|=\sqrt{\hat{t}(w)\cdot\hat{t}(w)^*}=2\left|\frac{2\cdot sin(w)-sin(2w)}{w^2} \right|\] Since \hat{t}(w) is purely imaginary, there are only two options for the phase: When the numerator is positive, we get a negative imaginary number with phase -\pi/2 , and when the numerator is negative, we get a positive imaginary number with phase \pi/2 . Finally, when \hat{t}(w)=0 (which happens at w=0 , by our earlier analysis, but also whenever is a whole multiple of \pi ), the phase is undefined. Here’s the magnitude and phase of \hat{t}(w) plotted against : It is common to talk about \hat{t}(w) as the frequency domain representation of t(x) . The frequency domain representation of functions When the functions we’re working with have time as their domain (e.g. the x in t(x) represents time), which is often the case in the study of signals and systems, the Fourier transform can be seen as computing the frequency domain representation of the function. Here’s the Fourier transform formula again: \[\hat{f}(w)=\mathcal{F}\left[f(x)\right]=\int_{-\infty}^{\infty}f(x)e^{-i\cdot wx}dx\] It takes - the time domain representation of a function, and converts it to \hat{f}(w) - a frequency domain representation. For well-behaved functions, these two representations are dual - each one describes the function completely, just in a different way. To convert back from a frequency domain representation to the time domain, we use the inverse Fourier transform: \[\mathcal{F}^{-1}\left[\hat{f}(w)\right]=\frac{1}{2\pi}\int_{-\infty}^{\infty}\hat{f}(w)e^{i\cdot w x}dw\] While a time-domain plot ( t(x) ) shows how a signal changes over time, a frequency-domain plot ( \hat{t}(w) ) shows how the signal is distributed across all possible frequencies. Moreover, as we’ve seen, \hat{t}(w) is complex valued. Each frequency therefore has both a magnitude and a phase: the magnitude tells us how strongly that frequency contributes, while the phase tells us how that component is shifted. The frequency domain is extremely useful in signal analysis; for example, when designing filters. The Fourier transform also has a number of properties that are very useful in signal analysis and processing. But first, let’s discuss what a "well-behaved function" means for the purpose of applying Fourier transforms. Existence condition for the Fourier transform The simplest existence condition for Fourier transforms is absolute integrability (also known as Lebesgue integrable): \[\int_{-\infty}^{\infty}|f(x)|dx<\infty\] With this condition, \hat{f}(w) exists on the entire domain, is continuous and vanishes (tends to 0) as |w|\rightarrow\infty [4] . While this condition is sufficient, it’s not necessary; there are less well-behaved functions that also have Fourier transforms defined with some limitations. In these notes, we’re mostly interested in well-behaved functions that are used in real-world engineering, so we won’t discuss the other cases. Another assumption commonly made for real-world functions is that they vanish (tend to 0) as |x|\rightarrow\infty . While this is not a direct outcome of absolute integrability [5] , it’s a reasonable assumption in engineering. After all, real-world signals have finite energies. Intuitively, when we also assume is uniformly continuous , the assumption of vanishing at |x|\rightarrow\infty is a logical conclusion, because otherwise how can the total area for |f(x)| be finite? An important outcome of this discussion is that the Fourier transform is unsuitable for periodic functions. Functions that repeat at intervals are not absolute integrable . For periodic functions, we use Fourier series. Some useful properties of Fourier transforms Linearity The Fourier transform is a linear operator, because the integral is linear: \[\begin{aligned} \mathcal{F}\left[\alpha f(x)+\beta g(x)\right]&=\int_{-\infty}^{\infty}\alpha f(x)e^{-i\cdot wx}dx+\int_{-\infty}^{\infty}\beta g(x)e^{-i\cdot wx}dx\\ &=\alpha\int_{-\infty}^{\infty}f(x)e^{-i\cdot wx}dx+\beta\int_{-\infty}^{\infty}g(x)e^{-i\cdot wx}dx\\ &=\alpha\mathcal{F}\left[f(x)\right]+\beta\mathcal{F}\left[g(x)\right] \end{aligned}\] So is the inverse Fourier transform; it’s similarly easy to show that: \[\mathcal{F}^{-1}\left[\alpha\hat{f}(w)+\beta\hat{g}(w)\right]= \alpha\mathcal{F}^{-1}\left[\hat{f}(w)\right]+\beta\mathcal{F}^{-1}\left[\hat{g}(w)\right]\] Scaling If we scale the domain of a function by a constant, its transform changes only slightly: \[\mathcal{F}\left[f(ax)\right]=\int_{-\infty}^{\infty}f(ax)e^{-i\cdot wx}dx\] Let’s do the variable substitution u=ax : \[\mathcal{F}\left[f(ax)\right]=\frac{1}{a}\int_{-\infty}^{\infty}f(u)e^{-i\cdot \frac{wu}{a}}du\] This is the Fourier transform evaluated at \frac{w}{a} , so: \[\mathcal{F}\left[f(ax)\right]=\frac{1}{a}\hat{f}\left(\frac{w}{a}\right)\] There’s one small caveat here; when a is negative, the integral bounds should be flipped, causing a minus sign in front of the transform. So we can write: \[\mathcal{F}\left[f(ax)\right]=\frac{1}{|a|}\hat{f}\left(\frac{w}{a}\right)\] Which works for any a\ne 0 . This property is intuitive when thinking about signals: suppose a>0 , then f(ax) means the signal is compressed in the time domain by a factor a . The scaling property says that the frequency domain is expanded using the same factor; in other words, the higher frequencies become more prominent because we need sharper transitions to represent the compressed signal. Time shifting What happens to the Fourier transform if we time-shift the input signal by some constant: f(x-x_0) . By definition: \[\mathcal{F}\left[f(x-x_0)\right]=\int_{-\infty}^{\infty}f(x-x_0)e^{-i\cdot wx}dx\] Substituting u=x-x_0 , we get du=dx , so: \[\begin{aligned} \mathcal{F}\left[f(x-x_0)\right]&=\int_{-\infty}^{\infty}f(u)e^{-i\cdot w(u+x_0)}du\\ &=e^{-iwx_0}\int_{-\infty}^{\infty}f(u)e^{-i\cdot wu}du\\ &=e^{-iwx_0}\mathcal{F}\left[f(x)\right] \end{aligned}\] Transform of a derivative An extremely useful property that’s often employed in the solution of partial differential equations; let’s calculate the Fourier transform of the derivative of : \[\mathcal{F}\left[f'(x)\right]=\int_{-\infty}^{\infty}f'(x)e^{-i\cdot wx}dx\] We’ll use integration by parts, where dv=f'(x) and u=e^{-i\cdot wx} . Therefore, v=f(x) and du=-iw\cdot e^{-i\cdot wx} : \[\mathcal{F}\left[f'(x)\right]=\left[f(x)e^{-i\cdot wx}\right]^{\infty}_{-\infty}-\int_{-\infty}^{\infty}f(x)(-iw\cdot e^{-i\cdot wx})dx\] Recall the assumption made in the "Existence condition..." section about vanishing at infinities. So the first part of the equation above is zero, and we’re left with: \[\begin{aligned} \mathcal{F}\left[f'(x)\right]&=-\int_{-\infty}^{\infty}f(x)(-iw\cdot e^{-i\cdot wx})dx\\ &=iw\int_{-\infty}^{\infty}f(x)e^{-i\cdot wx}dx\\ &=iw\cdot\mathcal{F}\left[f(x)\right] \end{aligned}\] Transform of convolution The convolution between two continuous functions and g(x) is defined as: \[(f\ast g)(x)=\int_{-\infty}^{\infty}f(\xi)g(x-\xi)d\xi\] Let’s calculate the Fourier transform of this function: \[\begin{aligned} \mathcal{F}\left[(f\ast g)(x)\right]&=\int_{-\infty}^{\infty}e^{-i\cdot wx}\left[\int_{-\infty}^{\infty}f(\xi)g(x-\xi)d\xi\right]dx\\ &=\int_{-\infty}^{\infty}\int_{-\infty}^{\infty}e^{-i\cdot wx}f(\xi)g(x-\xi)d\xi\ dx \end{aligned}\] This step of combining the integrals into a double integral, as well as the next step (changing the order of integration) is possible due to Fubini’s theorem and our assumption that and g(x) are Lebesgue integrable. Switch order of integration: \[\mathcal{F}\left[(f\ast g)(x)\right]=\int_{-\infty}^{\infty}\int_{-\infty}^{\infty}e^{-i\cdot wx}f(\xi)g(x-\xi)dx\ d\xi\] Now, f(\xi) in the inner integral doesn’t depend on x , so we can pull it out: \[\mathcal{F}\left[(f\ast g)(x)\right]=\int_{-\infty}^{\infty}f(\xi)\int_{-\infty}^{\infty}e^{-i\cdot wx}g(x-\xi)dx\ d\xi\] The inner integral is just the Fourier transform of a time-shifted g(x-\xi) , so we can write: \[\mathcal{F}\left[(f\ast g)(x)\right]=\int_{-\infty}^{\infty}f(\xi)e^{-i\cdot w\xi}\mathcal{F}\left[g(x)\right]d\xi=\mathcal{F}\left[g(x)\right]\int_{-\infty}^{\infty}e^{-i\cdot w\xi}f(\xi)d\xi\] And the remaining integral is the Fourier transform of , so: \[\mathcal{F}\left[(f\ast g)(x)\right]=\mathcal{F}\left[f\right]\cdot\mathcal{F}\left[g\right]\] Convolution in the time domain translates to multiplication in the frequency domain! This result is so important in signal processing that it’s called the convolution theorem . Appendix A: Riemann sum and the definite integral Suppose we have some function and we want to know the area bounded between this function’s graph and the x axis in a certain interval [a,b] . One way to do this is to take a partition of the interval: \[a=x_0<x_1<\cdots<x_{n-1}<x_n=b\] And calculate the area under for every element of the partition. We can then approximate such sub-areas by rectangles, as follows: We’ll denote the area of each rectangle as f(x^*_i)\cdot\Delta x : \Delta x=(b-a)/n is the width of one interval (assuming a uniform partition, but the math works just as well for non-uniform ones). x^*_i is some value in the interval [x_{i-1},x_i] .

0 views
André Arko 3 days ago

jj sales pitch

A new jj tutorial, summarizing a many of the reasons jj is so useful, and powerful, in just a few paragraphs. jj improves upon Git by achieving two opposing goals at the same time: it has a simpler mental model and command set, but simultaneously provides more powerful functionality. jj simplifies Git not by hiding complexity, but by using a better conceptual model. – Evan’s Jujutsu Tutorial

0 views
Karboosx 6 days ago

How to Create Your Own Decentralized Messenger Protocol

Ever wondered how to build a decentralized messenger without any central servers? It's all about federation - just like email! In this post, I'll show you how to design a simple protocol from scratch, from server discovery using .well-known files to handling end-to-end encryption.

0 views
Giles's blog 1 weeks ago

Writing an LLM from scratch, part 34b -- from bigrams to GPT-2, one component at a time (in JAX)

This post is the capstone of the most long-running series on my blog . In December 2024 (!), I started reading Sebastian Raschka 's book " Build a Large Language Model (from Scratch) ", and worked through it carefully. Being who I am, despite trying to apply a strict "no side quests" policy, I found myself zooming off and digging into all kinds of things. It's time to wrap it up. I had decided that the endpoint would be to build and train an LLM from scratch just using my notes -- no reference to the book, no reference to the model code I'd written when following the book. After an X/Twitter poll, I decided to use JAX for that, just to make sure that I really was building it from scratch and not regurgitating bits of PyTorch code like a bad coding LLM spitting out half-digested lumps of Stack Overflow. In my last post , I showed how I built a JAX training script that mirrored what I had built for the original PyTorch version of the model. To test it as I went along, I used it to train a really dumb "LLM", which instead of trying to predict the next token for every token in an input sequence, instead predicted the input -- that is, if you fed it It would return the same thing. I called that an A-to-A model. In this post, I'll show you how I turned it into a GPT-2 model, and then trained it from scratch on my RTX 3090 (using the parameter counts for the original paper's "small" size). What turned out really well with this is that I found a route that meant that almost every component I added made the model better! That's not guaranteed -- sometimes different aspects of an AI model depend on each other, so adding A without also adding B makes things worse. But (admittedly with a bit of backtracking in places) I was able to find a route that shows a nice clear progression. The final training run took 37 hours 15 minutes -- compared to 40 hours, 38 minutes for an equivalent PyTorch model . That is despite it being full-fat 32-bit -- the PyTorch one was using Automatic Mixed Precision (AMP), which allowed it to use 16-bit calculations in places where it would be relatively harmless in terms of loss. When asked to continue "Every effort moves you", it came back with a decent response: The model got 3.418784 loss on my held-back test dataset, as compared to my PyTorch model's 3.538161, and even more impressively, it was better than the original GPT-2 small's result of 3.499677 on the same dataset! However, just as I found previously , the OpenAI weights still beat mine consistently in instruction fine-tuning challenges. Let's get started. At the end of the last post, we had a solid training loop, using all of the tricks I'd picked up with my PyTorch code. The A-to-A model we were training with it looked like this: That was based on my preferred model of how LLMs work , where at the top level for a model, we feed in a sequence of token IDs, then: The A-to-A model basically skipped the second step completely: it would project to embedding space, then immediately project back to vocab space -- and after training, it was pretty good at mapping a sequence to itself. One interesting question is, if we train the same code, but this time try to get it to make next-token predictions, how good will it be at that? Obviously it can't be as good as a full LLM. But there are correlations between tokens; full stops will generally be followed by spaces, adjectives will normally be followed by other adjectives or nouns (at least in English), and so on. It would be kind of like the predictive text systems on a phone, where (at least until recently) it would just use the last word you entered to generate a list of possible next words to select from. Old-school natural language processing has a name for this: bigrams. The idea is that you can work out statistically what the most common two-word pairs are, which allows you to make a guess at a next word from a single one. (There are also trigrams, where you look at the last two words when predicting the next, then 4-grams, 5-grams, and so on.) You'd build up a full probability table -- for every word in your vocab, you'd have the probability of every word coming next. So maybe even with that minimal model, we could get it to learn something similar to a set of token-level (rather than word-level) bigrams, which would then get the loss down. Obviously it wouldn't be as good as a full bigram table -- for our GPT-2 vocab size of 50,257, that would need 50 , 257 2 = 2 , 525 , 766 , 049 parameters -- but perhaps it could approximate one. (For comparison, the model we're using has just an embedding table and an output head, each mapping between 50,257 dimensions and 768, so that's 2 × 50 , 257 × 768 ≈ 77  million parameters -- about 3% of the full table.) An uninitialised model would (hopefully) have a loss of about 10.82, implying a perplexity equal to the vocab size. If we can train our dumb model to get better loss than that, then we'd have the beginnings of an LLM. That was a simple test to run. In my training code, I had a dataset class that looked like this: That is, the inputs, the , were the same as the targets, the . If we fed it ...then we'd be training it to output exactly the same thing. The modified version for a real LLM would involve feeding it something like this: ...and targeting this: That's a simple change -- that method became this: I did that, and kicked it off to train on the 92,209,152 tokens that I was (somewhat arbitrarily) using in the last post to test my training loop. The loss chart looked like this: That was pretty promising! Loss came down from roughly 10.82 down to a fairly stable 6 or so by global step 768, and seemed to flatten out there. It's possible that further training could have got it down a bit more, but I decided (again, somewhat arbitrarily) to use the average train loss in the checkpoint period ending at step 937 as my starting point. If we could make changes that reduced that, then we'd be moving forward. For this model, that value was 5.909. So, what were the changes we needed to make to change our bigram-style model to a real, if small, LLM? Adapting from my how LLMs work post, a GPT-2-style LLM looks like this. We receive our sequence of token IDs, and then: Inside the Transformers blocks, we: So that gave me the checklist; looking at it, the most tempting next step was layer normalisation (henceforth LayerNorm). It's used at the end of the core loop, and then twice in the Transformers blocks. What would happen if we coded it up, and then added it to the core only? The purpose of LayerNorm is to stabilise training. We constrain the values flowing through our model so that they have certain statistical properties that tend to make the whole thing more trainable. That would mean that if it did help with this model -- placed in between the embedding layer at the start, and the output head at the end -- then we'd hope for loss to go down faster, and ideally finish at a lower level. Time to code it up! NNX has its own LayerNorm implementation , of course (as does PyTorch ), but in the book, we implement it ourselves, and that felt like the correct path to take. Firstly, I implemented a dummy version: ...and updated the core to create and call one: And kicked off a training run for a few seconds just to make sure that it hadn't broken anything and that loss dropped -- being my first NNX module-inside-a-module, I worried that there might have been something non-intuitive that I had to do to get it to work. But everything seemed good -- loss was dropping, no errors. So, following the notes I made when I first learned about LayerNorm , I needed to make the values flowing through centred around zero by subtracting their mean, and then scale them to have a variance of one by dividing by the standard deviation (details in those notes). The shape of the I had coming into my class's was this: That was . So we needed to do those operations strictly on the last axis, manipulating each embedding independently. JAX has a function and a one , both of which take an parameter. The object repackaged those as methods, which was convenient, so I did a first cut test like this: That printed out these results: ...which looked plausible; one number for each embedding vector. Could we broadcast them across the array? This blew up: Fair enough. But and have a kwarg that looked like it would help: ...and it did! Excellent. So the next step was to see if that would work even slightly. Interestingly loss started off a bit higher at 11.29 after the first global step -- so adding in the LayerNorm had actually made the model worse than it was -- but it seemed to be falling rapidly. Things weren't totally broken, at least. But there was more to LayerNorm than just zeroing the mean and scaling to the variance; we also needed to scale them up by a learnable amount, and then shift/bias them by adding on a different trainable amount. More precisely, both of those trainable amounts were different for each of the (in this case) 768 embedding dimensions. We needed two learnable vectors of length . I hadn't noted it down at the time but I figured (as it turned out, correctly) that a sensible starting point for those values would be all-zero for the bias, and all-one for the scale. From this help page , the way you create a trainable array associated with an NNX module is this: That code created a random vector, rather than the zeros/ones we needed, and we'd need to get the dimensions right. Because of the "Incompatible shapes for broadcasting" error I'd just had, I was feeling a bit paranoid about the latter, so I chose a shape of , and wrote this: That looked pretty plausible, though in retrospect I think I was being overly cautious and didn't need the leading two axes for the scale and bias. The only thing I was unsure about was whether the wrappers I had put in were really making those arrays trainable. I put some code in to print them out and kicked off a run for a few minutes, and confirmed that they were changing in ways that seem plausible -- small non-zero bias, scale close to but not equal to one. That was all good! Next, I spotted one issue. What if one of the standard deviations was zero? That would lead to a divide-by-zero error here: Now, the standard deviation, if it's not zero, has to be positive -- so adding on a small value would fix that 1 : With that in place, I felt that it was ready to go. Time to do a full training run! I kicked that off, and it completed with this output: Loss looked like this: Let's look at the results for the previous run without LayerNorm for comparison: You can see that the new run, the first one, drops faster. It's harder to see from the chart, but it also finished up with a lower training loss at 937 (my relatively arbitrary metric): 5.734 rather than 5.909. That was interesting! The new model was basically doing the same thing -- predicting the next token based only on the "current" token, but loss was lower. My take is that if we had trained the non-LayerNorm model for longer, it might have managed to eventually grind out a better loss. But LayerNorm was doing its job -- it was stabilising training, and as a result we converged faster. That was a win! I decided to run it through my old smoke test from the PyTorch training runs, and see how it completed "Every effort moves you": It was kind of impressive that it managed to finish the first line before it got stuck in a loop -- but it was understandable that we couldn't expect anything good yet. Each predicted token was based entirely on the token before it. What next? Back to our checklist: Inside the Transformers blocks, we: So, at this stage, for each input token we were predicting the next one based on the input token only -- like I said earlier, we were doing a somewhat roundabout way of building an approximation of a table of bigram probabilities. What would happen if we started paying attention to the tokens to the left? And what would be the simplest, dumbest way to do that? The real LLM has multiple layers of multi-head attention, each one also having a feed-forward network, some LayerNorms, and some shortcut connections. Single-head attention is easier to code, but even on its own, you'd expect it to be able to add some value. Each token would get at least some information from the ones to the left. And one layer, likewise, you'd expect might help a bit. I suspected that it wouldn't work on its own -- I expected I'd need shortcut connections too -- but decided to start with attention on its own. I modified the main class to have a single "Transformers" layer: ...where that layer was actually just single-head attention: Next, it was time for the class. I'm not going to write yet another attention explainer -- I think my "How do LLMs work?" one does a decent job of that, and "The 'why' of attention, or: attention heads are dumb" works well too. So in the next bit I'll assume that you understand the basics. My first cut was basically just the maths (up to the causal mask) to get the attention scores: It did the projections into query, key and value space, worked out the attention scores with the array multiplication, normalised it by dividing by the square root of the number of dimensions in the Q-K embedding space, and then zeroed out the scores where a token was attending to tokens in its "future". There were a couple of problems, though. Firstly, that wouldn't work if we were working with batches, and secondly, zeroing out the non-causal scores wasn't quite correct. The batches first. Our incoming here would have the shape . After the projections to the Q-K embedding space, both and would also be shaped . Now, the property on the JAX array class just reverses the axes, so the code above would give us with the shape . That would break! Matrix multiplication in JAX expects all but the last two axes to represent batches, so we actually wanted to have the shape ` . That meant that what we actually wanted was to just transpose the last two axes. The JAX function takes an parameter that allows you to specify the specific re-ordering of the input axes that you want. So I could rewrite the code like this: As would have the shape , and the transposed version of would be , they'd be compatible for matrix multiplication and give us a result that was -- just what we wanted for attention scores. The next step was to fix the causal mask. The next step in this attention mechanism was going to be running the causal attention scores in through softmax over the last dimension, to convert them into attention weights. Now, our current code was zeroing out unwanted acausal scores, but a zero still contributes to softmax. If you want a particular value to come out of softmax guaranteed to be zero, you need to set it to minus infinity. I decided that the easiest way to do this was to create a causal mask -- a boolean array that matched the size of , but was full of s: Then I could zero out (well, "false out") the cells in the mask related to unwanted future-facing scores, just like I was previously doing on the scores: ...and then I could apply that mask to omega with , telling it to create a new array, taking the value from where the mask had , and in places where it had . That seemed solid, so I just needed to run the result through , specifying that the last dimension was the one where it should apply the function, and that would give me the attention weights: Finally, I just needed to use those attention weights to get the attention output by mixing in appropriate portions of the projection of the inputs into value space, : As was shaped , and (like and ) was shaped , the batch axes were at the start where they belonged, and the matrix multiplication would work and return something shaped . With that, we were done! The final single-head attention class looked like this: I kicked off a training run with that, and it did work, in that loss went down over the course of the run -- but at the end of the run, the loss at step 937 was 5.934 -- significantly above the 5.734 I got on the previous run, with no attention. But that made sense! As I'd said earlier, I suspected that this wouldn't help if we had no shortcut connection. Intuitively, if you want to work out what token should be at position n + 1 , on average the most important other token you need to know about is probably whichever one is at position n . Knowing about the tokens at n − 1 , n − 2 , and so on, could well be helpful -- maybe very helpful -- but not at the cost of not knowing about the one at n . Now, single attention heads are just simple pattern-matchers. They can't learn complex rules, it's only by working together -- "horizontally", in multi-head attention or "vertically" across multiple layers -- that they can do complex things. What we were asking this head to do was to learn some way of gathering information about previous tokens, and also to keep the knowledge about the "current" one. That's a tall order for a dumb attention head! In my mind, this is a large part of the benefit of shortcut connections. They are often presented as a way to make sure that during training, gradients flow smoothly from the output end of the model to the earlier layers. But I prefer to think of them as preserving the original embeddings, so that each layer doesn't completely replace what came into it, but instead does something closer to adding on its own notes -- like scholars adding commentary to a core text in the Talmud . In the training run above, the attention head was trying to learn how to preserve the meaning of the embedding it was working on, while also merging in information from earlier ones. If we added a shortcut connection, then it would only have to do the second of those two jobs. The code was simple: I updated the module to do a shortcut connection: I kicked off a training run, and at the end it printed this: The loss chart looked like this: And, importantly, that training loss at step 937 which I was using as a metric was 5.553 -- a decent improvement over the previous best of 5.734. Even a dumb single attention head was able to do something useful, if it had a shortcut connection. I decided to run another qualitative smoke test: I mean, it was repetitive, but it was actually getting noticeably closer to making sense! So that was excellent news. What next? Our checklist looked like this: Inside the Transformers blocks, we: Now, our single attention layer was lacking something. Without position embeddings, that layer has no idea what order the tokens before the one it's looking at come in. If it's considering the " cat" in ...it doesn't know if it's looking at "The fat cat" or "fat The cat". Position embeddings are simple, and might help, so that was the next step. These were trivial to add. We had this core code: So I just added a position encoding module in : ...and mixed it in with the token embeddings to create new, improved to be used in our "Transformers" layer: I kicked off a training run with that: Pretty hard to distinguish from the previous one, but the metric I was tracking, that loss at step 937, had improved again! We were down to 5.354 from 5.553 :-) A quick qualitative smoke test didn't show that improvement, though: Pretty much indistinguishable to the previous one. But still, Loss Number Went Down, and that's what was important at this stage. It was time to try the next step. From the checklist: Inside the Transformers blocks, we: We had only one attention head right now. Individually, attention heads are dumb , so switching to multi-head attention seemed like a good thread to pull. At this point, my single-head attention code looked like this: I decided to re-implement multi-head attention (which I'll call MHA from here onwards) from first principles rather than working strictly from my notes, and then to come back and check it. If you're looking at your browser's scrollbar with horror (" still only 50%?!") and really don't want to read a full derivation of MHA, you can skip straight to the first complete version of the code . The point of MHA is that we're running multiple copies of the calculation above in parallel -- let's pin down the name of the number of copies as . Now, we could naively implement it just by spinning off threads and running the existing code in each, but that wouldn't really take advantage of the GPU's inherent parallelism. I felt that we could rely on the fact that JAX's matrix multiplications treat all but the last two dimensions as "batches". For example, if you have two arrays with shapes: ...then you can multiply them. A m × n matrix multiplied by a n × p one will be m × p , so you'll get something that is The other dimensions (so long as they match) will essentially act as an a × b × c × . . . × l batch. Now, right now we were just using a single batch dimension. Let's look at the core multiplication in the attention mechanism, which works out , the attention scores. I had this: Breaking that apart into two steps: We got from this line: Let's look at the shapes here. is our input embeddings for this layer; its shape is . Projecting it through , which is shaped gives us a shape for of again. , being a projection of through , which is the same shape as , will have the same shape as . Now, that means that is , and the calculation ...is doing a batched matrix multiplication getting us the that we want, shaped . But as I said above, there's no need to stop with just one batch dimension. Let's say that we have heads, and that they each work with embeddings sized . Imagine that we've already somehow done multiple projections into the key and query spaces for each of our heads, and that the results have somehow been put into arrays such that and are shaped -- that is, we've gained an extra axis that keeps the projections for each head into its query-key space separate. We could use the fact that both of those two leading axes are basically just batch dimensions, and the existing single matrix multiplication will still work, with one tiny tweak: the current transpose is this: ...to swap around the last two axes of a three-axis array. With one extra batch dimension, we'll need to take account of that and do this instead: That will be a multiplication of , shaped , with , shaped , which gives us an of the right shape, . So, if we can start treating the heads as just another batch dimension, things seem simpler, at least for the attention score calculation. Let's continue down through the single-head code, and then come back later to how we might get the inputs into that double-batched shape. The next line after the calculation just scales the attention scores by a scalar: That looked fine, just a broadcast division-by-float. We'd need to change that to be in some manner, but that's all. The will give us an array that's full of s. That seems reasonable. The next step: What will that do? Well, per the documentation : When , operates batch-wise on the trailing axes. ...which sounded good. and would be treated as batch axes, which meant that the next line: ...would work. Likewise, with the next line: ...the axis to apply to is explicitly stated as the last one, which is what we wanted. So at the end of all of those steps, we'd have shaped , where the last axis had been softmaxed (softmaxxed?). The next line looked a little trickier: In the single-head version we had of shape , and V of shape , so multiplying them gives us In the new MHA code so far, we had our shaped . So in order for the matrix multiplication to work, we'd need to be shaped . That would give us a result shaped as . And conveniently, we'd already decided that the correct shape for and for was . If we could use the same "magic" to do the projection into value space -- that is, to get such that the heads formed a new batch-like axis like we had for and -- then we'd be all set. So, at that point, I'd worked out the core of MHA. If we could get all of the inputs into the shape , and somehow handle an output of the shape , then we could use MHA code something like this: The next question was, how do we get our inputs into that shape? We could run them all through separate per-head weights -- that is, have an array with one per head, like , and for the first one. But that, again, felt like it would be failing to take advantage of the GPU properly. The solution was to think of how matrix multiplications work. If you multiply two matrices, X · Y , the value in the result, in row r , and column c , is the dot product of row r in X and column c in Y . So, imagine if you wanted to multiply X by n different versions of Y , let's call them Y 0 , Y 1 , and so on up to Y n . If you imagine a new matrix, Y all , which is basically all the Y x s stacked side-by-side, then the dot-product understanding of multiplication makes it pretty clear that if you did X · Y all , you would get the results of all of those separate multiplications, also stacked side-by-side. I'll call that kind of matrix a "striped" one, for want of a better word. Now, when we project our inputs into the embedding spaces used for attention, we have code like this: We've initialised the weights, in this case, as an , so what is happening under the hood here is basically: That is, it is just a matrix multiplication. 2 So if we imagine that is one of those "striped" matrices, holding all of the separate matrices to do the projections for all of the heads in a single one shaped , then we could stick with the current code -- the Our input would be shaped , so the result would be , and would have the projections for each head in the same vertical stripes as the separate heads' projection weights. Now, like PyTorch, JAX allows you to reshape arrays. You can take one axis of length (say) m × n , and split it into two of lengths m and n respectively -- or, conversely, you can combine two axes of length m and n to one of m × n . If our data had the shape , we could reshape it like this: ...and that would split things up. So we'd have Q shaped as . That's almost what we wanted! We needed , and a simple transpose could sort that out: Likewise for and , and that was our inputs sorted. Moving on to the output; it came from this: ...and as we worked out above, it was shaped . I remembered that we wanted to run that through a single linear layer to combine all of the different heads' outputs into one. It felt like the best way to do that would be to get it back into a "striped" layout: . This would be something like the inverse of the input-wrangling. That would need a reshape, but before I could do that, I'd need to get the axes that needed to be merged next to each other. If the input to the linear layer was going to be , we'd need to convert it from to first: ... and then we could just reshape it to : Finally, we could run it through a linear layer, with set to , and set to . I put that all together, and decided to throw something extra into the mix. I remembered that Raschka's code had various checks to make sure that , which seemed a little artificial -- I'd read that this was true of GPT-2, but wasn't a necessary restriction for GPT-style models, which makes sense. There's no obvious reason per se why the heads' embedding dimensions should sum up to the higher-level embedding dimensions. So I decided initially to just pass in and to the constructor. In my training script I could force them to match the GPT-2 model, but if I wanted to use the code later for something different, I could vary them. Then I remembered that although the dimensionality of the embedding spaces for the query and the key vectors have to match (because otherwise you can't multiply them to work out attention scores with Ω = Q K T ), the value vector's dimensionality can in theory be different. So I decided to break into two separate and parameters. The result was this: Unusually for a case where I went off the reservation like this, the whole thing with the embedding space dimensionality didn't cause any problems at all! But there was one small bug in this code, which I didn't discover until later -- we'll come to it by the end of the post. At this point, I did another of my short training runs, and: ...with a loss chart that looked like this: The training loss at the 937th global step was 5.336, only a tiny bit better than the 5.354 with single-head attention. That was quite possibly within the noise. Even though (due to the restriction I was enforcing in my training script) the , , and arrays were the same size, I was creating that , which would consume randomness and make things vary. If I were doing a proper scientific experiment to see if a single layer of MHA beat a single layer of single-head attention, I think I would have run both for more steps to see if the difference became more pronounced later. But for the purposes of this post, I decided to move on. My checklist now looked like this: Inside the Transformers blocks, we: Adding that simple neural network -- the FFN -- seemed like a good next step. The feed forward network is simple; you take the output of the MHA block, run it through a biased linear layer to expand it from to , then run it through the GELU activation function, then shrink it back down to with another linear layer. I didn't really see any value in writing my own implementation of GELU, given that even in the book we were just given code for an approximation to type in. So, using , I wrote this: Note that I added in a shortcut connection around the FFN as well, so that it didn't overwrite what was there, but only "added on its notes". I kicked that off, and it ran for ten minutes or so, but then OOMed: Adding didn't help. I spent some time trying to dig into what might be causing it, but eventually noticed something interesting: in , the VRAM usage was consistently 75% throughout. Now I knew that JAX pre-allocates 75% of VRAM when it starts up, but I'd been assuming that it would try to grab more if it needed it. It turned out I was wrong with that assumption -- it grabs 75%, but that's all you ever get! The solution turned out to be the environment variable. If you set that to, say, , then JAX will pre-allocate 90% of the VRAM, and you can use all of that. (You can also make it allocate as-needed with , and there are various other settings you can control with other environment variables on that linked page). Anyway, setting it to to grab 90% of VRAM worked, and I was able to get a successful run: The loss chart was this: ...and the training loss at global step 937 was 5.295, compared to the 5.336 from MHA alone. Another tiny improvement, another one that could have been in the noise. Again, if I were doing a proper experiment, I'd do a longer run, but for now, I decided to move on. The checklist looked like this: Inside the Transformers blocks, we: Now, my gut instinct was that the layer normalisation inside the Transformers blocks was of most value as a way of stabilising training over deep networks. And with one layer, it didn't seem like the right time to add it. Instead, I decided to add on multiple layers. For GPT-2 small, you have 12 layers. That was already being passed in to my 's method as , so I just replaced this: ...with this: ...and then just renamed it where it was called; this: ...became this: I kicked it off, and it completed! However, the loss chart was telling: Ouch. Loss started dropping quite nicely, but then things got out of control and it settled down at a loss that was essentially that of a random model. At step 937, we were at 10.75, so just a hair less than the 10.82 that randomly guessing next tokens would give. Well, LayerNorm is specifically meant to stabilise training, and the checklist looked like this: Inside the Transformers blocks, we: ...and the only remaining step was that LayerNorm in the Transformers blocks, so it was time to add it in! As per the checklist, we do the LayerNorm after we've taken our copy for the shortcut connection, just before MHA, and then likewise after the second shortcut copy, before the FFN. As I understand it, this was a GPT-2 innovation -- previously, people had done normalisation after those steps, but this pre-norm setup turned out to work better. The code changes were simple. I added two modules to the class, and then called them in the appropriate places (taking the opportunity to tidy up the variable naming in the forward pass while I was there): I kicked it off and ran it, and got these results: That certainly looked much healthier! However, when I looked at the loss at step 937, it was 5.311 -- a tiny bit higher than the single-layer MHA example, which got 5.295. I'd been willing to play a bit fast and loose with this loss number and allow myself to accept a win when the loss went down a tiny bit, even if it was such a small amount that it could have been within the noise. But increasing loss -- even if it could also be within the noise -- was a step too far. I decided that in this specific case, I'd be strict and test the hypothesis that longer training runs would demonstrate an improvement between one single layer without pre-norm, and multiple layers with pre-norm. I had to remember that these training runs would not be comparable with the earlier ones. In the training script, I had a learning rate schedule like this : That straight-line warmup period and the following cosine decay were 5% and 95% of the training run respectively, which meant that (for example) global step 937 of the short runs we had been doing would be at a completely different point in the schedule than the same step would in these longer runs. However, they would be comparable to each other, and that was what mattered. After some humming and hawing, I decided that a full Chinchilla-optimal (for the full model) training run over 3,260,190,720 tokens, rounded up to fit into a round number of global steps, would be a nice experiment. I expected it to run comfortably overnight for the single-layer run, and take a bit less than two days for the multi-layer one. So I kicked off the first. Just over 11 hours later: Here's the loss chart: The last checkpointing period in that run ended at global step 33,164, and the training loss then was 4.165 -- indeed, it had been at around 4.17 for quite some time, though the trend still seemed to be a tiny bit downward. So then I kicked off a run of the full version -- multiple layers, with pre-norm in the Transformers blocks. Just over 37 hours later: The "Final train loss" line at the end said it all, really! But here's the loss chart: ...and the loss at step 33,164 was 3.399. Definitely quite an improvement over the 4.165 that a single layer got. Again, at some point I might do the equivalent tests for the earlier results where improvements appear to be pretty much in the noise. It would be good to be sure that the changes really did have the impact I think they did. But for now: our checklist was looking like this: Inside the Transformers blocks, we: Everything was checked off. So was this journey over? Well, there was one thing that the original PyTorch code had that my new code didn't: dropout. I'd found in my lengthy interventions experiments that dropout seemed to make models worse. It was, I felt, a smart idea back in the days when people had little data and did multiple epochs, each sweeping over everything, but it made less sense nowadays with single-epoch training runs over very large datasets. (Though I do have some intuitive ideas about why it could still help .) Still, it would be good to show that it harmed loss for this model as well. Checking my notes, I found that there were four places where dropout was applied: The changes are tiny and rather dotted around the code, so rather than showing you isolated bits of code, if you'd like to see it you can take a look at the code at this point and search for "dropout". When I started running that, I got an error when saving the first checkpoint: This was happening deep inside the bowels of Safetensors, but it made a lot of sense. The object needs to keep track of the state of the random number generator, and that meant that the function that I was using might return a structure that had something that contained that state, and was not compatible with Safetensors. I decided that I'd cheat a little bit here. If I skipped the dropout layers when I saved my checkpoints, like this: ...then I'd be able to save them. This would have a problem -- if I restarted from a checkpoint, the dropout pattern after the restart would mirror the dropout pattern from the start of the training run, because the random seed it started with would not have come from the checkpoint, but just the initialisation code. I felt that this would not have a serious impact, though, and given that I'd not had to restart from checkpoints so far, I (wrongly, as it turned out) decided it wouldn't matter. I kicked off the run, and... after four hours, it OOMed. I cursed, decided that I'd nurse this run through anyway (despite my dropout checkpointing concerns), and kicked it off again. Three hours later, it OOMed again. I happened to be away from home at the time, logging in to my machine remotely (thanks, Tailscale !), and on looking at , I realised that the X window system on my machine was using a gig or so of VRAM. I was running the training run in a session, which meant that I could kill X and not lose state, so I did that, and adjusted the environment variable I was using -- it had been 0.90, so I bumped it up to 0.95. I kicked it off again, and... Note that the tokens seen only relates to the period since the restart, which is why it was lower. One more loss chart: ...and the training loss at step 33,164 was 3.524, higher enough than the 3.399 I got without dropout that I was comfortable that it wasn't in the noise. That was very reassuring. Once again, if this was a proper scientific experiment I'd fix the issue with saving dropout, and run it completely from scratch -- or, at least, run it all the way through from scratch without restarts, even if I had to try several times to get it done. But I don't think that "replaying" dropout would make the loss any worse. And for this experiment, I felt this was enough. So: checklist complete. GPT-2 model coded up. It was time for some evals! I wanted to evaluate these models against the ones I got using the old PyTorch code: specifically, the last local training run that used exactly the same training hyperparameters, and only differed in that it was trained using AMP -- 32-bit floats in general, but using 16-bit where the framework thought it would not be harmful. In order to do exactly the same evals, I decided it would be easiest to write a conversion script to take the Safetensors files written to my JAX checkpoints, and write out new files that were compatible with the PyTorch model code -- then I'd be able to use the original PyTorch eval code. I put something together , converted my last two models -- the full runs with and without dropout -- and tried to load them up. Unfortunately there was an error: You might remember that back when I went through multi-head attention, I mentioned that I'd made a mistake. Somehow, I'd misremembered, and thought that the output projection -- the one that mixes together all of the different heads' outputs -- was a linear layer without bias, despite my original notes being perfectly clear that it did have bias. The good news was that if I disabled bias in the PyTorch code, I could load the safetensors files that I had. So the two models I'd trained so far were not useless, and could actually work as a kind of natural experiment into the benefits of having that bias there. But anyway, in order to do things properly, I was going to need to fix the bug and train yet another model. The fix was simple, I just replaced this (in ): ...with this: Then it was time to kick off yet another training run. After another 37 hours: ...with this loss chart: ...and the training loss at step 33,164 was 3.398 -- almost exactly the same as the 3.399 that I got in the no-dropout training run without MHA bias above! Well, now it really was time for the evals. I updated my conversion script to handle the bias on the MHA output projections, and used it to convert the three models -- the un-biased ones, with and without dropout, and the biased one, without -- to the PyTorch format, then ran the loss test that I had been using to compare the old models on each. Here are the results, compared to the previous models, and OpenAI's: That was a pretty amazing result -- I'd clearly proven that JAX trains much better models than PyTorch! 3.5% better in the best case. Well, OK, no. My guess is that the difference was probably something like better luck with the initial weights on the JAX side, plus the improvement from not using AMP . Anyway, the important thing was that the JAX models were in the same kind of loss range as the PyTorch ones -- and while a 3.5% improvement in loss was more variation than I'd been expecting, it was definitely the right ballpark. Now, one thing I had found in the past was that the OpenAI weights -- and some of my own models, like the Fineweb-Edu ones -- were consistently better at an instruction fine-tuning test than their test loss scores would indicate. Would that hold here? The IFT eval code fine-tuned each model on the Alpaca dataset until validation loss started rising, then used the model prior to the start of the rise to generate responses for a test set. These were saved, and then run past an OpenAI model so that they could be compared with each other: ...with the model order randomly changed for each query to avoid any position bias. The methodology seemed solid, but I was uncertain about the "train until loss starts rising", as it meant that different models had wildly different amounts of fine-tuning -- between two and seven epochs. On the one hand it felt "unfair" to certain models that they'd get less training than others. On the other hand, if the less-trained models had been trained past the point where their validation loss started rising, then assuming that loss would continue to rise, further training would actually be a disadvantage rather than an advantage. I decided to stick with the original plan, and train until validation loss started rising. I did, however, switch the judge model from the GPT 5.4 that I used in my last IFT test to GPT 5.5. Here are the results: More interesting datapoints! As before, you can see that low loss is not particularly well-correlated with a high score on this instruction fine-tuning test. The OpenAI weights continue to lead the pack, and while one of our new JAX models did quite well, it's still beaten by the Cloud FineWeb, 8x A100 40 GiB model. But what was important here, just as with the loss, was that the new JAX models landed in the same ballpark as the PyTorch ones. They did, and so I could be confident that they were doing essentially the same thing. And that meant that, after 18 months, I had reached the end of my LLM from scratch journey. It's been a long trek . I started reading "Build a Large Language Model (from Scratch)" on 22 December 2024. I was planning to breeze through over the Christmas break, but somehow it morphed into being a curriculum onto which I could hang projects to learn the fundamentals of LLMs, beyond what was in the book. In May 2025, I had my first real conceptual breakthrough when I realised that attention heads are (individually) dumb , and as I continued, the second big one came later on in the same month, when the concept of embeddings as being projections between vocab space and embedding space (and the converse projection in the other direction that happens in the LLM's output head) became clear. In August I had the first moment where I felt that the standard teaching approach to LLMs might not be the full story; shortcut connections are normally explained as a way to fix vanishing gradients, while I felt that a better way to see them was a way to allow attention and the FFN to "annotate" the existing information, similarly to how Jewish scholars have annotated the original text of the Talmud . (The results in this post seem to point in that direction, given how even a single layer of attention was massively helped by adding them.) By early December, I had essentially finished the book, and felt I wanted to try to train my first base model from scratch on my RTX 3090 . It worked, and wasn't far off the quality of the original GPT-2 small. I was really surprised that I could do that with consumer hardware, and became interested (perhaps obsessively so) with whether I could match OpenAI's weights. In January 2026, I trained a model using DDP on Lambda Labs , and then spent the following months training model after model, trying to work out which interventions -- learning rate scheduling, gradient clipping, etc -- would improve the loss. I wrapped that up in late April , with the interesting finding that although I'd been able to get the test loss pretty low, that didn't seem to map cleanly to performance in my instruction fine-tuning tests. In other words, Loss Number Goes Down is an interesting technical game to play, but doesn't cleanly map to real-world performance. The final step was this post, and the previous one -- could I, using my notes, implement GPT-2 completely from scratch in JAX without referencing the book? And as you've read, the answer was a definite yes! Of course, as with any long-running project, there are some loose ends -- from this post alone, there's the interesting fact that JAX trained faster than PyTorch (perhaps could close the gap?) and had a larger possible batch size for full-fat 32-bit. And the fact that fixing the multi-head attention bias bug didn't seem to help with the loss much was interesting too. But those are really details, and there's so much beyond them to learn. Longer-context LLMs: position embedding improvements like RoPE, efficiency tricks like flash attention and attention variants like DSA. Mixture of experts models. How do optimisers really work? ( Do they work? ) And plenty more. So it's time to draw a line under this series, and start thinking about what comes next. It's been a blast; if you've been reading along, I hope it's been as useful (and fun) to read as it was to write. And as always, comments, questions and corrections very welcome below. On looking back at Raschka's code, after having worked through all of this, there's a slight difference. I do this: ...whereas he does this: Now, the standard deviation is the square root of the variance, so if you ignore the small numbers -- in my case, and in his -- the calculations are the same. But there is a difference once those are taken account of. I don't think it's large enough to have any serious effect in these runs, though.  ↩ In PyTorch, linear layers are stored as the transpose of the matrix that would allow you to do that, so it would be: Also, note that for simplicity (heh) I'm disregarding bias in this discussion.  ↩ Firstly, we convert them into embeddings, so we get a sequence of vectors, one for each token. We do this by a lookup into a table, but we can see it conceptually as a projection via a matrix, from vocab space (where a particular token ID is a one-hot vector) to embedding space. Next, we do the magic with our Transformers layers, getting embeddings for the next token. After these layers, the embedding at position n in the output sequence is for the predicted token to come after the token at position n in the input sequence, considering that input token and all other tokens to its left. Finally, we project those back from embedding space to logits, this time actually using a real matrix (in the form of a linear layer), the output head. The logits (after being run through softmax) represent the probabilities for each token of it being the next one. Convert them into embeddings. ✔ ️done Add on position embeddings. Run these embeddings through multiple successive Transformers blocks. Layer normalisation Project them back from embedding space to vocab space. ✔ ️done Take a copy of the input sequence of embeddings Layer normalisation Run multi-head attention Add the copy back in so that the version that came out of MHA is something more like an "annotation" of the original Take a second copy of that one Layer normalisation again Run it through a simple neural network Add the results of that back in. Convert token IDs into embeddings. ✔ ️done Add on position embeddings. Run these embeddings through multiple successive Transformers blocks. Layer normalisation ✔ ️done Project them back from embedding space to vocab space. ✔ ️done Take a copy of the input sequence of embeddings Layer normalisation Run multi-head attention Add the copy back in so that the version that came out of MHA is something more like an "annotation" of the original Take a second copy of that one Layer normalisation again Run it through a simple neural network Add the results of that back in. Convert token IDs into embeddings. ✔ ️done Add on position embeddings. Run these embeddings through multiple successive Transformers blocks. part-done -- one layer only Layer normalisation ✔ ️done Project them back from embedding space to vocab space. ✔ ️done Take a copy of the input sequence of embeddings ✔ ️done Layer normalisation Run multi-head attention part-done -- single-head attention only Add the copy back in so that the version that came out of MHA is something more like an "annotation" of the original ✔ ️done Take a second copy of that one Layer normalisation again Run it through a simple neural network Add the results of that back in. Convert token IDs into embeddings. ✔ ️done Add on position embeddings. ✔ ️done Run these embeddings through multiple successive Transformers blocks. part-done -- one layer only Layer normalisation ✔ ️done Project them back from embedding space to vocab space. ✔ ️done Take a copy of the input sequence of embeddings ✔ ️done Layer normalisation Run multi-head attention part-done -- single-head attention only Add the copy back in so that the version that came out of MHA is something more like an "annotation" of the original ✔ ️done Take a second copy of that one Layer normalisation again Run it through a simple neural network Add the results of that back in. Convert token IDs into embeddings. ✔ ️done Add on position embeddings. ✔ ️done Run these embeddings through multiple successive Transformers blocks. part-done -- one layer only Layer normalisation ✔ ️done Project them back from embedding space to vocab space. ✔ ️done Take a copy of the input sequence of embeddings ✔ ️done Layer normalisation Run multi-head attention ✔ ️done Add the copy back in so that the version that came out of MHA is something more like an "annotation" of the original ✔ ️done Take a second copy of that one Layer normalisation again Run it through a simple neural network Add the results of that back in. Convert token IDs into embeddings. ✔ ️done Add on position embeddings. ✔ ️done Run these embeddings through multiple successive Transformers blocks. part-done -- one layer only Layer normalisation ✔ ️done Project them back from embedding space to vocab space. ✔ ️done Take a copy of the input sequence of embeddings ✔ ️done Layer normalisation Run multi-head attention ✔ ️done Add the copy back in so that the version that came out of MHA is something more like an "annotation" of the original ✔ ️done Take a second copy of that one ✔ ️done Layer normalisation again Run it through a simple neural network ✔ ️done Add the results of that back in. ✔ ️done Convert token IDs into embeddings. ✔ ️done Add on position embeddings. ✔ ️done Run these embeddings through multiple successive Transformers blocks. ✔ ️done Layer normalisation ✔ ️done Project them back from embedding space to vocab space. ✔ ️done Take a copy of the input sequence of embeddings ✔ ️done Layer normalisation Run multi-head attention ✔ ️done Add the copy back in so that the version that came out of MHA is something more like an "annotation" of the original ✔ ️done Take a second copy of that one ✔ ️done Layer normalisation again Run it through a simple neural network ✔ ️done Add the results of that back in. ✔ ️done Convert token IDs into embeddings. ✔ ️done Add on position embeddings. ✔ ️done Run these embeddings through multiple successive Transformers blocks. ✔ ️done Layer normalisation ✔ ️done Project them back from embedding space to vocab space. ✔ ️done Take a copy of the input sequence of embeddings ✔ ️done Layer normalisation ✔ ️done Run multi-head attention ✔ ️done Add the copy back in so that the version that came out of MHA is something more like an "annotation" of the original ✔ ️done Take a second copy of that one ✔ ️done Layer normalisation again ✔ ️done Run it through a simple neural network ✔ ️done Add the results of that back in. ✔ ️done Once in the main body, just after we've worked out the embeddings. Twice in the transformers block: once after attention (but before the shortcut is mixed back in), and once after the FFN (ditto) Inside multi-head attention, on the attention weights ( which surprised me ). On looking back at Raschka's code, after having worked through all of this, there's a slight difference. I do this: ...whereas he does this: Now, the standard deviation is the square root of the variance, so if you ignore the small numbers -- in my case, and in his -- the calculations are the same. But there is a difference once those are taken account of. I don't think it's large enough to have any serious effect in these runs, though.  ↩ In PyTorch, linear layers are stored as the transpose of the matrix that would allow you to do that, so it would be: Q = xs × W q T Also, note that for simplicity (heh) I'm disregarding bias in this discussion.  ↩

0 views
Josh Comeau 1 weeks ago

Getting Started with Anchor Positioning

For decades, one of the most notoriously-challenging problems on the web has been sticking one element to another element, for things like tooltips and nested menus. The CSSWG has decided to provide a first-class solution to this problem, and it’s pretty friggin’ cool! In this tutorial, I’ll share the most useful parts I’ve found from this modern CSS feature.

0 views

My (mis)Adventures in Soldering

Building your own keyboard is a rite of passage for those caught up in the ergonomic rabbit hole. So, it was only a matter of time before I went all the way and did so. However, as a complete noob when it comes to soldering, I had a rough time getting started. I hope that this brief guide saves you hours of anguish! After procuring all the parts required for our keyboards, my friend and I proceeded to get absolutely nowhere with our soldering. Little did we know that the tip of my usb C iron (TS80p) was oxidized. We thought it was because the iron wasn’t getting hot enough or staying at a consistent temperature, and I promptly went to buy a Weller soldering station (which I would also not recommend, reasons to follow). I also promptly oxidized the tip on this machine as the sponge they give you in the kit is a travesty and you should not do that. The very first thing I would say that would have saved me much anguish is not using a wet sponge. The fact that many soldering stations ship with one instead of what you should be using (a brass sponge/wire) is a head scratcher. Water (if not using de-ionized water) will very quickly oxidize a soldering iron tip, and the temperature difference (ambient room temperature vs 350-400C) is enough to actually cause the iron tip to crack over time. Use brass wool. No water. Get this thing and use it instead. The second thing I would recommend is to use flux when you are soldering. And, not liquid flux, but something a little tackier that won’t immediately vaporise when you hit it with your iron. The reason that I had no luck was that the tip of my iron was not tinned, and that is how you “dry out” your iron very quickly, causing black/grey oxidation to build up. So, tin the iron when you first turn your iron on AND AGAIN BEFORE YOU PUT IT AWAY. The consensus on the internet about soldering temperature is to keep the iron just above the melting point. When your tip is oxidized, you have to bump to 400 degrees C or higher (some usb irons max out at 400) and as such you will be having one hell of a time to get solder to melt. I use lead-free solder, so I shoot for around 360 C give or take. Many will say leaded solder is more forgiving and it very well may be, I just don’t have experience to compare. The TS80p is a pluggable tip with a 3.5mm TRRS jack. The Weller WE1010 station has a heating element that I will call “legacy” - it does not go all the way to the tip of the iron, and the thermometer is located away from the tip, giving wildly inaccurate temperature readings. In addition to the previous point, the iron stays heated at a certain temperature with no auto down-regulation (they’ll shutoff after 1-2 minutes if you have it in settings). So oxidization is more likely on a traditional iron. What you want is a JBC C245 or C210 compatible iron or clone station. You don’t have to buy the authentic tips, and there are videos online of the cloned tips from Aliexpress actually being just as good (or better!) than the authentic tips. I thought about getting a full station, but instead got a capable USB C iron that seems to very much hold up to the wired stations. It’s only 100W, with many stations being 220W - so take that with a grain of salt, but for a keyboard or two, it has held up just fine. I may consider a TC22 or Fnirsi D200 station in the future, but will cross that bridge when we get there. If you are interested in the iron I am using, it is the Fnirsi HS-02 . Most irons will ship with a conical tip. These are trash and put heat at a very small point. I recommend a knife/chisel tip as you can then manipulate the tip and have greater or lesser heat transfer with the rotation of your wrist. You probably don’t want to be breathing in soldering fumes, so get yourself a cheap desk fan to blow the fumes away from you. For hobby projects, a fume extractor is probably not necessary, but you can go all out on this and build your own if you so wish . I cannot have a soldering tip post without the classic Louis Rossmann meme : “HEAT THE BOARD!” I didn’t have issues with this as I remember the above, but when first starting, some think that soldering is about heating and applying solder. It is not. It is about heating the components to the point they will accept solder. This makes a massive difference. The more people that learn to solder, the more we can fight for repairability, and you start to see that no board is actually dead, it probably just needs a new chip somewhere. The “literacy” that comes with soldering and the ability to repair electronics can take you from a consumer to someone that actually understands the underlying mechanisms. As always, God bless, and until next time. If you enjoyed this post, consider Supporting my work , Checking out my book , Working with me , or sending me an Email to tell me what you think.

0 views
Giles's blog 2 weeks ago

Writing an LLM from scratch, part 34a -- building a JAX training loop for an LLM training run

For over a year, I've been using Sebastian Raschka 's book " Build a Large Language Model (from Scratch) " -- and the multitude of side-projects that have branched out from reading it -- as something like a curriculum for learning about modern AI. The one final task I had set myself was to build and train an LLM from scratch just using my notes -- no reference to the book, no reference to the model code I'd written following the book. As an output, I wanted something as good as my best PyTorch model based on Raschka's code -- a base model, trained on 3.2B tokens, that my (admittedly limited) evals ranked as being close to the original GPT-2 small's quality. I wanted to use a different framework, just to make sure I wasn't parroting code that I'd somehow memorised, so I asked people on Twitter which one I should use, and the winner was JAX . I took a slightly different route to Raschka's book; he takes an inside-out perspective, explaining things like attention, gradually building up a complete GPT-2-style model, and then building a training loop on top of it. I wanted to go outside-in: I'd put together a training harness to train the simplest-possible model with an API similar to a real LLM, get that working to my satisfaction, and then add features to that simple model, one by one, until it had the full architecture in place. The plan (which actually worked out nicely!) was that I'd be able to show how each change improved things. That's all done now, and I'm posting about it in two parts; in this one, I'll explain how I built the training harness, and in the next, I'll show the actual building and training of the LLM. So let's get started! JAX itself has a relatively minimal API, and doesn't include standard neural network components like linear layers. Likewise it doesn't have any built-in optimisers, data loaders or similar ML utilities. Now, I could have decided to build my LLM using just pure JAX, like I previously did with a toy XOR model . But I felt that it would be better to build this in the style that real-world JAX code is written, which would mean using some of the many utility libraries . On the JAX site itself, there was a useful-looking link: "If you’re looking to use JAX to train neural networks, check out the JAX AI Stack !" On the linked page, it made it clear that the two core parts of that stack were: I took a look at both, and they seemed pretty easy to grasp. Indeed, at first glance, I felt that NNX looked pretty PyTorch-like! In their tutorial example, the only real obvious difference was the JAX-y derivative-style gradient calculation and the way that random numbers were handled. And even the random numbers were handled in a less pure-functional way than pure JAX -- instead of having to mess around with splitting keys, you could just pass in what appeared to be a stateful variable that somehow split itself internally as needed. So, NNX and Optax were the frameworks I'd use. Rather than grinding through the tutorials, I decided that I'd just dive right in, and try to pick things up as I went along. How hard could it be...? To build a functioning training loop, I needed a minimal model to train -- not an actual LLM, but something that behaved at least a bit like one. It would take in a sequence of tokens, and spit out logits for each token. In my preferred model of how LLMs work , at the top level for a model, we feed in a sequence of token IDs, then: All of that suggested to me that the dumbest "LLM" I could write just to get started would be one that just projected token IDs into embedding space, and then projected back to vocab space. No Transformer layers at all. I'd then train it so that instead of trying to predict the next token, it would try to "predict" what was fed into it in the first place. In other words, you'd feed the training loop this input: ...and this target ...rather than the normal setup for an LLM, where you feed it ...and give it targets of If I could get that to work -- and it felt like the kind of thing where you'd be able to get the loss down to near-zero without a huge amount of training -- then I could be reasonably sure that I had a working training loop. 1 I decided to call this an A-to-A model. Coding up the model itself was ridiculously simple: it looked like this: There's as much boilerplate in there -- for the parameters that I knew that the model would need when I built out the full LLM -- as there is actual code doing stuff! But the training loop was a bit more fun. As I said, my plan here was to make sure my understanding of the internals of LLMs was correct by rebuilding one just from my notes. That "notes only" restriction didn't apply to the training loop itself, so I allowed myself to crib a bit from the PyTorch DistributedDataParallel code that I'd been using to train the original model in the cloud. The first version that I used is here . Let's start at the bottom, where we have the function . It starts with some boilerplate to handle the concept of "runs". This is a pattern I've found myself using in most of my projects. When working on a model, it's useful to be able to do multiple training runs, changing things each time. You want to keep the checkpoints, metadata and training charts for each one for future reference. So in my repo, I'll have a "runs" directory, and in there subdirectories for each training run I want to track. In those subdirectories, there are JSON files -- one to configure the model, , and one to configure the training hyperparameters and similar stuff, . (It's worth noting that at this stage, a bunch of those hyperparameters were unused; I kept them in there out of laziness, as I knew I'd need them later.) So we start our function by loading those. Our next step is to completely ignore one of the training hyperparameters, . I definitely wanted to do gradient accumulation , but decided to leave it for later. Better to get a solid, simpler training run done first, I felt. Next, we download the dataset we're going to use to our local disk with (which will only download if there's not an up-to-date copy already there). The next step is to call to load it into RAM. You can see that there's another hard-coded variable there, . This is a holdover from the multi-GPU DistributedDataParallel code that this was all based on; in this blog post I'm only covering the code for single-GPU training, but I decided to leave the DDP stuff in there for dataset-wrangling purposes, hardcoded to one GPU, so that it would be easier to re-introduce if I later decide to implement something similar in JAX. Let's take a look at and its related stuff. If you go up to line 39 you'll see the code. Firstly, there's a that keeps track of our training data. If you look closely, you might spot one oddity in that class. We have this: Remember that at this stage, the plan was to train the model to map tokens to themselves rather than to make next-token predictions. So the targets are the same as the inputs, not the more normal next token, which would look like (and, in the next post, will look like) this: Next, we have a function to load the appropriate subset of the data from the copy on the local disk into one of those objects. I hit an out-of-memory issue when I ran the first version of this. It was trying to load the data into my GPU's VRAM -- JAX's default behaviour if you have a GPU, and the CUDA version of JAX is installed -- and there was too much to fit in there. After a bit of digging around I learned how to change the JAX default device so that it would be loaded into normal system RAM. Unfortunately, once I'd done that, I found that iterating through it was super-slow -- it took about 1.2 seconds to get one training batch of 6,144 tokens out of the array, which meant that I'd have a limit of 5,120 tokens/second of training from that alone. I eventually learned that the data had been loaded into the main RAM, but was being copied up to the GPU for processing because it had not been committed to the main RAM -- details here . Fixing that (with an explicit call to ) meant that getting a single training batch from the dataset and putting it onto the GPU took less than 0.001s, which was much better. So that was many hours of work that all got packed into lines 55 to 58 of the code: The remainder of the logic in is just to make sure that we have a dataset that is exactly the right size for the world size (even though that's always one right now), the microbatch size, the gradient accumulation steps, and the sequence length that we're working with, Let's go back to the function again. Having loaded our dataset, we create our model, passing in the model configuration stuff and also the (currently unused) dropout rate training hyperparameter, then we create a Flax NNX optimiser which wraps an Optax one. This was essentially a copy/paste from the Flax tutorial, except we're configuring the optimiser with learning rate and weight decay hyperparameters from the training config: Finally, we call to kick off our training loop, passing in some appropriate stuff. Let's go to that function next. We start off with a bit of housekeeping, then go into the main loop. You can see that it's kind of gesturing at gradient accumulation: ...but if you look at the actual body of that loop, it's not doing anything of the sort. It's just getting training batches, putting them on the GPU, doing a full training step, and keeping track of some metrics: So, we're just doing a traditional batch-by-batch training loop without gradient accumulation right now. But some of the infrastructure is there, because it was the next thing I wanted to add after I'd got the basic loop working. The rest of the function is just housekeeping and checkpointing; we'll come back to the checkpointing shortly, but first let's take a look at the function that actually trains the model on a set of inputs and targets, and its associated function -- they're just above . Now, as you might remember from my first JAX post , the best way to JIT a training loop is at as high a level as possible. So when I first coded this, I integrated that into the traditionally-named function like this: When I actually came around to run it the first time, loss wasn't falling at all, and after banging my head against it for a while, I realised I should have used rather than , fixed that, and kicked it off again. Loss started falling immediately. D'oh! Now let's take a look at loss. Cross entropy loss was clearly what I would need to train an LLM, and also felt like the right thing for the A-to-A model. Optax has five loss functions that are related to cross entropy; three of them looked a bit more complicated than I needed: So it was a choice between The latter was the right one -- expects the labels (that is, the target token IDs) to be one-hot vectors, while , as it says in the function name, expects integer labels, which is what we have. That sounded pretty similar to PyTorch's , but there was an important difference. For normal use (if you're not using K-dimensional loss, whatever that might be) PyTorch expects that the inputs are either just a one-dimensional tensor of c logits, or at worst a b x c matrix, where b is the batch size. I had noted when working through this section of Raschka's book that the code we wrote flattened things out. So a batch of six sequences, each 1,024 tokens long, with a vocab size of 50,257, would give us a logits tensor shaped like this: The first axis is the batches, the second is the length of the sequences -- remember, we have logits for every input token in the sequence, with next-token predictions for that token in the context of all of the other ones to its left. And the last axis, with a size equal to our tokeniser's vocabulary size, is the logits themselves. After flattening, it looked like a "batch" of 6 * 1024 = 6144 logits vectors: Likewise our targets -- the token IDs we wanted our model to be predicting -- were batched, and there was one per token in each sequence, so that tensor was Flattened, it looked like a "batch" of 6 * 1024 = 6144 targets: Finally, the PyTorch function returned a scalar value -- wrapped in a PyTorch object, of course, so that it could participate in the backward pass, but a single number. But I'd forgotten about all of that when I was writing this part of the JAX code, and just fed the inputs and the targets straight in to the JAX function. The result was interesting. I started with this: And printing out the shapes of each variable gave this: It had returned a cross entropy number for every element in every sequence, across all of the batches! What's interesting is that the docs for imply that it has the same restrictions as PyTorch's -- it expects a single batch axis in the tensors that are passed in. Perhaps they're out of date? Or perhaps Optax just assumes that you know that in JAX "a batch axis" should be read as "as many batch axes as you want"? Well, anyway -- it worked, and I checked that the numbers were solid. Now, of course, we can't ask JAX for gradients using that 6 × 1024 matrix -- the loss function needs to return a scalar -- but the function on a JAX array does exactly what we need. So I had a solid loss calculation, which you can see in : So that's covered our loss function and the JITted that uses it. The only remaining code that I haven't gone over in this version of the script is the stuff immediately above -- and . These are both called as part of the housekeeping code I glossed over in the function, after we take checkpoints. They just redraw a plot of the loss and other training metrics, using stuff that's stored in the metadata of all of the checkpoints so far. That means that there's a nice graphical way to keep track of a training run. Fairly dull stuff, so there's no need to go through them, but it is worth taking a look at the checkpointing code itself. You can see the version I was working with at this point here . It's not really much of a checkpoint; I was saving the model itself and the metadata needed for that charting code, but not the optimiser, which would be needed for a real checkpoint. After all, the purpose of a checkpoint is to be able to pick things up again if your training loop crashes, and you can't do that without the optimiser's state. Still, it was enough to get started with. That said, one wrinkle I encountered when writing that simple checkpointing code was that it was a tad tricky to save them in Safetensors format -- you can see the details here . So, that was my initial training code. It was time to let it rip: could I train my dumb "LLM" to map from A to A? As I mentioned earlier, the very first run didn't converge at all -- loss started at about 10.82, which was promising (it's exactly what you'd expect for a randomly-initialised network trying to predict GPT-2 tokens -- see here for details), but then it remained there. But when I fixed the " should be " issue, it started dropping. After 92,160,000 tokens seen, it seemed to have hit zero (at least to the three DPs I was printing), so I baked that into and did another training run fixed to that number of tokens. After about 14 minutes, it finished: A very promising final loss, even though that was just whatever we got on the last batch! The actual loss chart looked like this: If you're used to the loss charts in my previous posts, there's something to highlight here: I've switched the Y axis over to being log, so those bumps near the end are actually tiny deviations away from 0.001. I think it's worth showing what the model actually did at this point. It was actually somewhat later that I wrote some code to load up the model checkpoints from these training runs and do some smoke tests, but I'll show you some results now. I wrote some code based on my JAX safetensors post to load up a model's parameters from a checkpoint's file: ...and then wrote two test scripts. Firstly, was it really mapping from A to A? I wanted to be sure that the loss number was actually reflecting what I wanted it to reflect. I wrote a simple script that took a Safetensors file on the command line, and ran the first verse of The Rime of the Ancient Mariner (chosen because it uses oldish English so there are some odd tokens in it) through the LLM it loaded from that file. Here's what the model at the end of the run came up with: That's great! It could certainly handle the mapping. Out of interest, I decided to see how quickly it had learned to get that right. The average training loss in that "best" checkpoint at the end of the training run was 0.0001, so how did the mapping improve, and what was the loss, near the start of the training run? For the first checkpoint, when we'd just run one batch through, we had an average training loss of 10.8242. With the model parameters that were saved then, we get this output: As you'd expect from that loss, it's total token salad. Now let's take a look at the next checkpoint, taken after 375 "global steps" -- that is, 6,000 batches. In that one, the average train loss since that first checkpoint was 2.9323. But that hides something important -- the maximum loss, near the start, was (as you would expect) 10.78524, not much less than the average loss in the previous checkpoint. But the minimum (which we can safely assume was towards the end of this checkpointing period) was 0.54155, so we can reasonably assume that the model improved very rapidly at this point. And the A-to-A test bears this out: So, we can see that the bulk of the improvement happened right at the start! It was able to pass the A-to-A test for that fairly unusual sequence after just 6,001 total batches of 6 1,024-token sequences. The rest of the training run was perhaps just grinding out improvement on rarer tokens, and perhaps making it more certain about already-correct predictions. After all, the test script was simply printing the most likely token for each position, so at this state it might have been predicting some of those tokens as 51% probability. That would have meant a penalty in the loss function, even if the answer was actually correct. So that was an interesting script; I wanted to do another -- the standard smoke test that I've been using, based on Raschka's prompt: how does the model complete "Every effort moves you" when asked to continue the sentence? Here's the script , and here's what it generated: That makes perfect sense. In order to generate the next token in an autoregressive loop, we're looking at the logits for the last one in the prompt. When it first runs, the last token is " you", and our model is trained to map A to A, so its result is " you". We append that to the prompt, run it through again, the last token is still " you", so of course it "predicts" the token " you" again. And so on. So these results were both good news! The A-to-A mapping was working, and was converging rapidly in terms of loss -- and even more rapidly in terms of our poetic test. So, what was next? I wanted the training loop to be as similar as possible to the code I used for my best locally-trained PyTorch model . That used three things I had not built into the training loop at this stage: learning rate scheduling, gradient clipping, and gradient accumulation. The PyTorch code also had the ability to restart from a checkpoint -- not super-important in a 14-minute training run like this one, but I figured it would become important later. After all, the PyTorch runs on my local machine had taken almost two days, and if something went wrong halfway through (cat jumping onto PC power button, etc) then I really wouldn't want to start from scratch. I decided to handle gradient accumulation first. In PyTorch, doing gradient accumulation is pretty simple: the core of a typical training loop without it might look something like this: We start off by clearing out any gradients that are stashed on the model's parameters, then do a forward pass, work out the loss, do a backward pass to put new gradients on the parameters, and then step the optimiser to apply those gradients. Accumulating gradients just means changing it to something like this: That is, we do a forward and a backward pass times. Because we're not zeroing out existing gradients between them, the parameters will accumulate gradients over time -- each backward pass will add its contribution onto what is already there. Each time, we divide the loss by , so that the gradients that are put on the parameters are that much smaller, which means that by the end of our loop we've got gradients that are the average of what we'd have got if we'd done all of these microbatches in one big batch. Finally, once we've exited the loop, we step the optimiser to apply those averaged gradients. When I started thinking about implementing this in JAX, I noticed that Optax has a help page on how to do it , but then I had one of those brilliant shower thoughts that one sometimes has. I should have learned by my age that they rarely work out well, but this time I decided to give it a go rather than doing things the official way. My brilliant idea was that with some finessing, we could put the whole gradient accumulation loop inside JITted code. From what I'd learned so far, the higher up in our code we put the JIT decorator -- that is, the more of the training loop it covered -- the faster it would be. In itself, that wasn't a bad idea. But my first implementation was less smart: The were full-step arrays (eg. shaped (16, 6, 1024) for 16 gradient-accumulation steps over 6 microbatches of 1024 sequences), and the targets likewise. That seemed very clever! But in retrospect, it was obviously doomed to failure, and when I ran it, I ran out of VRAM. The point of gradient accumulation is that what you accumulate over time is, well, gradients. So you have to do a full forward pass and then a backward pass over the model for each microbatch, letting gradients build up, and then apply those in one go, like the PyTorch code did. Unfortunately what I was doing with my code was essentially all of the forward passes, one by one, letting the activations and JAX's internal structures representing what calculations had been done accumulate -- not the gradients -- and then doing a single backward pass across all of that. Mathematically it made sense -- I would have got the right effect if I'd had enough VRAM -- but it wasn't much more memory-efficient than just doing a single batch of sequences. Immediate CUDA OOM. My second attempt was a bit more sensible and ran OK without the JIT: You can see that now I was doing both the forward and the backward pass within the loop, and then working out the mean gradients with that , then passing those average gradients to the optimizer. It all made sense, and seemed to work when I ran it: ...and it wasn't as much slower as I would expect given the lack of JITting: 1,146 seconds versus 843. It was interesting that the final train loss was higher than the run without gradient accumulation, but larger effective batch sizes are not always a better thing: it depends very much on the model you're training and the data. The batch size and number of gradient accumulation steps I was using were ones I had optimised for the full 163M-parameter GPT-2-style LLM, not for this model. So it was OK if it was a bit worse. Anyway, I tried adding the to that function, and ran it: Ouch. And looking at the traceback, it appeared that it was the actual JITting that was running out of VRAM. Something to do with loop unrolling, perhaps? I dug around for a while, trying to use JAX's rather than a normal Python one, but to no avail -- I would always run out of GPU memory. Eventually, after a few hours, the alarm bells on my side quest detector had become too loud to ignore. Reluctantly, I gave up on hand-rolling my own gradient accumulation, and implemented it the Optax way . That was actually really nice and simple. The code is here , but the change is tiny and simple to explain. Remember that we had this code to set up the optimizer: That creates a Flax NNX optimiser, which uses an Optax AdamW optimiser under the hood. The Optax way to do gradient accumulation is to wrap the optimiser in a helper, which -- with the NNX optimiser wrapping the result -- looks like this: The wrapper is really neat. It has the same interface as a regular optimiser, so its method can be called with a set of gradients. But instead of applying them, it just accumulates them until a particular number of calls to have been made, at which it actually does apply the mean of the accumulated gradients, and resets its counter so that it starts accumulating again. That's actually a really nice API. And it actually meant that I would have been able to simplify the training loop. Remember, we had this: The loop-within-a-loop was needed by the PyTorch code, because we needed to do the optimizer step at the end to apply the accumulated gradients. But with the Optax wrapper, we could have just iterated over our samples in one top-level loop, relying on the to make its updates every iterations. However, I decided to leave it in -- keeping track of the training in terms of global steps meant that the training output with my JAX model would be easier to compare to the PyTorch versions. Perhaps if I'd been building the training loop completely from scratch I would have chosen differently. Anyway, with that code change in, I ran it, and: I had the same loss at the end as the by-hand un-JITted version, which was reassuring. And it was slightly faster than the non-gradient-accumulating version, but it's a small enough difference that it was probably just in the noise. So that was gradient accumulation! Here's the code with that added . Next, I wanted to get charting and scheduling of the learning rate, and gradient clipping working. Scheduling the learning rate means that we'll be changing it over the course of the run -- like this example from one of my PyTorch training runs: Having a chart like that one is really useful, as it allows you to sanity-check that the changes you are making to the learning rate really are the right ones. So I wanted to add the charting first, and then the scheduling. The boilerplate code to actually generate the chart, given learning rate numbers in the checkpoints' metadata, was already there, so I had to work out how to extract the current value of the learning rate from the optimiser and then save it into the checkpoints. This was the obvious starting point . Optax optimisers themselves don't store the learning rate, but if you create them like this: ...where the in the brackets is the normal stuff that you'd pass in to the optimizer when creating it, then you can extract the learning rate later. However, the code on that help page was using the Optax optimiser directly, whereas my one in the training code was wrapped inside a , which was in turn wrapped inside an NNX object, like this: Still, the solution seemed reasonably clear. I could use the trick on the that I was creating, and then pass it in to be wrapped like this: The next question was how to actually read the learning rate from that optimiser. The sample code in the Optax docs looked like this: Again, that was using the Optax optimiser directly, rather than trying to use one that was inside an NNX one. However, in the docs for NNX's optimiser I noticed that it exposes its wrapped Optax one's state as . I put in some temporary debug code to print that, and saw that it was the ' state, which made sense -- and that, in turn, contained the state of the wrapped one as . That had a field called , which was a dictionary that included as a key. Finally, the value that that key pointed to was a object. To get the actual value from there, you need to call its to get the actual value, which is a JNP array, so we needed to call on it. All of that led to the following abomination unto God, mankind, and the Law of Demeter : Eurgh. I mean, really, eurgh. Well, anyway, I put code to do that into the function and save the number as part of the metadata. I did a partial training run, just for long enough to confirm that the learning rate chart was being generated, and had a flat line on it at 0.0014, the constant learning rate I was using at that point. I can't say I was very proud of it, though. To recap, the learning rate schedule that I wanted was this: That's formed of two phases: an initial warmup, where the learning rate started at 0.00001 times the desired peak value, and then rose linearly to the peak, followed by a cosine wave to decay it to 0.1 times the peak. In PyTorch I had had to use different learning rate scheduler objects to handle each phase, with a wrapper to bolt them together : However, it's a common pattern in training loops, and conveniently Optax provides a class that does all of that for you. The only oddity in it is that is kind of misnamed; it's actually total steps, including the warmup. So I wound up writing this code: I did a training run with that, and it completed with this: The loss was a bit worse again, but just as with the gradient accumulation steps, the learning rate schedule I had specified was specifically designed for training a real (if small) LLM, not for this toy A-to-A task that I was using to test the training loop. The important thing was the learning rate chart, and it looked like this: Perfect! Here's the code at this point . There were two boxes left to check before I had a training loop I could actually use to build the LLM: gradient clipping and the ability to restart from a checkpoint. I decided to do gradient clipping first. Gradient clipping is where for each update, you look for gradients that are suspiciously large, and cut them off so that they don't make excessive changes to the model. The Optax docs made it look pretty simple: So, you use an to chain together first a thing that does clipping, and then the actual optimiser -- presumably the first thing in the chain sees the gradients and does stuff to them, and then the second receives whatever the first has returned. Now, the question was, should we do the chain outside or inside the MultiSteps? That is, should we clip gradients each time before we step the MultiSteps optimiser, or do we accumulate them and clip the average before we step the inner AdamW one? Looking at the old PyTorch code , I was running the gradient accumulation loop, and then clipping at the end. So the gradient clipping was happening to the accumulated gradients. That actually felt less intuitively good than the alternative, but I decided that we should try to mirror what the PyTorch code is doing. So: So, the optimiser would receive clipped gradients. Because it was wrapped in the , it was receiving the accumulated gradients every time that object hit its limit. Unfortunately there was still a problem: that change meant that the optimiser that we were reading the learning rate from with this horrendous code in the function: ...would now be inside yet another level of nesting -- the object. So, of course, when I ran it, it blew up with an error: I used some debug prints to work out what was going on, and determined that the state of the object was a tuple, the first element being an essentially-empty state for the clipper, and the second being the hyperparameter-injected state for the . So that meant that the new correct code to get the learning rate would be this: Note that we've gained that to do the lookup into the 's tuple state. I remember coming across a comment saying "forgive us for our trespasses in this method" in a codebase long ago, and I know well how the author felt. I did have an idea of how to at least limit the blast radius a bit, though. At this point in the code, I had the complex optimiser setup in the function, and the learning-rate-getting abomination in . I decided instead to define a function called right next to the optimiser setup, and pass that in to . So the horror was still there, but at least it was all in one place, like this: ...where called where it needed it. I was just about to kick this off, but by chance happened to take a closer look at the documentation for , and spotted that it said Clips updates element-wise, to be in That rung a bell! When I was originally looking into gradient clipping for the PyTorch training loop, I noted that that is a perfectly valid way to do gradient clipping, but it's not the way I ultimately chose. Instead, I was clipping based on the L2 norm. The JAX training code was meant to work the same way as the PyTorch code, so that was a good catch; I switched over from using to using , and then kicked off another training run: Everything looked fine; my guess was that the final loss was so similar because a simple task like A-to-A mapping, with such a shallow network, would be unlikely to cause gradients to explode. But it would be nice to be sure. Was there some way I could track the gradients and see if clipping had had to cut in? One neat thing we had in the PyTorch code was that we could track gradient norms pre-clipping: Unfortunately, and the general Optax API doesn't provide any way to access the pre-clipping norms: the that was the zeroth element of the state of the that we were reading in the horrendous learning rate-reading code is an alias of . I considered using to work out the norms directly, and logging that, but that would be tricky -- because the gradients we were applying the clipping to were not the ones that were generated in the function, but instead the ones that had accumulated inside the object over multiple gradient accumulation steps. This sounded like a lot of work for a not-enormous benefit, so I decided to leave it out for this project. There was, however, one small change that I wanted to make while I was messing around with gradients -- what to do if non-finite numbers crept into them. Back when I was first looking into gradient clipping, I was somewhat horrified to realise that the scaler object I was using to tell PyTorch to train in 16-bit for things where it felt it would help (Automated Mixed Precision, or AMP), was silently dropping any updates with non-finite gradients, and if you didn't use AMP, such gradients would be happily applied to your model, most likely completely breaking it by setting parameters to non-finite values. This felt like the wrong place for that kind of logic to go -- I felt that it should belong to the optimiser, or at least in some other part of the stack that wasn't specifically related to the totally orthogonal task of mixed-precision training. I checked what JAX's default behaviour with non-finite gradients was, and it turned out to be to just apply them -- but, with Optax, it actually was something you could fix at the optimiser level. If you wrap an Optax optimiser with , it will only apply finite gradients, so we could add it to the optimiser setup like this: I set to infinity to mirror the PyTorch code's behaviour. Now, obviously, this required yet another level of indirection in the learning-rate-getting function from hell: If you're keeping track, it's the in there. Heigh ho. So, it was time to run it again: That looked OK -- no change from before. Here's the code . Now, it was time to take the last step to finish the training loop: the ability to restart from a checkpoint. At this point, the checkpointing code was pretty basic -- it would save the model as a Safetensors file, along with some metadata like the min, max and average loss since the previous checkpoint, the number of the global step that we were on, and whether or not this was the best checkpoint (in terms of average training loss) so far. In order to restore from a checkpoint, we'd need more information. In the old PyTorch code, we needed three extra things on top of the model and the metadata: So that was the job: save the optimiser in , and then implement a so that we can restart from one. I could then try kicking off a training run, waiting for a bit, killing it, then restarting from the most recent checkpoint. The loss and learning rate charts would tell me whether or not the restart really had picked up from where it had left off. Initially I was thinking that I would just use pickle to save the optimiser, but that felt like a problem waiting to happen. Pickle has issues when you change Python versions or versions of installed packages, which never feels like it's going to be a problem, but all-too-frequently turns out to break stuff in reality. 2 Using Safetensors looked a bit tricky -- it had been hard to get it to work with Flax models, even though it had explicit support. Now, the recommended library for checkpointing in JAX code is called Orbax . I'd looked into it before, and it looked a bit heavyweight, so I'd moved on. But digging in a little more, I found that it had what looked like a simple API for saving PyTrees , which bypassed the complexity. Getting it working was still a bit tricky, though. Firstly, in the docs, they give this example: I tried that in the function with code like this: ...and got the error Huh. Digging into the library from the command line showed that the function was actually called . Not super-promising if the docs don't match the API (though to be fair, it does say right there in the package name). Anyway, changing that appeared to work: ...and then next to the 295 MB file called in my checkpoint directories, there was a 353 MB directory called . In PyTorch-land the optimiser had always been double the size of the model 3 , but given the wildly different file formats in play, I was comfortable enough that it was order-of-magnitude the same as the model and somewhat bigger. Perhaps Orbax was doing some kind of compression or something like that. Next, it was time to write . I started off by writing the function to load up the safetensors file -- that's the one I showed earlier, back when I showed how the original A-to-A model learned how to map a poem to itself, and that if you asked it how to complete "Every effort moves you", it would respond with " you you you you you" and so on. Once I had that, I created a , which called , and then loaded up the metadata and worked out what our best loss so far had been (which is necessary when continuing from a checkpoint so that, as you continue training, you can work out whether each new global step has had a loss that is better than the current best). That was simple enough: Restoring the optimiser turned out to be a bit trickier. Firstly, of course, just like with saving, the Orbax function was called rather than the documented . The next part was working out how to load it in a fashion that the optimiser would accept. If you load a checkpointed PyTree like this: Then what you get back is a "basic" PyTree -- it will consist of lists, dictionaries, tuples, basic Python types like strings, and JAX arrays. The problem is that the optimiser's state is formed of objects that can be mapped to such things -- for example, an object can be mapped to a dictionary where each field is an item in the dict -- but aren't actually those specific types of objects. So if you do this: ...you get an error, something like this: ...and likewise if you use the function I was using in the code: ...you'll get a slightly different but equally confusing error. After a certain amount of floundering around, limited by the lack of documentation (and it not seeming to match the API that I was seeing) I had the bright idea of looking at 's docstring, and that turned out to be excellent. In IPython: The solution was obviously that . When you provide it, it's used as a template. If in the abstract PyTree it finds a object, and in the loaded PyTree there is a dictionary in the same position with keys , and , it will create a object, setting those fields to those values. That means that you have something with the right structure to apply, so I wound up with this relatively simple code to load checkpoint into the optimiser: We're using the existing state of the optimiser as a template to tell Orbax how to structure the loaded one. I kicked off a training run, hit control-C halfway through, then restarted it from the checkpoint, and the final loss chart looked like this: ...and the learning rate chart like this: Perfect! The interrupt was at about global step 400, and the loss continued to go down properly, and the learning rate followed its schedule perfectly. Here's the checkpoint-loading code and the training script . So with that, phase one was done. I had a training script. It was massively overengineered for training this little A-to-A model, but just right for training a small LLM from scratch. And now it was time to do that -- and that's what I'll cover in the next post. If you're thinking "why not just have it return one-hot vectors based on the input tokens", remember that I needed something in the model to train, so that I could confirm that loss was going down. A pure "identity" model without the embedding space would have nothing to learn, so wouldn't be able to provide that.  ↩ It was a surprisingly large source of tech support queries on PythonAnywhere. Someone would train a model with (say) Python 3.11.1, and then try to run it on our servers using 3.11.2, and discover that they couldn't load up their checkpoints. This confused them and they wondered if it was something to do with our platform. I even had a quicktext response to send with a rundown on how Pickle works so that I didn't have to keep typing the same explanation. This may have biased me more against Pickle than I should rationally be.  ↩ AdamW stores two numbers per parameter to keep track of its optimisation state, so 2x the model size is exactly what you'd expect if both files were in the same format.  ↩ Flax NNX for neural network components. Optax for optimisation. Firstly, we convert them into embeddings, so we get a series of vectors. We do this by a lookup into a table, but we can see it conceptually as a projection via a matrix, from vocab space (where a particular token ID is a one-hot vector) to an embedding space. Next, we do the magic with our Transformers layers, getting embeddings for the next token. The embedding at position n in the output sequence, after these layers, is for the predicted token to come after the token at position n in the input sequence, considering that input token and all other tokens to its left. Finally, we project those back from embedding space to logits, this time actually using a real matrix (in the form of a linear layer). The logits (after being run through softmax) represent the probabilities for each token of it being the next one. The scaler that we used to do automated mixed-precision training. This JAX loop was not going to do that, so it was not necessary here. The learning rate scheduler. This was built into the optimiser for JAX, so I didn't think it was needed. The optimiser itself. This was important, and we definitely did need to save it. If you're thinking "why not just have it return one-hot vectors based on the input tokens", remember that I needed something in the model to train, so that I could confirm that loss was going down. A pure "identity" model without the embedding space would have nothing to learn, so wouldn't be able to provide that.  ↩ It was a surprisingly large source of tech support queries on PythonAnywhere. Someone would train a model with (say) Python 3.11.1, and then try to run it on our servers using 3.11.2, and discover that they couldn't load up their checkpoints. This confused them and they wondered if it was something to do with our platform. I even had a quicktext response to send with a rundown on how Pickle works so that I didn't have to keep typing the same explanation. This may have biased me more against Pickle than I should rationally be.  ↩ AdamW stores two numbers per parameter to keep track of its optimisation state, so 2x the model size is exactly what you'd expect if both files were in the same format.  ↩

0 views
neilzone 2 weeks ago

Restoring missing Address Book in Thunderbird 140 menu bar

For some reason, the Address Book tab/pane on Thunderbird’s menu bar had gone missing, and I struggled to find out how to get it back. So, for future me, what resolved it was:

0 views
Jeff Geerling 2 weeks ago

Quickly apply LUTs (color grading) with ffmpeg

This is a quick post, mostly for my own reference. I've avoided LUTs and 'Log' video footage for years 1 , mostly because of the extra tiny bit of workflow involved. Like RAW photos, 'Log' footage retains the video sensor's full dynamic range, so you can pull more color and luminance information out of the footage later. But unlike photography, where RAW has been a thing for decades, and many workflows 'just work' without me having to 'grade' every individual photo, in video precious few consumer apps handle Log footage gracefully. You generally end up with a muddy grey mess.

0 views
Unsung 4 weeks ago

Show your hands honor for the strange power they bring you

Here’s a big interactive essay I just finished . The theme: What does it take to build interfaces that truly allow for fast operation – and why that matters. If you like the interactive details posts here on Unsung, the essay is kind of a concentrated dose of all that. You can technically read it on the phone, but it’s so much better on a computer (or a big tablet). It has ~40 interactive playgrounds, and sounds, and a glossary, and all sorts of fun stuff I’m doing for the first time. I wanted to share some things I learned over the years, and nod toward mostly anonymous creators of UX inventions I’ve long admired. I also thought it could be interesting to make interfaces appear as machinery – you’ll see what I mean. Let me know if I succeeded! #flow #interface design #marcin wichary

0 views
fLaMEd fury 1 months ago

Create A Static Site Using 11ty &amp; Deploy to Neocities (2026 Refresh)

What’s going on, Internet? Way back in 2022 I wrote a guide on building a static site with 11ty and deploying it to Neocities . It’s been one of my most-read posts, but it’s also aged: Eleventy has moved to v3 with a brand new module system, the dev server changed, and my whole workflow has shifted away from GitHub toward Forgejo and Codeberg . So here’s the refresh. I haven’t hosted my own site on Neocities for years now, but it’s still home to a huge community of personal sites and homepages, especially folks in the 32-Bit Cafe , so this guide is still very much for them. This guide aims to help you create a homepage using the static site generator (SSG) 11ty , keep the code in version control, and deploy it to Neocities , first by hand, then automatically. The homepage that we are creating will take advantage of the Nunjucks templating language, allowing us to create a shared header, navigation and footer across all the pages on our homepage. We will be creating an about, links, and contact pages before diving in and creating the ability to add a blog and a list of all blog posts on the blog page! We will structure and style the page with a standard HTML5 boilerplate and some basic CSS that should allow you to add in your unique flavour that we all know you love to do. This guide assumes the following: First off, from a terminal, confirm that you have Node and NPM installed: Create a new directory and cd into it: Initiate a new project: Install 11ty: Once the 11ty installation is complete, open the project in your favourite code editor: You should now be in VSCodium with the following project structure: Open and update the scripts section to the following: We also need to tell Node that this is an ESM project. Add to . The file should look like this: The line lets us use modern / syntax in our config and JavaScript files. The script lets us run to serve our homepage with hot-reload, provided by Eleventy's built-in dev server. Every time you save a change in VSCodium, the browser reloads with your most recent changes, amazing! From the terminal (or VSCodium), create a new file at the project root: Open the file in VSCodium and add the following and save: This configuration file tells 11ty what to do. Setting the directory to tells 11ty where to look for changes, this is our working directory. When changes are detected, 11ty builds the site and outputs it to the directory which is where the static html/css/img files are served from, amazing! As we’re going to be keeping our homepage code in version control, create a file in the project root: Open the file in VSCodium and add the following and save: The .gitignore file is a text file that tells Git which files or folders to ignore in a project. In this case, our file tells git to ignore the directory and the directory where our static files are built locally. Now comes the fun part, building our homepage. 11ty supports a number of templating languages, but the two you’ll reach for most are Markdown and plain HTML. Markdown is the popular choice for content like blog posts: you just write, without tags getting in the way. HTML is handy when you need precise structure. The best part is you can drop HTML straight into a Markdown file and 11ty renders it correctly, so it’s never one or the other. For the pages that make up the site’s structure (home, about, links, contact) we’ll use HTML, because it maps neatly onto the layouts and partials we’re about to build. When we get to the blog, we’ll write the posts in Markdown, where it shines. Use whichever fits the job. Create a directory at the project root and cd into it: Create an file in the terminal or VSCodium: Open the file and add some content: Now from the terminal start 11ty: If everything has been configured right so far you should see the following: Now you can open up and check out your new 11ty homepage! It should look like this: A Basic Hello World HTML Page Amazing! But what we want to avoid is having to write out the and and tags on each and every page, and be able to include a site header, navigation and footer so we don’t have to copy and paste the changes across every page each time we update. Let’s checkout templating a layout! Create a new directory in the directory and cd into it: Create a file in the terminal or VSCodium: Open the file and add the following: We've created as a Nunjucks template file, hence the file extension. This means we can use Nunjucks' double curly braces for using frontmatter variables. In our layout template we're calling and . Now, head back to the file you created earlier, delete the contents and add some front matter and some content: If you’ve kept 11ty running and the browser running it should look like this: A Basic Hello World HTML Page Using a Template Amazing! Now lets create the additional pages for our homepage. Create the following pages in the directory with the terminal or VSCodium: Open each of them up and add in some front matter and content: about.html: links.html: contact.html: You should now be able to browse each of these pages if you kept 11ty running on the following urls: Great stuff, but that’s no use without a navigation! Let’s take a look at and create a shared , , and to bring our homepage together. In the terminal cd into and create three partial files: Open each of them up and add some content: header.njk: navigation.njk footer.njk: Once our partials are created, open again and update it to include our new elements and partials: If you’ve kept 11ty running and the browser running it should look like this: A Basic Hello World HTML Page Using a Template and Partials Amazing! Now lets add the blog. Blog posts are mostly prose, so this is where Markdown earns its keep. We’ll write the posts as files and let 11ty turn them into pages. Create a new directory in the directory and cd into it: Create the following files in the directory with the terminal or VSCodium: Awesome, Open each of them up in VSCodium and add the following: my-first-post.md : my-second-post.md : my-third-post.md We better create a blog layout so it renders! Head back to the directory to create a new layout file: Open up in VSCodium and add the following: Check that your blog posts are loading: Amazing right? But to make it a blog, we need a blog page that lists all of our blog posts. We can do this with a collection: Open again and add a key called with a value of : Now 11ty has created a collection called and all we have to do is list it. Head back to the directory and create a file: Open it and add the following: If you’ve kept 11ty running and the browser running it should look like this: A Basic Blog List Page Amazing huh? Great, so far we have a fully functional home page, but it doesn’t look quite right. We need a style sheet. You can use the one below as an example, it’s basic styling with some modern techniques, or just throw in your own! Create a new directory in , cd into it and create : Open in VSCodium and add the following: styles.css: Now we need to include the style sheet in our layout file. Open it up and add to the : _includes/base.njk: You would have noticed that the stylesheet hasn’t been applied, we have to do one more thing in , something called file passthrough copy. Open in VSCodium and add the following: Because this will come up we may as well create the directories and add in the configuration for our images, fonts and JavaScript files. Create the following directories in : Update again: Just make sure you put all your static files in the appropriate directory and you’ll be good. So finally, if you’ve kept 11ty running and the browser running it should look like this: A Nicely Styled Homepage Yours will look a little different depending on the colours and fonts you chose above. Now we have a homepage we’re happy with, let’s get it online. There are two ways to get your site onto Neocities. We’ll start with the simplest, pushing it from your terminal by hand, then automate it so a deploy happens every time you commit. Whichever method you choose, first build a fresh copy of your site: This writes the finished HTML, CSS and assets to the directory. That’s the folder we deploy. Neocities provides a command-line tool that lets you push your site straight from your terminal. It’s a Ruby gem, so you’ll need Ruby installed. The first time you run a command it’ll ask for your Neocities username and password, then store an API key locally so you don’t have to log in again. Push the contents of your directory: That’s it, your homepage is live. For a lot of people this is all you need. Build, push, done. Pushing by hand is fine, but it’s even nicer to have your site rebuild and deploy itself every time you commit a change. We can do that with Forgejo Actions , the built-in CI for Forgejo. If you self-host Forgejo this runs on your own runner; if you don’t self-host, Codeberg offers the same thing (more on that below). First, push your project to a repository on your Forgejo instance. Then grab your Neocities API key from your account settings (Manage Site Settings → API Key) and add it to your repository as a secret named (Repository → Settings → Actions → Secrets). Now create a workflow file at : A few things to note in this workflow: Commit and push the workflow file. From now on, every push to rebuilds your site and deploys it to Neocities automatically. If you don’t run your own Forgejo instance, Codeberg is a free, community-run home for your code and runs the very same Forgejo Actions. The workflow file above works as-is. Push your project to a Codeberg repo, add the secret in the repository settings, and you’re away. You may need to enable Actions for your repository first; see the Codeberg CI documentation for details. Already have a homepage you’ve been hand-coding on Neocities? You don’t have to start from scratch. Eleventy is happy to take what you’ve got and slot it into this structure. Copy each existing page into (your old becomes , and so on). Then move the parts every page repeats, the , header, nav and footer, into and the partials you built earlier. Delete that boilerplate from each page and add a little front matter at the top: Whatever’s left in the file is just that page’s own content, and the layout wraps it. Your CSS goes in , images in , and fonts in . The passthrough copy we set up earlier ships them straight to . If a page is mostly writing, paste the body into a file instead of . Any fiddly HTML, like an embed or some custom markup, can stay exactly as it is and 11ty will render the Markdown around it. Run , check looks the way you expect, then push it live with the Neocities CLI or your Forgejo Actions workflow. Same site you already had, now with layouts, partials and a build step doing the repetitive work for you. Reference: I created the original version of this guide based heavily on these existing guides, and they’re still well worth a read: Without these, I wouldn’t even know how to write down what I needed to. Hey, thanks for reading this post in your feed reader! Want to chat? Reply by email or add me on XMPP , or send a webmention . Check out the posts archive on the website. You have a basic understanding of HTML and CSS You have a basic understanding of the command line and terminal You have Node.js installed (version 18 or newer) You're using VSCodium as your editor You have a Neocities account You have somewhere to keep your code: a Forgejo instance or a Codeberg account http://localhost:8080/blog/my-first-post/ http://localhost:8080/blog/my-second-post/ http://localhost:8080/blog/my-third-post/ picks the runner label. This is the default on Forgejo and Codeberg. Actions are referenced by their full URL. The checkout and setup-node actions come from , so we stay off GitHub for those. The deploy step uses , which is hosted on GitHub. We're only using it. Your code still lives on Forgejo or Codeberg. The option removes remote files that aren't in your new build, the same as on the CLI. Create Your First Basic 11ty Website Itsiest, Bitsiest Eleventy Tutorial

0 views

Plugins case study: Pluggy

Recently I came upon Pluggy , a Python library for developing plugin systems. It was originally developed as part of the pytest project - known for its rich plugin ecosystem - and later extracted into a standalone library. You're supposed to reach out for Pluggy if you want to add a plugin system to your tool or library and want to use something proven rather than rolling your own. In this post I will share some notes on how Pluggy works, and will then review how it aligns with the fundamental concepts of plugin infrastructures . Pluggy is built around the concept of hooks : functions that host applications or tools (from here on, just "hosts") expose and plugins implement. A host exposes hooks by using a decorator returned from pluggy.HookspecMarker and a plugin implements this hook using a decorator returned from pluggy.HookimplMarker . Pluggy's documentation explains this fairly well; in this post, I'll show how to implement the htmlize tool with some plugins, introduced in the original article in my plugin series . As a reminder, htmlize is a toy tool that takes markup notation similar to reStructuredText, and converts it to to HTML. It supports plugins to handle custom "roles" like: As well as plugins that do arbitrary processing on the entire text. Out host defines two hooks: A hook is created by calling HookspecMarker with the project's name. This project name has to match between the host and its plugins. Pluggy is permissive about what hooks accept as parameters and what they return; for maximal flexibility and to stay true to the original htmlize example, our hooks return functions. To accompany this HookspecMarker , the host also defines a HookimplMarker with the same name: This is used by plugins to attach to hooks when they're loaded. The host's main function loads plugins at startup as follows: hookspecs is our Python module containing the hooks shown above. load_setuptools_entrypoints is Pluggy's helper for loading plugins that were pip -installed into the same environment and registered as setuptools entry points . It's a way to signal - in one's setup.py or pyproject.toml file - some metadata that projects can review at runtime. In our project, the plugins register themselves with this section in the pyproject.toml file: This says "for entry point htmlize , define a new entry named tt ". Pluggy's load_setuptools_entrypoints then uses importlib.metadata to access this information. Note that Pluggy doesn't require using this mechanism. Hosts can implement any plugin discovery method they want, and add plugins directly to their PluginManager with the register method. But this is the mechanism used for pytest and many other projects; it makes it very easy to automatically discover and register plugins that are installed with pip and equivalent tools. Once PluginManager loads the plugins, invoking them is straightforward; here's how htmlize invokes the contents hooks [1] : Generally, hook invocations return a list of all the hooks attached to by different plugins (a single host application can have multiple plugins installed and attaching to the same hook). When the host invokes the hook as shown above, the default order is LIFO, but plugins can affect this with hook options like tryfirst and trylast . Here's our entire narcissist plugin that's attaching to the contents hook: Some notes: Let's see how this case study of Pluggy measures against the Fundamental plugin concepts that were covered several times on this blog . It's important to remember that Pluggy is not a specific host application with a bespoke plugin system; rather, it's a reusable library for creating such plugin systems. Therefore, this is more of a meta case study. Generally, Pluggy leaves discovery logic to the user's discretion. Its PluginManager has a register method for adding plugins, and these can be discovered in any way the application chooses. That said, Pluggy comes with one discovery mechanism built in - through the entry points process of Python packaging, as shown above. This is hugely convenient for a large number of applications, as long as both the application and its plugins are installed via standard Python packaging tools (which is a very reasonable assumption in the Python ecosystem). In the entry point process, plugins register themselves by adding a [project.entry-points.<HOST-ID>] section in their pyproject.toml file. Otherwise - as in the previous section - users are free to devise their own registration schemes. This one is easy, since it's called hooks in Pluggy parlance as well! Pluggy's implementation of hooks is rather elegant, with function decorators available for plugins to set. We've seen an example of this above with @htmlize.hookimpl decorating htmlize_contents . Since Pluggy is designed for Python hosts and Python plugins, this one is fairly straightforward. The plugins typically assume the host project is already installed in the Python environment and its modules can be imported. In our example, hookimpl is imported from htmlize by the plugin to accomplish this. It also shows how host data is passed to the plugin - the post and db parameters. These are APIs exposed by the host for the plugins' use. In footnote 2 of my original fundamental concepts of plugin infrastructures post, I wrote [2] : I still believe my statement is true - plugin frameworks are very easy to create, and the functionality they provide is relatively small compared to their large surface area. In other words, this is a shallow API . That said, Pluggy does provide some nice functionality for the more advanced uses of plugins: Are these worthwhile for your project? It really depends on the project, and it's always worth keeping the tradeoff between dependencies and project effort in mind. The full code repository for this post is available here . It expects htmlize to be installed; as discussed previously, we rely on Pluggy's default install-based approach where both the host and plugins are installed into the same Python environment and can thus find each other. However, Pluggy supports any custom discovery method. It uses the hookimpl exported value shown earlier. It returns a function that acts on contents; this is the htmlize -specific contract (ABI, if you will) we've discussed before. Automatic entry point registration mechanism - if you need it Signature validation Consistent plugin result collection across multiple hook attachments in a single plugin and across many plugins Plugin ordering with firstresult , tryfirst , trylast , etc. Hook "wrappers" for some special use cases

0 views
Ankur Sethi 1 months ago

So you want to write a GUI framework

Link: https://www.cmyr.net/blog/gui-framework-ingredients.html There are a handful of technical blog posts in my bookmarks that made me go oh, I never thought of it that way when I first read them. I'm talking about posts like Parse, don't validate , Text Editing Hates You Too , Choose Boring Technology , or Making illegal states unrepresentable . I consider these required reading for any working programmer. To me, Colin Rofls' So you want to write a GUI framework falls into the same category. Before reading this post, I'd never considered how much work goes into building a GUI framework. There's a reason even trillion-dollar megacorporations use web technologies to build their apps , ship buggy frameworks year after year , or drop support for platforms with no concern for their users. Building a brand-new GUI framework in 2026 is a long slog, and you don't get to reap the fruits of your labor until you've solved every single problem on Colin's list. Colin writes : Regardless of the specifics, there is one major dividing line to recognize, and this is whether or not a framework is expected to integrate closely into an existing platform or environment . On one side of this line, then, are tools for building games, embedded applications, and (to a lesser degree) web apps. In this world, you are responsible for providing almost everything your applications will need, and you will be interacting closely with the underlying hardware: accepting raw input events, and outputting your UI to some sort of buffer or surface. (The web is different; here the browser vendors have done that integration work for you.) On the other side of this line are tools for building traditional desktop applications. In this world, you must integrate tightly into a large number of existing platform APIs, design patterns, and conventions, and it is this integration that is the source of most of your design complexity. In general, a game or an embedded application is a self-contained world; there is a single ‘window’, and the application is responsible for drawing everything in it. The application doesn’t need to worry about menus or sub-windows; it doesn’t need to worry about the compositor , or integrating with the platform’s IME system. Although they maybe should , they often don’t support complex scripts . They can ignore rich text editing. They likely don’t need to support font enumeration or fallback . They often ignore accessibility. He goes on to enumerate all the integration points a GUI framework has with its host platform, including windowing, menus, 2D graphics, text rendering, accessibility, user input, and a bunch more. Each of these problems is hard on its own, but to build a GUI framework that people will want to use, you must solve all of these problems simultaneously . A few surprising things that stood out to me from the post: We don't have too many viable cross-platform GUI frameworks today, especially if you want to target desktop computers. It takes too much time, money, and specialized expertise to build one. If I was starting a desktop app business today, there are only two frameworks I'd feel comfortable relying on: Electron and Qt. Nothing else is mature enough. Dropdowns and select menus are actually tiny windows. If they weren't, they would be constrained to live inside your app's main window. You can see this in action when a web application cobbles together a custom select box using a bunch of s. Those custom selects can never overflow the boundaries of your browser. Building an abstraction that supports all the different 2D drawing APIs across platforms (CoreGraphics on Mac, Direct2D on Windows, Cairo on Linux, etc.) is difficult. To get around this, many cross-platform apps bundle Skia, which adds ~17MB to the application's binary. The article is from 2021, so that footprint is probably larger now. GPUs are built to render 3D scenes, which makes them worse at rendering 2D scenes. Rendering 2D scenes on GPUs is an area of active research. If you only ever write English, you've probably never thought about IME s. I write Hindustani and Punjabi, and broken support for the macOS IMEs for those languages immediately tells me that an app is built using a non-native GUI framework. Replicating the native behavior and conventions of a platform is difficult but possible. Replicating the native appearance of a platform—down to the animation curves, gradients, border radii—is a fool's errand. In my opinion, if you're building a cross-platform app, it's better to have it look completely alien than trying to mimic the platform's native widgets. But not respecting the platform's conventions for things like drag and drop, scroll acceleration, etc. is nonnegotiable.

0 views
Shayon Mukherjee 1 months ago

Building a tiny FUSE filesystem

Lately I have been working around sandboxing, storage, and networking, and a lot of that work keeps coming back to files, which makes sense since Unix has organized itself around everything is a file for over fifty years. Your terminal and random number generator are device files you can open and read (/dev/tty, /dev/urandom), and even network sockets, which are created with their own system call rather than opened by path, are read and written through the same interface afterwards.

0 views
Ankur Sethi 1 months ago

Using SwiftUI to Build a Mac-assed App in 2026

Link: https://pfandrade.me/blog/mac-assed-swiftui-app/ Paulo Andrade , creator of Secrets and Shopie : There was a time when Mac apps felt unapologetically Mac. Panic, Omni, Cultured Code, Bare Bones, Sofa. The years just before the iPhone SDK were probably peak Mac-assedness. Then Apple's center of gravity shifted toward the iPhone. Now we have Electron, Catalyst, and iPadOS apps on the Mac. And even Apple's SwiftUI apps often sand off the very behaviors that made Mac software feel great in the first place. SwiftUI was announced at WWDC in 2019, almost exactly 7 years ago now. It was meant to be a unified toolkit that would allow you to build apps for Mac, iPhone, iPad, Apple Watch, and any future platforms Apple might release. Most Apple developers would agree that SwiftUI has failed to deliver on that promise. In fact, Paulo's post is not the first I've read about SwiftUI's various inadequacies. Michael Tsai recently made a list of grievances professional SwiftUI developers have with the framework . I've been personally interested in getting back into building native Mac apps since at least the COVID lockdowns. But every time I've asked for advice on whether I should learn SwiftUI or AppKit, I've been met with the same answer: learn both. For somebody who has a full-time job and somewhat of a social life, this is untenable. It's just not possible for me to learn two new UI frameworks just as a cost of entry into the Apple developer ecosystem, no matter how motivated or skilled I might be. Meanwhile, long-time Mac users complain that nobody builds native apps anymore. To be fair, diehard Mac users have always complained about this, but I believe this time their complaint has legs. I don't see too many native Mac apps being built in 2026. The old stalwarts are still going strong—BBEdit, Things, Transmit, iA Writer, and all the rest—but pretty much every recent app I've used is built on top of Electron. It's easy to point the finger at Electron and React, or at CXOs that want to hire cheap frontend developers over expensive native developers, or at developers themselves, but I feel Apple is at least partially to blame for the state of the ecosystem today. I don't want to invest my time in an incomplete and buggy UI framework, and I certainly don't want to learn two UI frameworks just to try my hand at building a native app. I suspect most developers feel the same. Paulo ends his post with: You can see the result everywhere. SwiftUI is productive, modern, and often delightful, right up until you try to make a really good Mac app. Then suddenly you're fighting the framework for things the Mac solved 20 years ago. WWDC starts in two hours from the time I'm writing this post. Perhaps today we'll see some announcements that address some of these issues? Perhaps the Apple of 2026 will finally catch up with the Apple of 2006 in terms of software quality? Whether Apple cleans up their mess or not, Electron exists today and works fine . It lets you get your work out the door and into the hands of your users. It lets you build your business without worrying about what Apple will or will not do. As does React, which hasn't changed significantly since SwiftUI was announced.

0 views
Kaushik Gopal 1 months ago

OpenCode power user tips

In this post, I’d like to talk about some power user tips for OpenCode - an open source , model agnostic harness that more people should be using. Hopefully some of the advanced use cases convince you to give OpenCode (and OpenChamber ) a shot. intermediate to advanced tips only I am specifically choosing to talk about some advanced tips in this post. If you’ve never used an agent harness or are looking to learn how to use OpenCode, this post can be useful but reader beware. While (Ctrl + P) will list out all the possible commands (and is helpful), OpenCode has the concept of a “leader” key (which defaults to ). The leader key allows you to execute targeted useful commands more quickly and there’s a slew of useful ones pre-defined 1 . People reach for whole terminals and extra tooling to juggle between agent sessions. I too had an overly customized tmux setup that looked like this: OpenCode simplifies this. Just hit and you view current sessions and can instantly switch to that session by just selecting it from the list. The ability to quickly rename a session from this view is a godsend for me and what lets me be organized. session directory filtering you can pass a flag to when launching it, which filters the session list to just this workspace/directory by default. You can alternatively not pass that flag, and the session list will show all sessions. Forking takes the session you’re in and spawns a new one. You branch off into a separate conversation while the main agent keeps grinding on whatever you left it doing. I love this feature and even cobbled my own version with tmux long before most harnesses shipped it. Claude Code, Codex and other harnesses have caught up and support this feature. But OpenCode’s UX is the smoothest. You simply type in your chat. It gives you the option to fork the current chat or from a previous point in the message. You can then rename the forked session right from the list ui, and jump back and forth. The easy session switching again comes in handy here. Need to rewind to an earlier point in the same conversation? In OpenCode, there’s no escape-escape dance. leader g shows you a timeline and you can revert the conversation instantly, fork a new session from there, or just copy the message text. Probably one of the main reasons I find it hard switching away from OpenCode. I can bounce between GPT-5.5, Kimi K2.6, and Opus by just hitting 2 . change model & reasoning + switches the model on the fly. changes the reasoning type. I see a future where we will have smaller models we can run locally. OpenCode can point to that ollama model you have running on your own machine too. Click here if you’re curious about my model choices. Not everyone realizes this but OpenCode ships with LSP servers built-in . This means the coding agents inside OpenCode understand how to navigate different programming languages better. You’ll find less file search and grepping. Anthropic even recommends LSP server integration as an advanced move for making harnesses behave in large codebases. OpenCode gives you much of that for free. The other reason I swear by OpenCode: hit to cycle through custom agents. Here’s a few I use a lot: view the subagent work When an agent fans work out to subagents, + pulls up the subagent view so you can watch them work. Like others, you can use OpenCode for scripting and one-shot reviews: So up until now, I’ve mostly talked about features in the context of the TUI. My good friend YY recently introduced me to OpenChamber and it’s changed a lot of things for me. OpenChamber is an OpenCode GUI wrapper. OpenCode already has a web client btw. But OpenChamber has a lot of nice bells and whistles. But here’s the kicker, it’s using your same OpenCode server. In a previous post I dug into OpenCode’s server-client architecture: you run OpenCode as a server and connect multiple clients to it. A client can be a terminal tab, your phone, a desktop, a browser — each an isolated session pointed at the same server, fully synced. OpenChamber is just another client, but a super powered GUI one. This feature has taken the world by storm; especially since Codex introduced their implementation. OpenChamber gives you this feature for free with a super nice UX. One button click and either using or internally, it opens a secure 3 tunnel that you can connect your phone or another client to. So now, your phone controls OpenChamber and by proxy OpenCode exactly as you would from your computer. This was possible with OpenCode and tailscale too (as I mentioned in my previous post) but OpenChamber’s UX and secure tunnel approach makes this fluid. I almost never take my work laptop with me, when I’m getting out of the house now. Just speaking to my phone and a browser tab that has OpenChamber open. The other OpenChamber feature I lean on: multi-run. You have a prompt and want to try it across several models at once. I think Cursor was the first to introduce this feature. OpenChamber provides a super nice UI for this. This is how I’ve been kicking the tires on Opus 4.8 and updating my model choices . There’s just one caveat to be aware of. OpenChamber by default probes for a running OpenCode server. If it doesn’t find an OpenCode server there, it will silently spawn its own. So if you truly want all your sessions in sync, you should start your OpenCode server on port first, then open OpenChamber regularly and it’ll attach to the one you already have. I have a handy shell alias to just start a background OpenCode server now like so: If you didn’t read this tip in time, and need to kill previous OpenCode server instances, I suggest the handy procs cli command. There’s a lot more to both OpenCode and OpenChamber, but this is the stuff I reach for daily. The bit that’s stuck with me most is the one-server, many-clients setup — run a single OpenCode server and point everything at it: the TUI, OpenChamber, your phone. Steal whatever helps here, and if there’s a tip I’m sleeping on, send it my way. OpenChamber v1.12.0 tunnel bug Heads up: OpenChamber v1.12.0 added a headless web app mode, and remote instance switching now changes the OpenChamber API endpoint without loading the full remote UI. This seems to have busted the remote mobile tunnel setup I describe above. :/ The developer is responsive and working on a fix 🤞. Until then, I recommend sticking to v1.11.7 , which you can download manually. You can also bind commands that don’t have a predefined key. As an example, I bind the “Exit the app” command to so I can quit OpenCode quickly.  ↩︎ yes yes, you’re probably nuking your prompt/KV cache, but you shouldn’t have long running conversations anyway.  ↩︎ one-time + TTL + revocable connect link  ↩︎ + switches the model on the fly. changes the reasoning type. red-team — think differently from the implementer with an independent adversarial lens and hunt for failure modes. ghostwriter — drafts messages, posts with a less AI tropey voice. brainstormer — custom agent that’s explicitly tuned to help me brainstorm ideas, plans etc. pr-reviewer — strict reviewer that ignores past conversation and reviews with fresh eyes. kimi-coder — a coding agent guardrailed to Kimi: fast, cheap implementation. agent-kombat — see my agent-kombat post. I have it wired into a custom agent for quick use. You can also bind commands that don’t have a predefined key. As an example, I bind the “Exit the app” command to so I can quit OpenCode quickly.  ↩︎ yes yes, you’re probably nuking your prompt/KV cache, but you shouldn’t have long running conversations anyway.  ↩︎ one-time + TTL + revocable connect link  ↩︎

0 views
JSLegendDev 1 months ago

My Biggest Gripe With YouTube

3 years ago, I started a YouTube channel called JSLegendDev where I uploaded tutorials teaching the JavaScript programming language through the development of 2D games. The state of the space around the time I started was as follows : Tutorials inferior to an hour in length were not in demand. They made very little views. Tutorials divided into multiple parts where dead on arrival. You were guaranteed dwindling views on every new upload. To adapt, other content creators started uploading longer, multi-hour, often project based tutorials which translated to more views. Seeing the shift, I also decided to follow suit and uploaded tutorials reaching the 4-10 hour mark. I saw some success doing this. Therefore, I kept at it for a while. However, as time passed, I got tired of recording extremely long tutorials and they, in general, started to make less views. There are many hypotheses as to why YouTube’s algorithm started serving tutorial content less. The advent of AI could’ve been the likely cause but also a general shift in YouTube becoming more of an entertainment focused platform to the detriment of educational content. Something you now put on TV to relax. In the programming space, channel producing content that can be watched passively like tech news, tech drama, tech history, high level discussions, etc… continued to thrive. Seeing this new shift and because I was genuinely tired of making YouTube tutorials, I published my first scripted video titled “How do Devs Make Levels Without Game Engines” which was first published as an article. In that piece, I told the story of how I discovered a convenient way to design levels for my games using an external editor called Tiled in conjunction with my editor-less game framework. At the end of that video, I promoted a paid tutorial I made teaching the exact steps needed to achieve what was presented. The video ended up accumulating over 30k views, which was pretty great! It took far less effort to make compared to my multi-hour tutorials and I was able to make a few sales on my paid tutorial I mentioned within. Previously, I was very unsuccessful in selling any paid courses and I didn’t quite understand why. However, the answer now hit me like a truck. Why would anyone still have the appetite for a paid course after having invested the time following a free multi-hour course? Even if the subject of the paid offering was different, they would probably be too tired to commit to another one. Anyway, following in the footsteps of this first breakthrough, I uploaded another scripted video titled “You Can Now Make PS2 Games in JavaScript” which was again first published as an article. In that video, I told the story of how I discovered that you could make PS2 games in JavaScript and provided an overview of how the viewer could get started. Despite including very practical knowledge, the viewer was never expected to follow along and therefore could watch it passively. It was a resounding success, over 100k views! Unfortunately, I didn’t sell any courses in that video because I simply didn’t have the energy to both make the video and a course. The best business decision would have been to wait before uploading. I’ll go into more details later, but my biggest gripe with YouTube is that it’s no longer a great platform to build an audience but rather it’s only good for reach and here, I had wasted a lot of reach. After having made so many game development tutorials, I wanted to try my hand in creating an original game that I would sell on Steam. Once the project was starting to take shape, I had the idea of making a video about it to gauge interest as I wasn’t sure it would find an audience. Therefore, I had the idea of using the same format used in my two previous successful videos. However, rather than focusing on technical details, I instead would tell the story of how I came up with my game’s design covering the various iterations and challenges I faced while working on it. Therefore, I ended up uploading a video titled “Making a Small RPG” which again, was originally an article. It was also a resounding success reaching barely below 100k views! However, it came with a hidden cost. That cost was the tipping point that made me realize that YouTube is no longer a good platform to build an audience on. I naively thought that if the video performed well, this would translate to subscribers and an audience eager to hear more about the project, but this wasn’t the case. I had made a big mistake by not setting up a Steam page to direct viewers to before publishing the video. On my next upload concerning the project, the fall off in terms of views was brutal. I went from 98k views to below 10k. It became clear that YouTube was acting as a gatekeeper between me and the audience I thought I had built. After reflecting on the situation, I came to the following conclusion. The reason my 3 previous videos had performed well was due having certain characteristics that aligned with YouTube’s goal as a platform, which consists in making people watch videos for as long as possible so they can serve more ads. I listed them below : The subject of all three videos were remarkable which lead to people clicking on them. Something is remarkable when it obviously stands out as being interesting/noteworthy. For example, the subject of my video titled “You Can Now Make PS2 Games in JavaScript” is remarkable because the PS2 is a very popular, but now old console and you had to use a hard programming language called C++ to make games for it. Being able to now use JavaScript, a simpler but most importantly, a language originally designed for making websites and not games, makes the subject come across as immediately noteworthy. Therefore, remarkable. The use of storytelling made people eager to watch more of the video. This can be explained by the fact that we instinctively want to know what happens next in a compelling story. Finally, the length of the videos were all above 10 minutes and the 2 more successful ones were in the 15+ min range. This resulted in more absolute watch time compared to shorter content. For example, if 2 videos are both watched fully by the same audience. The shorter one will translate to less total time spent on the platform compared to the longer one. Therefore, YouTube will recommend the longer one instead because there’s an opportunity cost to doing otherwise. To understand the fall off, it’s important to first mention that usually, series on YouTube don’t work. The second video of a series ends up making less views than the first because it requires prior context before clicking. Thus reducing its appeal and limiting its reach. However, I knew this going in. I tried making the second video as independent as possible but in the end, a second video talking about the same subject was bound to be less remarkable. It didn’t help that because I summarized the content of the first video in the second one, a familiar viewer would have found it less engaging making the video further away from hitting criteria 2 and 3 that I outlined above. Consequently, I realized I had wasted my biggest marketing ammunition regarding my small RPG game as I had no way to contact the audience hit by the first video. Like with the one on making PS2 games in JavaScript, I had wasted tremendous reach. At this point, I realized my biggest gripe with YouTube was simply that I could not access my audience reliably. Therefore, was it really my audience? On one hand, YouTube allows someone without a following to reach millions but on the other, the link to those reached is fickle. I thought I was building an audience by gaining subscribers but instead, I was building a sand castle that could easily be carried away by the slightest algorithm waves. YouTube wasn’t always like this. People used to subscribe to channels and seek their content in their subscriptions tab. However, the platform effectively buried this model by conditioning users to seek recommended videos on the home page and deprioritizing the Subscriptions tab to the point that it barely looks like a clickable section. You have to click on the “Subscriptions” text to access your sub feed. Doesn’t look very clickable doesn’t it? I think that we’re now entering an era where YouTube is starting to treat content creators as interchangeable much like TikTok. They saw the success TikTok had, tried to replicate it with Shorts and now YouTube long form is getting affected as well. I fear that in the future, uploading to YouTube will look no different than making posts on Reddit. You might get views, you might get comments, but they’re self contained to a specific post with no following building up and no guarantee of your next posts having the same reach. The conclusion to all of this is that it’s not worth it to be a YouTuber. Relying on YouTube adsense and sponsorships (sponsors use views as a metric to determine how much to pay you) for your livelihood is simply not sustainable due to how fickle getting views on the platform is. Therefore, focusing so much on making YouTube content will most likely lead to your exploitation. That said, is quitting really the answer? Considering that YouTube can give you incredible reach even if you’re a nobody as long as you make content that is remarkable, engaging (for example, through storytelling) and long enough, it would be stupid to completely walk away, at least in my case. Therefore a new strategy appears on the horizon. It consists in building your audience outside of YouTube through a mailling list (Substack conveniently allows you to do so) and to strategically make occasional compelling YouTube content to tap into the platform’s reach potential. However, the key is to always direct viewers to the mailling list. Why is building an audience through email so important? because it allows you to have a direct and long lasting link with your audience. It also gives you independence from social media platforms. Even in the case of Substack, where this article is currently hosted, I can export my email list and move to another platform or email sending service without my subscribers even noticing. This shift implies that I no longer need to worry about pumping frequent content for YouTube because I’m not making money through them or worrying about doing so. By making YouTube content rarely, I get to keep most of my energy to build something compelling outside the platform like an actual game, writing interesting articles, making an in-depth course or other kinds of art/products. This plan seems to me as more sustainable and more healthy long term. That’s about all I’ve got to share. Hope this article was insightful. If you’re curious to see where this journey will lead, I recommend subscribing! I usually write about programming, game development and game design. Subscribe now You can check some of my previous articles below. Tutorials inferior to an hour in length were not in demand. They made very little views. Tutorials divided into multiple parts where dead on arrival. You were guaranteed dwindling views on every new upload. The video ended up accumulating over 30k views, which was pretty great! It took far less effort to make compared to my multi-hour tutorials and I was able to make a few sales on my paid tutorial I mentioned within. Previously, I was very unsuccessful in selling any paid courses and I didn’t quite understand why. However, the answer now hit me like a truck. Why would anyone still have the appetite for a paid course after having invested the time following a free multi-hour course? Even if the subject of the paid offering was different, they would probably be too tired to commit to another one. Anyway, following in the footsteps of this first breakthrough, I uploaded another scripted video titled “You Can Now Make PS2 Games in JavaScript” which was again first published as an article. In that video, I told the story of how I discovered that you could make PS2 games in JavaScript and provided an overview of how the viewer could get started. Despite including very practical knowledge, the viewer was never expected to follow along and therefore could watch it passively. It was a resounding success, over 100k views! Unfortunately, I didn’t sell any courses in that video because I simply didn’t have the energy to both make the video and a course. The best business decision would have been to wait before uploading. I’ll go into more details later, but my biggest gripe with YouTube is that it’s no longer a great platform to build an audience but rather it’s only good for reach and here, I had wasted a lot of reach. After having made so many game development tutorials, I wanted to try my hand in creating an original game that I would sell on Steam. Once the project was starting to take shape, I had the idea of making a video about it to gauge interest as I wasn’t sure it would find an audience. Therefore, I had the idea of using the same format used in my two previous successful videos. However, rather than focusing on technical details, I instead would tell the story of how I came up with my game’s design covering the various iterations and challenges I faced while working on it. Therefore, I ended up uploading a video titled “Making a Small RPG” which again, was originally an article. It was also a resounding success reaching barely below 100k views! However, it came with a hidden cost. That cost was the tipping point that made me realize that YouTube is no longer a good platform to build an audience on. I naively thought that if the video performed well, this would translate to subscribers and an audience eager to hear more about the project, but this wasn’t the case. I had made a big mistake by not setting up a Steam page to direct viewers to before publishing the video. On my next upload concerning the project, the fall off in terms of views was brutal. I went from 98k views to below 10k. It became clear that YouTube was acting as a gatekeeper between me and the audience I thought I had built. After reflecting on the situation, I came to the following conclusion. The reason my 3 previous videos had performed well was due having certain characteristics that aligned with YouTube’s goal as a platform, which consists in making people watch videos for as long as possible so they can serve more ads. I listed them below : The subject of all three videos were remarkable which lead to people clicking on them. Something is remarkable when it obviously stands out as being interesting/noteworthy. For example, the subject of my video titled “You Can Now Make PS2 Games in JavaScript” is remarkable because the PS2 is a very popular, but now old console and you had to use a hard programming language called C++ to make games for it. Being able to now use JavaScript, a simpler but most importantly, a language originally designed for making websites and not games, makes the subject come across as immediately noteworthy. Therefore, remarkable. The use of storytelling made people eager to watch more of the video. This can be explained by the fact that we instinctively want to know what happens next in a compelling story. Finally, the length of the videos were all above 10 minutes and the 2 more successful ones were in the 15+ min range. This resulted in more absolute watch time compared to shorter content. For example, if 2 videos are both watched fully by the same audience. The shorter one will translate to less total time spent on the platform compared to the longer one. Therefore, YouTube will recommend the longer one instead because there’s an opportunity cost to doing otherwise.

0 views

Installing Non-Guix System

As a heavy emacs user, Guix system seems to be the logical place to rest one’s head in the perpetual distro hop . As an all-or-nothing type guy, I’ve been running NixOS for the better part of a year now, but Guix seems to philosophically align with me. That’s right, forget about pragmatism when you can have ideological purity! But, as one will very quickly comes to find in installing Guix, the militant push to have everything be free software means that if you are installing this system to anything other than a hardwired desktop running some sort of open source video card or a librebooted ThinkPad from 2008 , you will be very quickly out of luck when you want to say, use wifi or run any sort of video encode. If you have the ability to plug your machine into an ethernet port, then you can install guix without much issue, but - the moment ethernet is inaccessible, you have to do a workaround. The mitigation? Baking non-guix into an .iso image and installing from that instead. In this article, I will discuss how I got non-guix installed to my x230 ThinkPad, and the process to get there. Non-Guix is a repository of proprietary drivers and the mainline linux kernel so that you can run Guix on a system that doesn’t have 100% free hardware (which is essentially every computer these days). You won’t hear about it in the mainline Guix channels as it is ideologically in conflict with what Guix is attempting to do, so it’s a bit of a Fight Club type scenario (you don’t talk about it). With that being said, nvidia drivers for GPUs, wifi (broadcom and intel), and some other goodies are available within the repo, so all you have to do is add “non-guix” as a channel in your channels.scm: As we previously discussed, you will have a bad time when trying to install a base Guix .iso on your non-freedom respecting hardware. So, we will go ahead and bake a non-guix .iso for you so that you can use wifi to install your new system. You will install guix (the package manager) on your machine for this. Congratulations, guix is available on pretty much every linux distribution and MacOS. Installation instructions can be found here You can then update with . This will take a bit of time, so go get a coffee and let it run. to your file add: and run once again. You will also have to clone the non-guix repo locally: and then you can finally build the guix system .iso with Again, this will take some time. You will be given a location in which this .iso exists after building it in the /gnu/store when it finishes. Copy that path for the next step. You can now plug your USB key into your machine and we will burn in the image to the disk and make it bootable with : run to find the location of your plugged in drive, then: Congratulations, you now have a bootable non-guix .iso for installation! Reboot your machine and select the usb stick as your boot drive. You can then install guix as normal with the guided installer, when prompted for wifi connection, you can scan for networks and should be able to connect to your SSID without issue. You will have to change some of the generated configuration file before installation. Note the + and - lines: After this, save the file and STOP , you will NOT install normally you will hit Ctrl+Alt+F3 and open a new TTY (scary black screen with a prompt). Hit and type the following: and finally: The system will then install and you will get just one more coffee as you wait. Caffeinated enough yet? Upon completion, reboot, pull the USB stick, and you will have your new shiny Guix system with wifi driver support available. As always, God bless, and until next time. If you enjoyed this post, consider Supporting my work , Checking out my book , Working with me , or sending me an Email to tell me what you think. https://github.com/AidanWelch/guix-blog https://gitlab.com/nonguix/nonguix

0 views