Five Ways to Compute the Length of a Circular Arc
by
gg582 · 2026-09-22 10:47:51 · 101 views · 12 min read
Table of contents
Suppose we want to calculate the distance traveled along a right-turn road.
If the road consists of a sequence of circular arc segments, the total distance can be obtained by calculating the length of each arc and summing the results.
Let the chord length be c, the sagitta be v, the diameter be d, and the radius be r=d/2.
The exact arc length is
s=2r\arcsin\left(\frac{c}{2r}\right).
That gives us a reference value.
But it is not the only way to calculate an arc.
Historically, calculations involving chords and arcs have been approached through short approximations, series, and repeated geometric subdivision. Modern numerical computing adds another option: use a numerical implementation of a transcendental function directly, or use a truncated expansion when the required precision allows it.
The interesting question is therefore not which method is universally better.
It is how different calculation methods distribute computational cost, numerical error, and memory requirements. Across different domains and computational environments, similar trade-offs between precision and computation can emerge again and again.
This benchmark puts several such methods into the same Rust program.
The five methods
The first method is a short approximation:
s\approx c+\frac{2v^2}{d}.
The implementation requires only a few floating-point operations.
segment.chord
+ 2.0
* segment.sagitta
* segment.sagitta
/ segment.diameter
The second method is a chord-to-arc series.
It starts with the chord length and generates subsequent terms recursively:
T_0=c
T_n = T_{n-1} \frac{(2n-1)^2}{4(2n)(2n+1)} \frac{c^2}{r^2}.
The recurrence is implemented with a for loop.
let ratio = chord / radius;
let ratio_sq = ratio * ratio;
let mut term = chord;
let mut sum = term;
for n in 1..8 {
let odd = (2 * n - 1) as f64;
let even = (2 * n) as f64;
let next_odd = (2 * n + 1) as f64;
term *= odd * odd;
term *= ratio_sq;
term /= 4.0 * even * next_odd;
sum += term;
}
This is a modern floating-point implementation of the chord-to-arc series associated with Mingantu's work. The benchmark uses eight terms.
The third method repeatedly bisects the chord.
Starting with one chord,
c_0=c,
each subdivision doubles the number of smaller chords:
1\rightarrow2\rightarrow4\rightarrow8\rightarrow\cdots
The new chord length is obtained geometrically.
let mut small_chord = chord;
let mut count = 1usize;
for _ in 0..subdivisions {
let half_chord = small_chord * 0.5;
let center_to_midpoint =
(radius * radius
- half_chord * half_chord)
.sqrt();
small_chord =
(2.0
* radius
* (radius - center_to_midpoint))
.sqrt();
count *= 2;
}
count as f64 * small_chord
This implements the repeated chord subdivision associated with Liu Hui's circle method. The benchmark uses eight subdivision levels.
The fourth method is a modern second-order approximation:
s\approx c+\frac{8v^2}{3c}.
segment.chord
+ 8.0
* segment.sagitta
* segment.sagitta
/ (3.0 * segment.chord)
The fifth method directly evaluates the reference equation:
s=2r\arcsin\left(\frac{c}{2r}\right).
let radius = segment.diameter * 0.5;
let half_angle =
(segment.chord / (2.0 * radius)).asin();
2.0 * radius * half_angle
These five methods represent five different ways of spending computation: a short approximation, a series, repeated geometric refinement, a truncated modern expansion, and direct numerical evaluation.
Test environment
The benchmark was run on the following machine.
| Component | Specification |
|---|---|
| CPU | AMD Ryzen 5 5600X, 12 threads, up to 4.65 GHz |
| GPU | NVIDIA GeForce RTX 3070 LHR |
| Memory | 62.73 GiB |
| Distribution | Debian GNU/Linux 13.6 (trixie) x86_64 |
| Kernel | Linux 6.12.107+deb13-amd64 |
| Shell | bash 5.2.37 |
| Terminal | Konsole 25.4.2 |
| Desktop | KDE Plasma 6.3.6 |
| Window system | KWin / Wayland |
The program was compiled as an optimized native build:
rustc -C opt-level=3 -C target-cpu=native main.rs -o arc_bench
The benchmark was run with:
./arc_bench 100 1000 10.0 15.0 8
The arguments specify 100 arc segments, 1000 iterations, a 10.0 m chord, a 15.0 m radius, and eight subdivision levels.
The source defaults to 10,000 segments and 100,000 iterations, but the reported run explicitly uses the parameters above.
Road geometry
The generated road consists of 100 identical circular arc segments.
| Parameter | Value |
|---|---|
| Arc segments | 100 |
| Chord length | 10.000000 m |
| Radius | 15.000000 m |
| Diameter | 30.000000 m |
| Sagitta | 0.857864 m |
| Sagitta / chord | 0.085786 |
| Subdivision levels | 8 |
| Small chords per arc | 256 |
The sagitta is calculated as
v=r-\sqrt{r^2-\left(\frac{c}{2}\right)^2}.
With eight subdivision levels, the third method ultimately represents each original arc with
2^8=256
small chords.
Accuracy
First, the program prints the result for a single arc.
One arc
-------
exact : 10.195107283624 m
approx-1 : 10.049062085871 m
approx-2 : 10.195107280661 m
approx-3 : 10.195104289258 m
approx-4 : 10.196248343486 m
For the complete 100-segment road, the actual program output is:
One complete right-turn road
----------------------------
exact 1019.510728362 m abs error 0.000000 m rel error 0.000000%
approx-1 1004.906208587 m abs error 14.604520 m rel error 1.432503%
approx-2 1019.510728066 m abs error 0.000000 m rel error 0.000000%
approx-3 1019.510428926 m abs error 0.000299 m rel error 0.000029%
approx-4 1019.624834349 m abs error 0.114106 m rel error 0.011192%
The approx-2 absolute error is printed as 0.000000 m because the program prints the value to six decimal places.
The underlying difference is
1019.510728362-1019.510728066 = 0.000000296\ {\rm m},
or approximately 0.296 micrometers.
The other errors are directly visible in the program output:
| Method | Absolute error | Relative error |
|---|---|---|
| exact | 0 m | 0% |
| approx-1 | 14.604520 m | 1.432503% |
| approx-2 | 0.000000296 m | about 0.00000003% |
| approx-3 | 0.000299 m | 0.000029% |
| approx-4 | 0.114106 m | 0.011192% |
The important point is not that one method is simply "accurate" and another is "inaccurate." The relevant question is what error a particular application can tolerate.
A road-distance calculation, a simulation, a geometry engine, and a measurement system can have very different error requirements.
Performance
The benchmark performs
100\times1000=100,000
arc-length calculations for each method.
The measured output is:
Performance benchmark
---------------------
iterations : 1000
total calls : 100000
method time throughput RSS
exact 0.505 ms 197.915 M calls/s RSS 2596 KiB
approx-1 0.097 ms 1029.739 M calls/s RSS 2596 KiB
approx-2 0.866 ms 115.436 M calls/s RSS 2596 KiB
approx-3 3.648 ms 27.411 M calls/s RSS 2596 KiB
approx-4 0.097 ms 1029.103 M calls/s RSS 2596 KiB
The two short approximations are the fastest in this run. Both complete 100,000 calls in approximately 0.1 ms.
The direct asin() calculation takes 0.505 ms.
The series takes 0.866 ms.
The repeated subdivision method takes 3.648 ms.
The RSS reported by the process is 2596 KiB for every method.
That last number, however, is not sufficient for comparing the memory cost of the algorithms.
All five methods operate on the same road vector, so most of the process's resident memory is common to the entire benchmark. RSS therefore tells us about the process as a whole rather than the local state required by each calculation.
Local stack data
The source-level local data used by the calculation is another useful quantity to record.
The basic segment is:
struct ArcSegment {
chord: f64,
sagitta: f64,
diameter: f64,
}
An ArcSegment contains three f64 values:
3\times8=24\ {\rm bytes}.
The method selector and subdivision count are also passed into arc_length().
The relevant local data can therefore be summarized as follows.
| Method | Main local values | Local data size |
|---|---|---|
| approx-1 | segment, method, subdivisions |
33 B before alignment |
| approx-4 | segment, method, subdivisions |
33 B before alignment |
| exact | above + radius, half_angle |
49 B before alignment |
| approx-2 | above + series state | about 97 B before alignment |
| approx-3 | segment, method, subdivisions + radius + small_chord + count + loop temporaries |
about 81 B before alignment |
These figures are source-level local data footprints, not measurements of the final machine-code stack frames.
That distinction matters.
For example, f64 occupies 8 bytes as a Rust value, but the compiler may keep it in an XMM register rather than putting it on the stack. A local variable can also be optimized away entirely. Conversely, register spills can introduce stack slots that are not obvious from the source code.
Therefore the numbers above should not be read as "the exact number of bytes allocated on the stack by the CPU."
They answer a narrower question: how much local scalar state does the source-level algorithm introduce before compiler optimization?
For this benchmark, that distinction is more informative than RSS alone because the input vector is shared by every method.
Memory is part of the trade-off too
Once local state is included, the comparison has three dimensions rather than two.
There is numerical error.
There is computational time.
There is also the amount of state that the calculation needs to maintain.
The first and fourth approximations have very little local state and require only a few arithmetic operations.
The exact calculation adds the intermediate radius and half-angle.
The series method maintains several intermediate values for the recurrence:
ratio
ratio_sq
term
sum
odd
even
next_odd
n
The subdivision method similarly maintains the current small chord, the chord count, and intermediate geometric quantities.
None of these differences is large enough to affect the RSS of this particular program. The measured RSS remains 2596 KiB because the dominant process state is shared.
But when the same calculation is embedded in a much larger system, repeated millions of times, or executed in a memory-constrained environment, local state and register pressure can become relevant alongside arithmetic cost.
This is particularly important when considering the original historical problem. A calculation method does not exist in isolation from its computational environment.
A calculation is a choice about resources
The benchmark shows several different points on the same general curve.
The first approximation uses very little arithmetic and accepts an error of about 1.43% in this particular geometry.
The fourth approximation uses a similarly small amount of local state and reduces the relative error to about 0.0112%.
The series method performs more arithmetic and reaches an error of approximately 0.296 micrometers over the complete test road.
The subdivision method uses 256 small chords per original arc and reaches an error of approximately 0.299 mm.
The direct calculation uses a numerical implementation of asin() and, in this environment, is faster than the explicit series.
None of those results makes one method universally preferable.
If an application only needs a rough estimate and evaluates the expression extremely frequently, a short approximation may be appropriate.
If the error budget is tighter, more computation can be spent.
If a calculation is not performance-critical, the direct mathematical expression may be preferable simply because it is easier to maintain.
If the algorithm is intended to run in a constrained environment, the number of operations and the amount of local state can both become design parameters.
The point is the structure of the decision, not a single winner.
Why the historical examples are interesting
The historical methods are useful here because they provide concrete examples of this same type of decision.
A short approximation reduces the calculation to a small number of operations.
A series replaces the target quantity with a sequence of terms.
Repeated subdivision replaces a curved object with a collection of simpler geometric objects.
A modern truncated expansion keeps only the terms needed for a specified approximation.
A numerical library function packages a more complicated numerical procedure behind a single operation.
These are different mathematical constructions, but they all answer a similar computational question:
How much calculation should be spent to obtain the required result?
The answer depends on the domain.
A method that is reasonable for one application can be inappropriate for another. A one-percent error may be irrelevant in one context and unacceptable in another. Likewise, an additional multiplication or square root may be irrelevant on a desktop processor but important inside a heavily repeated computation or a constrained device.
This is why the historical examples should not be turned into a contest between "old" and "modern" mathematics.
Their value is that they give us actual examples of calculation strategies developed in particular mathematical contexts. When reconstructed and benchmarked today, those strategies make a recurring pattern visible: different domains can converge on different ways of balancing precision against computational cost.
The benchmark implementation
Each arc segment is represented by three floating-point values:
#[derive(Clone, Copy)]
struct ArcSegment {
chord: f64,
sagitta: f64,
diameter: f64,
}
The methods are selected through an enum and dispatched by arc_length().
The complete road is evaluated by iterating over the segments:
fn route_length(
road: &[ArcSegment],
method: Method,
subdivisions: usize,
) -> f64 {
let mut total = 0.0;
for &segment in road {
total += black_box(
arc_length(
segment,
method,
subdivisions,
),
);
}
total
}
The benchmark uses Instant for timing and black_box to prevent the optimizer from eliminating the calculations being measured.
RSS is read from /proc/self/status through VmRSS.
The local stack data figures discussed above are derived from the source-level variable types rather than from the runtime RSS measurement. A precise machine-code stack-frame measurement would require inspecting the compiler-generated code and its stack metadata, and would be specific to the compiler, target, optimization level, and inlining decisions.
Conclusion
A circular arc is a small mathematical problem, but it contains a general problem in numerical computation.
There may be an exact formulation. There may also be shorter approximations, series, iterative constructions, or library functions that reach different levels of accuracy with different amounts of computation and local state.
The historical examples make this pattern especially visible.
The relevant question is not whether an old method can outperform a modern CPU. It cannot be meaningfully separated from the computational environment for which the method was developed.
The useful comparison is instead the structure of the calculation.
One method spends fewer operations and accepts more error.
Another spends more operations to reduce that error.
Another changes the representation of the problem itself, replacing a curve with many simpler pieces.
A modern approximation may reduce the calculation again by retaining only the terms that matter for the desired precision.
And a modern numerical library may make a formerly complicated calculation available as a single function call.
This is a recurring pattern in numerical work.
Different domains, different hardware, and different error requirements can lead to different practical points on the same precision-versus-computation curve. In that sense, the history of numerical calculation offers a collection of concrete cases where precision and computational cost were balanced in different ways.
The point of this benchmark is not to declare one of those methods the winner.
It is to make that recurring trade-off measurable.